Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41779cf7bb | ||
|
|
c36a6f9f90 | ||
|
|
6eaf7458ab | ||
|
|
ad30a2350e | ||
|
|
4b3b48388c | ||
|
|
0d99d33c38 | ||
|
|
bbfad166b0 | ||
|
|
e95e32d72a | ||
|
|
6fa9e0d2dd | ||
|
|
8a6332be83 | ||
|
|
4674f0a61c | ||
|
|
6fbb1c5d91 | ||
|
|
d05fe3d4de | ||
|
|
56622e4be8 | ||
|
|
4dc638109b | ||
|
|
d7a4a29ce3 | ||
|
|
9fbbf8dc3a | ||
|
|
5fa8e7d60a | ||
|
|
b01b9689c8 | ||
|
|
4426da6f5d | ||
|
|
5ead82e627 | ||
|
|
c29b0d2f13 | ||
|
|
480fd8d517 | ||
|
|
4c09fa5355 |
No files matched your search
Generated
+15
-15
@@ -3445,7 +3445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"block2 0.6.2",
|
||||
"eframe",
|
||||
@@ -3472,7 +3472,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-cli"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3481,7 +3481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-derive"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3490,7 +3490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-doc"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3507,7 +3507,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-engine"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -3523,7 +3523,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-geom"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3532,7 +3532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"naga",
|
||||
@@ -3545,7 +3545,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-io"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"serde",
|
||||
@@ -3560,7 +3560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-kernel"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -3578,7 +3578,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-mcp"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
@@ -3586,7 +3586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-render"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"png",
|
||||
@@ -3597,7 +3597,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-sketch"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3608,7 +3608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-ui-egui"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -3629,7 +3629,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "solidcraft-web"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5107,7 +5107,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
exclude = ["vendor"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
+6
-1
@@ -21,6 +21,8 @@ offsets and plastic face placements, XZ (+Y normal) with a v6 migration preservi
|
||||
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).
|
||||
@@ -51,7 +53,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
|
||||
| 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; optional compact named left feature history with rollback bar, multi-selection and persistent folders above the parts browser), 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; taper handle open), 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; 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), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep rails, twist and scale (#771, loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | deferred | — |
|
||||
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis. Proposed, in order: surface Extrude/Revolve/Sweep/Loft from open and closed curves (#757), Offset surface (#758), Unstitch and Reverse Normal (#766), Delete Face without healing (#768); Extend curved faces and edges (#760), mutual trim and trim by projected curves (#767), Untrim (#761), Thicken every surface kind (#765); Ruled (#762), Boundary surface and curve networks with G0/G1/G2 (#763), Patch with G1/G2 and general loops (#759), Blend surface (#764), sweep guide rails and two-rail sweeps (#771; orientation, twist and end scale done; loft rails #201), fillet between surface bodies (#769); continuity analysis, surface curvature combs and isocurves (#770) | 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), 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), Exploded views (automatic or by hand, shown without changing placements); 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 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 in Fusion's layout 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 | — |
|
||||
@@ -336,6 +338,9 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
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.
|
||||
- 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.
|
||||
|
||||
+11
-1
@@ -333,6 +333,12 @@
|
||||
"ui.save_as": "Speichern unter",
|
||||
"ui.save_as_ellipsis": "Speichern unter...",
|
||||
"ui.scale": "Skalieren",
|
||||
"ui.orientation": "Ausrichtung",
|
||||
"ui.twist": "Verdrehung",
|
||||
"ui.end_scale": "Endskalierung",
|
||||
"ui.sweep_turn_with_path": "Mit Pfad drehen",
|
||||
"ui.sweep_keep_as_drawn": "Wie gezeichnet",
|
||||
"ui.sweep_follow_direction": "Richtung folgen",
|
||||
"ui.section_analysis": "Querschnittsanalyse",
|
||||
"ui.select": "Auswählen",
|
||||
"ui.selection": "Auswahl",
|
||||
@@ -594,6 +600,7 @@
|
||||
"cmd.parameters_delete": "Parameter löschen",
|
||||
"cmd.parameters_export": "Parameter exportieren",
|
||||
"cmd.parameters_favorite": "Favorisierter Parameter",
|
||||
"cmd.parameters_move": "Parameter verschieben",
|
||||
"cmd.parameters_graph": "Parametergraph",
|
||||
"cmd.parameters_import": "Parameter importieren",
|
||||
"cmd.parameters_list": "Parameter auflisten",
|
||||
@@ -896,5 +903,8 @@
|
||||
"Mitre": "Gehrung",
|
||||
"Reference face": "Referenzfläche",
|
||||
"Round": "Rund",
|
||||
"Type": "Typ"
|
||||
"Type": "Typ",
|
||||
"joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.",
|
||||
"joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.",
|
||||
"joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist."
|
||||
}
|
||||
+11
-1
@@ -151,6 +151,7 @@
|
||||
"cmd.parameters_delete": "Delete Parameter",
|
||||
"cmd.parameters_export": "Export Parameters",
|
||||
"cmd.parameters_favorite": "Favourite Parameter",
|
||||
"cmd.parameters_move": "Move Parameter",
|
||||
"cmd.parameters_graph": "Parameter Graph",
|
||||
"cmd.parameters_import": "Import Parameters",
|
||||
"cmd.parameters_list": "List Parameters",
|
||||
@@ -636,6 +637,12 @@
|
||||
"ui.save_as": "Save As",
|
||||
"ui.save_as_ellipsis": "Save As...",
|
||||
"ui.scale": "Scale",
|
||||
"ui.orientation": "Orientation",
|
||||
"ui.twist": "Twist",
|
||||
"ui.end_scale": "End Scale",
|
||||
"ui.sweep_turn_with_path": "Turn with Path",
|
||||
"ui.sweep_keep_as_drawn": "Keep as Drawn",
|
||||
"ui.sweep_follow_direction": "Follow Direction",
|
||||
"ui.section_analysis": "Section Analysis",
|
||||
"ui.select": "Select",
|
||||
"ui.select_other_hint": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there.",
|
||||
@@ -1153,5 +1160,8 @@
|
||||
"RULED SURFACE": "RULED SURFACE",
|
||||
"Mitre": "Mitre",
|
||||
"Reference face": "Reference face",
|
||||
"Round": "Round"
|
||||
"Round": "Round",
|
||||
"joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.",
|
||||
"joint.one_body": "Both picks are on {body}. Pick the second one on another part.",
|
||||
"joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is."
|
||||
}
|
||||
+136
-1
@@ -4,6 +4,12 @@
|
||||
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Ouvrir le brouillon GitHub ci-dessous. Votre titre et votre rapport y seront déjà remplis.",
|
||||
"2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Joindre votre capture d'écran : la copier-coller, ou enregistrer le PNG puis le téléverser.",
|
||||
"3. Click Submit new issue on GitHub to send your report.": "3. Cliquer sur Submit new issue sur GitHub pour envoyer votre rapport.",
|
||||
"nav.touchpad.label": "Pavé tactile",
|
||||
"nav.touchpad.help": "Maintenir Maj et déplacer le pointeur pour faire un panoramique ; maintenir Alt/Option et le déplacer pour orbiter. Aucun bouton de souris n'est nécessaire. Le défilement vertical zoome.",
|
||||
"nav.touchpad.orbit": "Orbite (maintenir Alt/Option et déplacer ; sans bouton de souris)",
|
||||
"nav.touchpad.pan": "Panoramique (maintenir Maj et déplacer ; sans bouton de souris)",
|
||||
"nav.touchpad.zoom": "Zoom (défilement vertical ou pincement)",
|
||||
"nav.touchpad.scroll": "Le pavé tactile zoome avec le défilement vertical. Votre réglage de panoramique à deux doigts est conservé pour les autres préréglages.",
|
||||
"browser.body_name": "Corps{number}",
|
||||
"browser.component_name": "Composant{number}",
|
||||
"browser.parameters": "Paramètres ({count})",
|
||||
@@ -82,6 +88,9 @@
|
||||
"cmd.doc_recovery_list": "Conceptions récupérables",
|
||||
"cmd.document_inspect": "Inspecter la conception",
|
||||
"cmd.document_units": "Changer les unités de la conception",
|
||||
"cmd.document_metadata": "Propriétés de la conception",
|
||||
"cmd.document_metadata_set": "Modifier les propriétés de la conception",
|
||||
"cmd.document_metadata_remove": "Supprimer une propriété de la conception",
|
||||
"cmd.edit_checkpoint": "Point de restauration",
|
||||
"cmd.edit_restore_checkpoint": "Revenir au point de restauration",
|
||||
"cmd.engine_commands": "Lister les commandes",
|
||||
@@ -95,6 +104,7 @@
|
||||
"cmd.file_insert_mesh": "Insérer un maillage",
|
||||
"cmd.file_insert_step": "Insérer un STEP",
|
||||
"cmd.file_save_mesh": "Enregistrer comme maillage",
|
||||
"cmd.file_slicers": "Slicers installés",
|
||||
"cmd.inspect_accessibility": "Analyse d'accessibilité",
|
||||
"cmd.inspect_center_of_mass": "Centre de gravité",
|
||||
"cmd.inspect_curvature_comb": "Analyse par peigne de courbure",
|
||||
@@ -141,6 +151,7 @@
|
||||
"cmd.parameters_delete": "Supprimer le paramètre",
|
||||
"cmd.parameters_export": "Exporter les paramètres",
|
||||
"cmd.parameters_favorite": "Paramètre favori",
|
||||
"cmd.parameters_move": "Déplacer le paramètre",
|
||||
"cmd.parameters_graph": "Graphe des paramètres",
|
||||
"cmd.parameters_import": "Importer des paramètres",
|
||||
"cmd.parameters_list": "Lister les paramètres",
|
||||
@@ -283,6 +294,7 @@
|
||||
"cmd.view_list": "Lister les vues nommées",
|
||||
"cmd.view_rename": "Renommer la vue nommée",
|
||||
"cmd.view_save": "Nouvelle vue nommée",
|
||||
"dialog.computing": "Calcul… {seconds} s",
|
||||
"dialog.custom_axis": "↖ Axe personnalisé",
|
||||
"dialog.edit_title": "MODIFIER {title}",
|
||||
"dialog.select": "↖ Sélectionner",
|
||||
@@ -317,6 +329,16 @@
|
||||
"ui.animation": "Animation",
|
||||
"ui.animation_speed": "Vitesse d'animation",
|
||||
"ui.anti_aliasing": "Anticrénelage",
|
||||
"ui.supersampling": "Suréchantillonnage (SSAA)",
|
||||
"ui.supersampling_off": "Désactivé (1×)",
|
||||
"ui.supersampling_adaptive": "Seulement quand la vue est immobile",
|
||||
"settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.",
|
||||
"ui.display_quality": "Qualité d'affichage",
|
||||
"ui.display_quality_coarse": "Grossière",
|
||||
"ui.display_quality_normal": "Normale",
|
||||
"ui.display_quality_fine": "Fine",
|
||||
"ui.display_quality_ultra": "Ultra",
|
||||
"settings.display_quality_note": "Les niveaux plus fins affichent plus lisses les faces et arêtes courbes, mais leur maillage prend plus de temps. Seule la vue change, jamais la conception ni les exports.",
|
||||
"ui.apply": "Appliquer",
|
||||
"ui.arc": "Arc",
|
||||
"ui.assemble": "Assembler",
|
||||
@@ -576,6 +598,10 @@
|
||||
"ui.quit": "Quitter",
|
||||
"ui.radial_context_menu": "Menu contextuel radial",
|
||||
"ui.radius": "Rayon",
|
||||
"ui.pull_direction": "Direction de démoulage",
|
||||
"ui.tool_direction": "Direction de l'outil",
|
||||
"ui.reachable": "Accessible",
|
||||
"ui.blocked": "Inaccessible",
|
||||
"ui.radius_type": "Type de rayon",
|
||||
"ui.rectangle": "Rectangle",
|
||||
"ui.rectangular_pattern": "Réseau rectangulaire",
|
||||
@@ -590,9 +616,33 @@
|
||||
"ui.revolve": "Révolution",
|
||||
"ui.right": "Droite",
|
||||
"ui.save": "Enregistrer",
|
||||
"ui.heading_save_mesh": "ENREGISTRER COMME MAILLAGE",
|
||||
"ui.format": "Format",
|
||||
"ui.mesh_format_stl": "STL",
|
||||
"ui.mesh_format_3mf": "3MF",
|
||||
"ui.mesh_format_3mf_tagged": "3MF avec étiquettes de faces",
|
||||
"ui.send_to_slicer": "Envoyer au slicer",
|
||||
"ui.slicer": "Slicer",
|
||||
"ui.custom_program": "Personnalisé…",
|
||||
"ui.no_slicer_found": "Aucun slicer trouvé",
|
||||
"ui.send": "Envoyer",
|
||||
"ui.custom_program_tip": "Choisir un slicer à ajouter à la liste",
|
||||
"ui.slicer_delete_tip": "{program}\nAppuyer sur Suppr pour le retirer de la liste",
|
||||
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
|
||||
"ui.save_mesh_no_slicer": "Aucun slicer trouvé : choisissez Personnalisé… et sélectionnez votre slicer, ou désactivez Envoyer au slicer",
|
||||
"ui.not_a_program": "{path} n'est pas un programme exécutable",
|
||||
"ui.sent_to_slicer": "Envoyé à {slicer}",
|
||||
"ui.saved_to": "Enregistré : {path}",
|
||||
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
|
||||
"ui.save_as": "Enregistrer sous",
|
||||
"ui.save_as_ellipsis": "Enregistrer sous...",
|
||||
"ui.scale": "Échelle",
|
||||
"ui.orientation": "Orientation",
|
||||
"ui.twist": "Torsion",
|
||||
"ui.end_scale": "Échelle finale",
|
||||
"ui.sweep_turn_with_path": "Tourner avec la trajectoire",
|
||||
"ui.sweep_keep_as_drawn": "Garder tel que dessiné",
|
||||
"ui.sweep_follow_direction": "Suivre une direction",
|
||||
"ui.section_analysis": "Analyse de coupe",
|
||||
"ui.select": "Sélectionner",
|
||||
"ui.select_other_hint": "Sélectionner un autre élément : maintenir le bouton gauche immobile sur le modèle (ou clic droit › Sélectionner un autre…) pour lister tout ce qui se trouve sous le curseur ; cliquer de nouveau au même endroit pour sélectionner l'élément suivant.",
|
||||
@@ -744,6 +794,8 @@
|
||||
"Files from many CAD programs have Y up, so they open lying on their back here (Z up).": "Les fichiers de nombreux logiciels de CAO ont Y vers le haut : ils s'ouvrent donc couchés ici (Z vers le haut).",
|
||||
"Filter": "Filtrer",
|
||||
"Find in Browser": "Trouver dans le navigateur",
|
||||
"Copy Error Message": "Copier le message d'erreur",
|
||||
"Copy Warning Message": "Copier le message d'avertissement",
|
||||
"Flat size": "Dimensions à plat",
|
||||
"Follow camera (Look At sketches)": "Suivre la caméra (vue normale aux esquisses)",
|
||||
"Font": "Police",
|
||||
@@ -932,6 +984,17 @@
|
||||
"ui.pinToShortcuts": "Épingler aux raccourcis",
|
||||
"ui.pinToToolbar": "Épingler à la barre d'outils",
|
||||
"ui.properties": "Propriétés",
|
||||
"ui.design_properties.project": "Projet",
|
||||
"ui.design_properties.part_number": "Référence",
|
||||
"ui.design_properties.revision": "Indice",
|
||||
"ui.design_properties.author": "Auteur",
|
||||
"ui.design_properties.organization": "Organisation",
|
||||
"ui.design_properties.description": "Description",
|
||||
"ui.design_properties.license": "Licence",
|
||||
"ui.design_properties.not_text": "Enregistré sous une autre forme que du texte ; une saisie ici le remplace.",
|
||||
"ui.design_properties.exports": "Les exports STEP, IGES et 3MF les incluent quand le format le permet.",
|
||||
"ui.design_properties.others": "Données d'autres logiciels",
|
||||
"ui.design_properties.others_note": "Conservées telles quelles dans la conception et jamais exportées.",
|
||||
"ui.removeSection": "Retirer la coupe",
|
||||
"ui.removeFromToolbar": "Retirer de la barre d'outils",
|
||||
"ui.repeat": "Répéter",
|
||||
@@ -955,6 +1018,9 @@
|
||||
"ui.updateToCurrentView": "Mettre à jour avec la vue actuelle",
|
||||
"ui.zoom": "Zoom",
|
||||
"ui.command_done": "{command} terminé",
|
||||
"drive.stopped_by_loop": "Arrêté à {value} : la boucle passant par {joint} ne peut plus se fermer",
|
||||
"drive.stopped_by_contact": "Arrêté à {value} : {a} traverserait {b}",
|
||||
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé",
|
||||
"timeline.group": "Nouveau dossier de fonctions",
|
||||
"timeline.ungroup": "Supprimer les dossiers de fonctions",
|
||||
"timeline.rename_group": "Renommer le dossier de fonctions",
|
||||
@@ -1029,5 +1095,74 @@
|
||||
"update.install_failed": "La mise à jour ne s'est pas installée : {error}. Rien n'a été modifié ; Télécharger récupère plutôt le paquet.",
|
||||
"cmd.render_settings_get": "Réglages de rendu",
|
||||
"cmd.render_settings_set": "Enregistrer les réglages de rendu",
|
||||
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé"
|
||||
"drive.unconnected": "Rien ne bouge : {part} n'est relié à rien de fixé",
|
||||
"joint.one_component": "Ces pièces sont des corps d'un même composant : {bodies}. Une liaison déplace un composant par rapport à un autre, chaque pièce a donc besoin de son propre composant.",
|
||||
"joint.one_body": "Les deux sélections sont sur {body}. Choisissez la seconde sur une autre pièce.",
|
||||
"joint.from_bodies_hint": "Chaque corps sélectionné passe dans un nouveau composant à lui et reste où il est.",
|
||||
"cmd.render_scene": "Inspecter la scène de rendu",
|
||||
"cmd.render_settings_apply": "Appliquer la caméra de rendu",
|
||||
"toolbar.render": "RENDU",
|
||||
"toolbar.render_scene": "SCÈNE",
|
||||
"render.preview": "Aperçu intégré",
|
||||
"render.preview_help": "Ombrage par rastérisation et transparence mélangée. La réfraction, l'HDRI et le rendu final ne sont pas encore disponibles.",
|
||||
"render.setup_name": "Nom du préréglage",
|
||||
"render.background": "Arrière-plan de la scène",
|
||||
"render.perspective": "Caméra en perspective",
|
||||
"render.fit": "Cadrer le produit",
|
||||
"render.restore_camera": "Restaurer la caméra enregistrée",
|
||||
"render.save_setup": "Enregistrer le préréglage",
|
||||
"render.unsaved": "Modifications de l'aperçu non enregistrées",
|
||||
"render.reload_settings": "Recharger les réglages enregistrés",
|
||||
"render.unavailable_preview": "Ce préréglage demande un profil d'aperçu indisponible.",
|
||||
"render.use_preview": "Utiliser l'aperçu intégré",
|
||||
"render.navigation_help": "Faire glisser pour orbiter ; glisser avec le bouton du milieu pour un panoramique ; molette pour zoomer. Enregistrer le préréglage mémorise la caméra actuelle.",
|
||||
"render.return_design": "Revenir à la conception",
|
||||
"render.image_title": "Image de rendu",
|
||||
"render.image_help": "Image d'aperçu CPU · PNG · transparence approximative",
|
||||
"render.image_width": "Largeur",
|
||||
"render.image_height": "Hauteur",
|
||||
"render.image_transparent": "Arrière-plan transparent",
|
||||
"render.image_check": "Vérifier la demande de rendu",
|
||||
"render.image_supported": "La demande de rendu est prise en charge ; la file d'attente et la réserve de mémoire seront revérifiées au lancement.",
|
||||
"render.image_queued": "En attente de rendu",
|
||||
"render.image_preparing": "Préparation de l'image",
|
||||
"render.image_running": "Rendu de l'image",
|
||||
"render.image_encoding": "Encodage du PNG",
|
||||
"render.image_cancelling": "Annulation",
|
||||
"render.image_cancelled": "Rendu annulé",
|
||||
"render.image_completed": "Image prête à exporter",
|
||||
"render.image_failed": "Échec du rendu",
|
||||
"render.image_unknown": "État du rendu indisponible",
|
||||
"render.image_reduce": "Essayez une image plus petite ou une scène plus simple.",
|
||||
"render.image_cancel": "Annuler le rendu",
|
||||
"render.image_stale": "Cette image utilise la scène capturée avant les dernières modifications.",
|
||||
"render.image_start": "Lancer le rendu",
|
||||
"render.image_overwrite": "Remplacer le fichier existant",
|
||||
"render.image_export": "Exporter en PNG",
|
||||
"cmd.render_preflight": "Vérifier la demande de rendu",
|
||||
"cmd.render_start": "Rendu d'image",
|
||||
"cmd.render_status": "Progression du rendu",
|
||||
"cmd.render_cancel": "Annuler le rendu",
|
||||
"cmd.render_export": "Exporter l'image de rendu",
|
||||
"toolbar.image": "IMAGE",
|
||||
"appearance.surface_response": "Réponse de la surface",
|
||||
"appearance.pbr_opaque": "PBR opaque",
|
||||
"appearance.metallic": "Métallique",
|
||||
"appearance.roughness": "Rugosité",
|
||||
"appearance.dielectric_ior": "Indice de réfraction (diélectrique)",
|
||||
"appearance.pbr_help": "Voir cette surface dans l'espace Rendu ou dans un PNG du produit. Sans réfraction ni transmission.",
|
||||
"render.opaque_preview_help": "PBR opaque et couleurs simples ; transparence mélangée. La réfraction, l'HDRI et le rendu final ne sont pas encore disponibles.",
|
||||
"render.opaque_image_summary": "PNG d'aperçu · PBR opaque · transparence mélangée",
|
||||
"UNTRIM": "ANNULER L'AJUSTEMENT",
|
||||
"Face": "Face",
|
||||
"Holes": "Trous",
|
||||
"Holes (edges on them)": "Trous (par leurs arêtes)",
|
||||
"In place (surfaces)": "En place (surfaces)",
|
||||
"None": "Aucun",
|
||||
"Picked": "Sélectionnés",
|
||||
"Restore outer boundary": "Restaurer le contour extérieur",
|
||||
"RULED SURFACE": "SURFACE RÉGLÉE",
|
||||
"Mitre": "Onglet",
|
||||
"Reference face": "Face de référence",
|
||||
"Round": "Arrondi"
|
||||
}
|
||||
+11
-1
@@ -151,6 +151,7 @@
|
||||
"cmd.parameters_delete": "パラメータを削除",
|
||||
"cmd.parameters_export": "パラメータをエクスポート",
|
||||
"cmd.parameters_favorite": "お気に入りパラメータ",
|
||||
"cmd.parameters_move": "パラメータを移動",
|
||||
"cmd.parameters_graph": "パラメータ グラフ",
|
||||
"cmd.parameters_import": "パラメータをインポート",
|
||||
"cmd.parameters_list": "パラメータを一覧表示",
|
||||
@@ -636,6 +637,12 @@
|
||||
"ui.save_as": "名前を付けて保存",
|
||||
"ui.save_as_ellipsis": "名前を付けて保存…",
|
||||
"ui.scale": "尺度",
|
||||
"ui.orientation": "向き",
|
||||
"ui.twist": "ねじれ",
|
||||
"ui.end_scale": "終端スケール",
|
||||
"ui.sweep_turn_with_path": "パスに沿って回転",
|
||||
"ui.sweep_keep_as_drawn": "描いた向きを保持",
|
||||
"ui.sweep_follow_direction": "方向に従う",
|
||||
"ui.section_analysis": "断面解析",
|
||||
"ui.select": "選択",
|
||||
"ui.select_other_hint": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。",
|
||||
@@ -1153,5 +1160,8 @@
|
||||
"RULED SURFACE": "ルールドサーフェス",
|
||||
"Mitre": "留め継ぎ",
|
||||
"Reference face": "参照面",
|
||||
"Round": "丸め"
|
||||
"Round": "丸め",
|
||||
"joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。",
|
||||
"joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。",
|
||||
"joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。"
|
||||
}
|
||||
+11
-1
@@ -333,6 +333,12 @@
|
||||
"ui.save_as": "다른 이름으로 저장",
|
||||
"ui.save_as_ellipsis": "다른 이름으로 저장...",
|
||||
"ui.scale": "축척",
|
||||
"ui.orientation": "배향",
|
||||
"ui.twist": "비틀림",
|
||||
"ui.end_scale": "끝 배율",
|
||||
"ui.sweep_turn_with_path": "경로를 따라 회전",
|
||||
"ui.sweep_keep_as_drawn": "그린 대로 유지",
|
||||
"ui.sweep_follow_direction": "방향 따르기",
|
||||
"ui.section_analysis": "단면 분석",
|
||||
"ui.select": "선택",
|
||||
"ui.selection": "선택",
|
||||
@@ -594,6 +600,7 @@
|
||||
"cmd.parameters_delete": "매개 변수 삭제",
|
||||
"cmd.parameters_export": "매개 변수 내보내기",
|
||||
"cmd.parameters_favorite": "즐겨찾는 매개 변수",
|
||||
"cmd.parameters_move": "매개 변수 이동",
|
||||
"cmd.parameters_graph": "매개 변수 그래프",
|
||||
"cmd.parameters_import": "매개 변수 가져오기",
|
||||
"cmd.parameters_list": "매개 변수 목록",
|
||||
@@ -896,5 +903,8 @@
|
||||
"Mitre": "연귀",
|
||||
"Reference face": "참조 면",
|
||||
"Round": "둥글게",
|
||||
"Type": "유형"
|
||||
"Type": "유형",
|
||||
"joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.",
|
||||
"joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.",
|
||||
"joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다."
|
||||
}
|
||||
+11
-1
@@ -140,6 +140,7 @@
|
||||
"cmd.parameters_delete": "Удалить параметр",
|
||||
"cmd.parameters_export": "Экспортировать параметры",
|
||||
"cmd.parameters_favorite": "Избранный параметр",
|
||||
"cmd.parameters_move": "Переместить параметр",
|
||||
"cmd.parameters_graph": "График параметра",
|
||||
"cmd.parameters_import": "Импортировать параметры",
|
||||
"cmd.parameters_list": "Список параметров",
|
||||
@@ -607,6 +608,12 @@
|
||||
"ui.save_as": "Сохранить как",
|
||||
"ui.save_as_ellipsis": "Сохранить как...",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.orientation": "Ориентация",
|
||||
"ui.twist": "Закрутка",
|
||||
"ui.end_scale": "Масштаб в конце",
|
||||
"ui.sweep_turn_with_path": "Поворачивать по траектории",
|
||||
"ui.sweep_keep_as_drawn": "Как нарисовано",
|
||||
"ui.sweep_follow_direction": "Следовать направлению",
|
||||
"ui.section_analysis": "Анализ сечения",
|
||||
"ui.select": "Выбрать",
|
||||
"ui.select_other_hint": "Выбрать другое: удерживайте левую кнопку на модели (или правый щелчок › Выбрать другое…), чтобы увидеть всё под курсором; повторный щелчок в том же месте выбирает следующий объект.",
|
||||
@@ -1066,5 +1073,8 @@
|
||||
"appearance.dielectric_ior": "Показатель преломления диэлектрика",
|
||||
"appearance.pbr_help": "Просмотр поверхности во вкладке Рендер или на PNG изделия. Без преломления и пропускания света.",
|
||||
"render.opaque_preview_help": "Непрозрачный PBR и простые цвета; прозрачность через смешивание. Преломление, HDRI и финальный рендер пока недоступны.",
|
||||
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание"
|
||||
"render.opaque_image_summary": "Предпросмотр PNG · непрозрачный PBR · прозрачность через смешивание",
|
||||
"joint.one_component": "Эти детали - тела одного компонента: {bodies}. Сопряжение двигает один компонент относительно другого, поэтому каждой детали нужен свой компонент.",
|
||||
"joint.one_body": "Оба выбора на {body}. Выберите второй на другой детали.",
|
||||
"joint.from_bodies_hint": "Каждое выбранное тело переходит в свой новый компонент и остаётся на месте."
|
||||
}
|
||||
+11
-1
@@ -141,6 +141,7 @@
|
||||
"cmd.parameters_delete": "Видалити параметр",
|
||||
"cmd.parameters_export": "Експортувати параметри",
|
||||
"cmd.parameters_favorite": "Обраний параметр",
|
||||
"cmd.parameters_move": "Перемістити параметр",
|
||||
"cmd.parameters_graph": "Граф параметрів",
|
||||
"cmd.parameters_import": "Імпортувати параметри",
|
||||
"cmd.parameters_list": "Список параметрів",
|
||||
@@ -578,6 +579,12 @@
|
||||
"ui.save_as": "Зберегти як",
|
||||
"ui.save_as_ellipsis": "Зберегти як...",
|
||||
"ui.scale": "Масштаб",
|
||||
"ui.orientation": "Орієнтація",
|
||||
"ui.twist": "Закручування",
|
||||
"ui.end_scale": "Масштаб у кінці",
|
||||
"ui.sweep_turn_with_path": "Повертати за траєкторією",
|
||||
"ui.sweep_keep_as_drawn": "Як намальовано",
|
||||
"ui.sweep_follow_direction": "Слідувати напрямку",
|
||||
"ui.section_analysis": "Аналіз перерізу",
|
||||
"ui.select": "Вибрати",
|
||||
"ui.select_other_hint": "Вибір іншого: утримуйте ліву кнопку нерухомо на моделі (або клацніть правою › Вибрати інше…), щоб побачити список усього під курсором; клацніть ще раз у тому ж місці, щоб вибрати наступний об'єкт там.",
|
||||
@@ -1012,5 +1019,8 @@
|
||||
"update.installer_started": "Інсталятор запуститься, коли SolidCraft закриється.",
|
||||
"update.dmg_opened": "Образ диска відкрито й перевірено: перетягніть SolidCraft до Applications, замінивши стару копію, а потім перезапустіть SolidCraft.",
|
||||
"update.install_failed": "Оновлення не встановлено: {error}. Нічого не змінено; натомість «Завантажити» отримає пакет.",
|
||||
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим"
|
||||
"drive.unconnected": "Нічого не рухається: {part} не з'єднано ні з чим закріпленим",
|
||||
"joint.one_component": "Ці деталі - тіла одного компонента: {bodies}. З'єднання рухає один компонент відносно іншого, тому кожній деталі потрібен власний компонент.",
|
||||
"joint.one_body": "Обидва вибори на {body}. Виберіть другий на іншій деталі.",
|
||||
"joint.from_bodies_hint": "Кожне вибране тіло переходить у власний новий компонент і лишається на місці."
|
||||
}
|
||||
+11
-1
@@ -151,6 +151,7 @@
|
||||
"cmd.parameters_delete": "删除参数",
|
||||
"cmd.parameters_export": "导出参数",
|
||||
"cmd.parameters_favorite": "收藏参数",
|
||||
"cmd.parameters_move": "移动参数",
|
||||
"cmd.parameters_graph": "参数关系图",
|
||||
"cmd.parameters_import": "导入参数",
|
||||
"cmd.parameters_list": "列出参数",
|
||||
@@ -634,6 +635,12 @@
|
||||
"ui.save_as": "另存为",
|
||||
"ui.save_as_ellipsis": "另存为...",
|
||||
"ui.scale": "缩放",
|
||||
"ui.orientation": "定向",
|
||||
"ui.twist": "扭转",
|
||||
"ui.end_scale": "末端缩放",
|
||||
"ui.sweep_turn_with_path": "随路径转动",
|
||||
"ui.sweep_keep_as_drawn": "保持绘制方向",
|
||||
"ui.sweep_follow_direction": "跟随方向",
|
||||
"ui.section_analysis": "截面分析",
|
||||
"ui.select": "选择",
|
||||
"ui.select_other_hint": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。",
|
||||
@@ -1153,5 +1160,8 @@
|
||||
"RULED SURFACE": "直纹曲面",
|
||||
"Mitre": "斜接",
|
||||
"Reference face": "参考面",
|
||||
"Round": "圆角"
|
||||
"Round": "圆角",
|
||||
"joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。",
|
||||
"joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。",
|
||||
"joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。"
|
||||
}
|
||||
@@ -262,6 +262,26 @@ pub struct ThinWall {
|
||||
pub thickness2: Option<String>,
|
||||
}
|
||||
|
||||
/// How a swept profile turns as it follows its path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SweepOrientation {
|
||||
/// Square to the path, turning with it (a rotation-minimising frame).
|
||||
#[default]
|
||||
Perpendicular,
|
||||
/// As drawn: the profile moves along the path without turning.
|
||||
Parallel,
|
||||
/// Square to the path, its turn about the path fixed by this direction (in the feature's
|
||||
/// frame): the direction's projection onto the profile stays where it was at the start.
|
||||
Direction(#[walk(dir)] Vec3),
|
||||
}
|
||||
|
||||
impl SweepOrientation {
|
||||
pub fn is_perpendicular(&self) -> bool {
|
||||
*self == SweepOrientation::Perpendicular
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
|
||||
/// an open chain's is the side it bends toward (a straight one's, the left of its first
|
||||
/// curve as drawn).
|
||||
@@ -701,6 +721,17 @@ pub enum FeatureKind {
|
||||
#[walk(sketch)]
|
||||
path_sketch: u64,
|
||||
path: Vec<String>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
/// Angle the profile turns about the path over its length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Twist")]
|
||||
twist: Option<String>,
|
||||
/// The profile's scale at the end of the path (linear along its length).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(unitless = "Scale")]
|
||||
scale: Option<String>,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -1327,6 +1358,17 @@ pub enum FeatureKind {
|
||||
#[walk(sketch)]
|
||||
path_sketch: u64,
|
||||
path: Vec<String>,
|
||||
/// How the profile turns along the path (default: perpendicular to it).
|
||||
#[serde(default, skip_serializing_if = "SweepOrientation::is_perpendicular")]
|
||||
orientation: SweepOrientation,
|
||||
/// Angle the profile turns about the path over its length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(angle = "Twist")]
|
||||
twist: Option<String>,
|
||||
/// The profile's scale at the end of the path (linear along its length).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[walk(unitless = "Scale")]
|
||||
scale: Option<String>,
|
||||
},
|
||||
/// A surface body lofted through sections in order, each one open or closed chain of
|
||||
/// sketch curves: walls only, no caps.
|
||||
|
||||
+47
-10
@@ -2276,17 +2276,24 @@ fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Featu
|
||||
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
|
||||
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
|
||||
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
|
||||
/// refuses it).
|
||||
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
|
||||
/// refuses it). Pieces the hole parts from a sheet's base are named in `warning`.
|
||||
fn sheet_hole_cut(
|
||||
st: &mut ModelState,
|
||||
f: &Feature,
|
||||
tools: &[(Traced, Option<more::PrimitivePrism>)],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if st.sheets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let saved = (st.sheets.clone(), st.bodies.clone());
|
||||
let mut handled: Vec<String> = Vec::new();
|
||||
// Noted only when every tool was machined here.
|
||||
let mut parted = None;
|
||||
for (tool, prism) in tools {
|
||||
let tool = &tool.body;
|
||||
let done = match prism {
|
||||
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true) {
|
||||
Some(prism) => match more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut, true, &mut parted) {
|
||||
Ok(done) => done,
|
||||
// Drilled along a panel (into a rim) the hole is no flat cut-out: a bore of
|
||||
// that panel, when it stays inside it.
|
||||
@@ -2322,6 +2329,9 @@ fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<mor
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(note) = parted {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
@@ -3043,7 +3053,8 @@ fn feature_tools_with_constructor(
|
||||
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
|
||||
Ok(vec![roles.tool(body)])
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, orientation, twist, scale, .. } => {
|
||||
let ctl = sweep_control(vals, orientation, twist, scale)?;
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -3061,10 +3072,10 @@ fn feature_tools_with_constructor(
|
||||
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
|
||||
// A path through 3D sketch curves follows them as points.
|
||||
if let Some(pts) = path_points(ps, path, start)? {
|
||||
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
|
||||
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path_with(&ss.plane, r, &pts, &ctl)?))).collect();
|
||||
}
|
||||
let segs = path_segments(ps, path, start)?;
|
||||
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
|
||||
regions.iter().map(|r| Ok(tool(r, kernel::sweep_with(&ss.plane, r, &segs, &ctl)?))).collect()
|
||||
}
|
||||
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
|
||||
let ps =
|
||||
@@ -3112,6 +3123,32 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
|
||||
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
|
||||
/// A sweep's options from its stored orientation and its twist and end scale expressions
|
||||
/// (none: no twist, scale 1). Out-of-range values are refused by the kernel.
|
||||
pub(crate) fn sweep_control(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
orientation: &crate::SweepOrientation,
|
||||
twist: &Option<String>,
|
||||
scale: &Option<String>,
|
||||
) -> Result<kernel::SweepControl> {
|
||||
let orientation = match *orientation {
|
||||
crate::SweepOrientation::Perpendicular => kernel::SweepOrientation::Perpendicular,
|
||||
crate::SweepOrientation::Parallel => kernel::SweepOrientation::Parallel,
|
||||
crate::SweepOrientation::Direction(d) => kernel::SweepOrientation::Direction(d),
|
||||
};
|
||||
let twist = match twist {
|
||||
Some(t) => val(vals, t, Kind::Angle)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let scale = match scale {
|
||||
Some(k) => val(vals, k, Kind::Unitless)?,
|
||||
None => 1.0,
|
||||
};
|
||||
let ctl = kernel::SweepControl { orientation, twist, scale };
|
||||
ctl.check()?;
|
||||
Ok(ctl)
|
||||
}
|
||||
|
||||
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
|
||||
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
|
||||
let holes = match wall {
|
||||
@@ -4018,7 +4055,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
// Cuts through sheet metal become cut-outs of its flat pattern.
|
||||
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
|
||||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref(), warning)?;
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, targets, &handled);
|
||||
}
|
||||
@@ -4028,7 +4065,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = hole_feature_tools(vals, st, f)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools, warning)?;
|
||||
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
|
||||
if handled.is_empty() {
|
||||
apply_traced(st, f, tools, Operation::Cut, &[])?;
|
||||
@@ -4418,7 +4455,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let corner = point(vals, corner)?;
|
||||
let b = kernel::box_solid(corner, corner + s)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false)?;
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, more::PrimitivePrism::Box { lo: corner, hi: corner + s }, *operation, false, warning)?;
|
||||
let tools = vec![crate::lineage::box_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
@@ -4430,7 +4467,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let b = kernel::cylinder(base, *axis, radius, height)?;
|
||||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||||
let prism = more::PrimitivePrism::Cylinder { base, axis: *axis, radius, height };
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false)?;
|
||||
let handled = more::sheet_primitive_cut(f, st, &b, prism, *operation, false, warning)?;
|
||||
let tools = vec![crate::lineage::cylinder_tool(b)];
|
||||
if !handled.is_empty() {
|
||||
return apply_beside_sheet_cut(st, f, tools, *operation, &[], &handled);
|
||||
|
||||
@@ -618,7 +618,10 @@ pub(super) fn sheet_eval(
|
||||
None => *p,
|
||||
});
|
||||
}
|
||||
let mut sh = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?;
|
||||
for note in notes {
|
||||
append_warning(warning, note);
|
||||
}
|
||||
tag_flanges(&mut sh, 0, f.id);
|
||||
put_sheet(st, sh)
|
||||
}
|
||||
@@ -761,6 +764,7 @@ pub(super) fn sheet_cut(
|
||||
tools: &[Body],
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) {
|
||||
@@ -768,7 +772,16 @@ pub(super) fn sheet_cut(
|
||||
}
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some())
|
||||
// The outlines of the sketch's text: the sheet remembers where its letters went.
|
||||
let text: Vec<Seg2> = ss
|
||||
.sketch
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. }))
|
||||
.flat_map(|l| ss.sketch.link_curves(&l.id))
|
||||
.flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning)
|
||||
}
|
||||
|
||||
/// Whether a cut of `f` with these targets (else its participants, else its component's
|
||||
@@ -800,6 +813,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
prism: PrimitivePrism,
|
||||
operation: Operation,
|
||||
drill: bool,
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) {
|
||||
return Ok(Vec::new());
|
||||
@@ -824,7 +838,7 @@ pub(super) fn sheet_primitive_cut(
|
||||
let Some(normal) = normal else { return Ok(Vec::new()) };
|
||||
let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) };
|
||||
let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?;
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false)
|
||||
sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false, &[], warning)
|
||||
}
|
||||
|
||||
/// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each
|
||||
@@ -911,6 +925,12 @@ impl PrimitivePrism {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most outline segments of a sketch's text a cut tells letters by, and the most text regions
|
||||
/// a sheet keeps (see [`crate::sheet::SheetBody::labels`]).
|
||||
const MAX_TEXT_CURVES: usize = 20_000;
|
||||
const MAX_LABELS: usize = 1000;
|
||||
|
||||
/// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sheet_prism_cut(
|
||||
f: &Feature,
|
||||
@@ -922,6 +942,8 @@ fn sheet_prism_cut(
|
||||
targets: &[String],
|
||||
constructor: Option<&ExtrudeConstructor>,
|
||||
footprints_bound: bool,
|
||||
text: &[Seg2],
|
||||
warning: &mut Option<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
let n = plane.normal();
|
||||
let mut work = sheet::FlatWork::new();
|
||||
@@ -960,7 +982,14 @@ fn sheet_prism_cut(
|
||||
&& current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r)))
|
||||
.then_some(c.interval)
|
||||
});
|
||||
// The regions of the cut that are letters, with their counters.
|
||||
let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES {
|
||||
regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut plans = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
'sheets: for original in &st.sheets {
|
||||
if !sheet_eligible(f, st, targets, original) {
|
||||
continue;
|
||||
@@ -993,6 +1022,7 @@ fn sheet_prism_cut(
|
||||
sh.material_panels(Some(plane), &mut work)?
|
||||
};
|
||||
let mut modeled = false;
|
||||
let mut labels = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
let w = if sh.flat { sh.world(None) } else { sh.world(pi) };
|
||||
let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n);
|
||||
@@ -1032,6 +1062,7 @@ fn sheet_prism_cut(
|
||||
let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t));
|
||||
// Sub-resolution layers must not be silently erased by endpoint snapping.
|
||||
let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6);
|
||||
labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok()));
|
||||
let mapped = if footprints_bound && current_footprints.len() == tools.len() {
|
||||
current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::<Result<Vec<_>>>()?
|
||||
} else {
|
||||
@@ -1096,19 +1127,34 @@ fn sheet_prism_cut(
|
||||
}
|
||||
}
|
||||
}
|
||||
if modeled {
|
||||
for flat in labels {
|
||||
if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS {
|
||||
sh.labels.push(flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !modeled {
|
||||
super::sheet_boolean_unchanged(&mb.body, tools, operation)?;
|
||||
plans.push((sh.body.clone(), Some((sh, mb.body.clone()))));
|
||||
} else if sh.flat_material()?.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
let material = sh.flat_material()?;
|
||||
if material.is_empty() {
|
||||
// Empty exact material removes geometry and metadata together. Budget or
|
||||
// topology errors propagate; they are never mistaken for an empty blank.
|
||||
plans.push((sh.body.clone(), None));
|
||||
} else {
|
||||
// Every piece the cut leaves stays; the ones it parts from the base are named.
|
||||
notes.extend(parted_note(&sh, &material, original));
|
||||
let body = sh.solid()?;
|
||||
plans.push((sh.body.clone(), Some((sh, body))));
|
||||
}
|
||||
}
|
||||
}
|
||||
let handled = plans.iter().map(|(name, _)| name.clone()).collect();
|
||||
if !notes.is_empty() {
|
||||
append_warning(warning, notes.join("; "));
|
||||
}
|
||||
for (name, result) in plans {
|
||||
match result {
|
||||
Some((sh, body)) => {
|
||||
@@ -1129,6 +1175,35 @@ fn sheet_prism_cut(
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
/// A cut's note on the pieces it parted from the base of `sh` (see
|
||||
/// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut,
|
||||
/// `before` the sheet before it): how many, their area and where they lie in the flat pattern.
|
||||
/// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the
|
||||
/// pieces can't be worked out: the note is no reason for a cut to fail.
|
||||
fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option<String> {
|
||||
let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?;
|
||||
let area: f64 = pieces.iter().map(Region2::area).sum();
|
||||
let at: Vec<String> = pieces
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|r| {
|
||||
let c = r.interior_point();
|
||||
format!("({:.1}, {:.1})", c.x, c.y)
|
||||
})
|
||||
.collect();
|
||||
let at = at.join(", ");
|
||||
let body = &sh.body;
|
||||
Some(match pieces.len() {
|
||||
1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"),
|
||||
n => {
|
||||
let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() };
|
||||
format!(
|
||||
"{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept"
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance
|
||||
/// `level` from `at` along `n`: the edges used by one of them only, chained.
|
||||
fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
|
||||
@@ -176,7 +176,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path } => {
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale } => {
|
||||
let ctl = crate::eval::sweep_control(vals, orientation, twist, scale)?;
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
let ps =
|
||||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -195,8 +196,8 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
let pts: Vec<Vec2> = chains.iter().flatten().flat_map(|s| s.polyline(1e-2)).collect();
|
||||
let start = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64));
|
||||
let body = match path_points(ps, path, start)? {
|
||||
Some(pts) => kernel::sweep_sheet_points(&ss.plane, &chains, &pts)?,
|
||||
None => kernel::sweep_sheet(&ss.plane, &chains, &path_segments(ps, path, start)?)?,
|
||||
Some(pts) => kernel::sweep_sheet_points_with(&ss.plane, &chains, &pts, &ctl)?,
|
||||
None => kernel::sweep_sheet_with(&ss.plane, &chains, &path_segments(ps, path, start)?, &ctl)?,
|
||||
};
|
||||
let traced = crate::lineage::ChainRoles { sketch: ss, chains: &chains }.tool(body);
|
||||
push_sheet(st, f, traced);
|
||||
|
||||
@@ -75,6 +75,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("thread", json!({"type": "thread", "face": o, "length": "thread.length"})),
|
||||
("loft", json!({"type": "loft", "sections": []})),
|
||||
("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"sweep_options",
|
||||
json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0, 1, 0]}, "twist": "sweep.twist", "scale": "sweep.scale"}),
|
||||
),
|
||||
("shell", json!({"type": "shell", "faces": [o], "thickness": "shell.thickness"})),
|
||||
("draft", json!({"type": "draft", "faces": [o], "angle": "draft.angle", "neutral": plane("draft_neutral"), "pull": [0, 0, 1]})),
|
||||
("construction_plane", json!({"type": "construction_plane", "plane": plane("cplane")})),
|
||||
@@ -147,6 +152,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
json!({"type": "surface_revolve", "sketch": 1, "axis": {"type": "world", "axis": "X"}, "angle": "srev.angle", "angle2": "srev.angle2"}),
|
||||
),
|
||||
("surface_sweep", json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]})),
|
||||
(
|
||||
"surface_sweep_options",
|
||||
json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": "parallel", "twist": "surface_sweep.twist", "scale": "surface_sweep.scale"}),
|
||||
),
|
||||
("surface_loft", json!({"type": "surface_loft", "sections": []})),
|
||||
("surface_offset", json!({"type": "surface_offset", "faces": [o], "distance": "soff.distance", "tolerance": "soff.tolerance"})),
|
||||
("unstitch", json!({"type": "unstitch", "body": "B"})),
|
||||
@@ -181,7 +191,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
let o = [0, 0, 0];
|
||||
let tool = json!({"body": "B", "point": o});
|
||||
let extra = match name {
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "sweep_options" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
|
||||
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_perforate" | "sheet_unfold" | "boss" | "lip"
|
||||
| "snap_fit" | "rest" | "surface_offset" => {
|
||||
|
||||
@@ -136,6 +136,11 @@ thread
|
||||
expressions: thread.length
|
||||
loft
|
||||
sweep
|
||||
sweep_options
|
||||
inputs:
|
||||
Twist = sweep.twist (Angle)
|
||||
Scale = sweep.scale (Unitless)
|
||||
expressions: sweep.twist, sweep.scale
|
||||
shell
|
||||
inputs:
|
||||
Thickness = shell.thickness (Length)
|
||||
@@ -317,6 +322,11 @@ surface_revolve
|
||||
Angle 2 = srev.angle2 (Angle)
|
||||
expressions: srev.angle, srev.angle2
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
inputs:
|
||||
Twist = surface_sweep.twist (Angle)
|
||||
Scale = surface_sweep.scale (Unitless)
|
||||
expressions: surface_sweep.twist, surface_sweep.scale
|
||||
surface_loft
|
||||
surface_offset
|
||||
inputs:
|
||||
|
||||
@@ -63,6 +63,9 @@ loft
|
||||
profiles: 4 all, 5 all
|
||||
sweep
|
||||
profiles: 1 all
|
||||
sweep_options
|
||||
moves: orientation.direction[1], orientation.direction[2]
|
||||
profiles: 1 all
|
||||
shell
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
face points: [0, 0, 0]
|
||||
@@ -142,6 +145,7 @@ surface_extrude
|
||||
surface_revolve
|
||||
moves: axis.dir[0], axis.dir[1], axis.dir[2], axis.origin[0], axis.origin[1], axis.origin[2]
|
||||
surface_sweep
|
||||
surface_sweep_options
|
||||
surface_loft
|
||||
surface_offset
|
||||
moves: faces[0][0], faces[0][1], faces[0][2]
|
||||
|
||||
@@ -67,6 +67,10 @@ sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
remap_bodies: targets[0]
|
||||
shell
|
||||
remap_bodies: body
|
||||
draft
|
||||
@@ -159,6 +163,9 @@ surface_revolve
|
||||
surface_sweep
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_sweep_options
|
||||
sketches: [1, 2]
|
||||
remap_refs: path_sketch, sketch
|
||||
surface_loft
|
||||
sketches: [4, 5]
|
||||
remap_refs: sections[0].sketch, sections[1].sketch
|
||||
|
||||
@@ -191,6 +191,15 @@
|
||||
//! A bend point can also lie on a bend modelled with its radius (a cylinder between two flat
|
||||
//! faces tangent to it), which then is one bend at the two faces' virtual sharp with the
|
||||
//! rule's radius; 0.11.0 found no edge there or refused the curved face.
|
||||
//! Also under 21, a Sweep and a Surface Sweep can carry `orientation` (`parallel`, or
|
||||
//! `{direction: [x, y, z]}`; absent: perpendicular), `twist` (an angle expression) and `scale`
|
||||
//! (the end scale, an expression). A sweep without them saves as before; 0.11.x refuses
|
||||
//! format 21, so no released build sweeps a twisted or scaled profile straight.
|
||||
//! Nothing new is saved for converted sheets' corners and bend ends, but a design converts as
|
||||
//! the rule says when it is evaluated: corners between the walls take the rule's corner relief
|
||||
//! and a bend ending beside more of the fixed face its bend relief (0.11.0 left every corner a
|
||||
//! notch and cut no bend relief), and a wall on part of an edge bends only where it stands
|
||||
//! (0.11.0 refused it).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -1763,4 +1772,28 @@ mod tests {
|
||||
let json = edited(&d, |v| v["components"][1]["id"] = u64::MAX.into());
|
||||
assert!(read(&json).unwrap_err().to_string().contains("damaged design"));
|
||||
}
|
||||
|
||||
/// A sweep saved before its options existed loads as a plain sweep and saves as it was; the
|
||||
/// options round-trip.
|
||||
#[test]
|
||||
fn sweep_options_are_optional_and_round_trip() {
|
||||
use crate::{FeatureKind, SweepOrientation};
|
||||
let old =
|
||||
serde_json::json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"], "operation": "new_body"});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &k else { panic!("{k:?}") };
|
||||
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let old = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"]});
|
||||
let k: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), old);
|
||||
let new = serde_json::json!({"type": "surface_sweep", "sketch": 1, "path_sketch": 2, "path": ["l1"],
|
||||
"orientation": {"direction": [0.0, 1.0, 0.0]}, "twist": "90 deg", "scale": "0.5"});
|
||||
let k: FeatureKind = serde_json::from_value(new.clone()).unwrap();
|
||||
let FeatureKind::SurfaceSweep { orientation, .. } = &k else { panic!("{k:?}") };
|
||||
assert_eq!(*orientation, SweepOrientation::Direction(solidcraft_geom::Vec3::Y));
|
||||
assert_eq!(serde_json::to_value(&k).unwrap(), new);
|
||||
let parallel: SweepOrientation = serde_json::from_value(serde_json::json!("parallel")).unwrap();
|
||||
assert_eq!(parallel, SweepOrientation::Parallel);
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,25 @@ impl Document {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Move user parameter `name` to just before (or with `after`, just after) user parameter
|
||||
/// `target`: the order the Parameters dialog lists them in. Nothing else changes.
|
||||
pub fn move_user_param(&mut self, name: &str, target: &str, after: bool) -> Result<()> {
|
||||
fn user(params: &[crate::Param], n: &str) -> Option<usize> {
|
||||
params.iter().position(|p| p.name == n && !p.model)
|
||||
}
|
||||
let (Some(from), Some(_)) = (user(&self.params, name), user(&self.params, target)) else {
|
||||
let missing = if user(&self.params, name).is_none() { name } else { target };
|
||||
return Err(DocError::Unknown(format!("user parameter `{missing}`")));
|
||||
};
|
||||
if name == target {
|
||||
return Err(DocError::Invalid("a parameter cannot move next to itself".into()));
|
||||
}
|
||||
let moved = self.params.remove(from);
|
||||
let at = user(&self.params, target).map_or(self.params.len(), |i| if after { i + 1 } else { i });
|
||||
self.params.insert(at, moved);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_favorite(&mut self, name: &str, on: bool) -> Result<()> {
|
||||
if !self.name_taken(name) {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
|
||||
+233
-11
@@ -31,6 +31,8 @@ const MAX_FLAT_POINTS: usize = 16_384;
|
||||
const MAX_FLAT_SAMPLES: usize = 65_536;
|
||||
const MAX_FLAT_PIECES: usize = 4096;
|
||||
const MAX_FLAT_WORK: usize = 500_000_000;
|
||||
/// Pieces of flat material sharing less area than this (mm²) only touch.
|
||||
const TOUCH_AREA: f64 = 1e-6;
|
||||
|
||||
/// Starts the warning a fold gives when its `fixed` point touches no material: a recompute keeps
|
||||
/// the side the point lies on, and the `sheet.fold` command refuses such a point (#263).
|
||||
@@ -490,6 +492,12 @@ pub struct SheetBody {
|
||||
/// Perforation fields (#732), flat coordinates; their holes are computed on demand and never cut into the B-rep.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub perforations: Vec<SheetPerforation>,
|
||||
/// Regions (flat coordinates) that text cut or machined, letters with their counters as
|
||||
/// holes: a record of where the sheet's text went, so that its DXF can write the letters on
|
||||
/// layers of their own (see [`SheetBody::letters`]). The cut-outs and pockets themselves are
|
||||
/// in `holes` and `pockets`. Derived from feature history, like `pockets`.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub labels: Vec<Region2>,
|
||||
/// Unfolded.
|
||||
pub flat: bool,
|
||||
/// Gap between flanges that meet at a corner.
|
||||
@@ -1600,6 +1608,26 @@ impl SheetBody {
|
||||
Ok(through.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The text the sheet carries: what [`SheetBody::labels`] records, ready to tell whether a
|
||||
/// loop of the flat pattern is a letter.
|
||||
pub fn letters(&self) -> Letters {
|
||||
let regions = self
|
||||
.labels
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
|
||||
for p in r.outer.polyline(1e-3) {
|
||||
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
|
||||
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
|
||||
}
|
||||
// A polyline of 0.001 mm chords is within 0.001 mm of the curve.
|
||||
let pad = Vec2::new(0.01, 0.01);
|
||||
(lo - pad, hi + pad, std::iter::once(&r.outer).chain(&r.holes).flat_map(|lp| lp.segs.iter().copied()).collect())
|
||||
})
|
||||
.collect();
|
||||
Letters { regions }
|
||||
}
|
||||
|
||||
/// Pocket operations clipped to the surviving flat material, with exact depth intervals.
|
||||
pub fn machining(&self) -> Result<Vec<SheetPocket>> {
|
||||
let material = self.flat_regions()?;
|
||||
@@ -1670,6 +1698,84 @@ impl SheetBody {
|
||||
let regions = if cuts.is_empty() { regions } else { material_boolean(®ions, &cuts, kernel::BoolOp::Cut, &mut work)? };
|
||||
Ok(regions)
|
||||
}
|
||||
|
||||
/// The pieces of flat material that a cut parted from the base: `after` is this sheet's
|
||||
/// [`Self::flat_material`] after the cut, `before` the sheet before it. A piece is parted when
|
||||
/// it holds none of the base panel, lies in a piece that held some before the cut, and no
|
||||
/// other piece surrounds it: a flange cut off along its bend (with any flange on it), the
|
||||
/// part of a ring's inside that lies on a flange's edge, the tip of a flange beyond a cut
|
||||
/// through a closed corner. A ring's island and a letter's counter, inside other material,
|
||||
/// are not counted; nor are pieces an earlier cut parted, nor anything once no piece holds
|
||||
/// the base.
|
||||
pub(crate) fn parted_from_base(&self, after: &[Region2], before: &SheetBody) -> Result<Vec<Region2>> {
|
||||
if after.len() < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut work = FlatWork::new();
|
||||
work.charge(after.len().saturating_mul(after.len()))?;
|
||||
let base = [planar_region(&self.base)?];
|
||||
let holds_base = |piece: &Region2, work: &mut FlatWork| -> Result<bool> {
|
||||
let common = material_boolean(std::slice::from_ref(piece), &base, kernel::BoolOp::Intersect, work)?;
|
||||
Ok(common.iter().map(Region2::area).sum::<f64>() > TOUCH_AREA)
|
||||
};
|
||||
// The cheap test first: a piece inside another's outline is an island, whatever it holds.
|
||||
let outlines: Vec<(Vec<Vec2>, Vec2, Vec2)> = after
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let pts = r.outer.polyline(1e-3);
|
||||
let lo = pts.iter().fold(Vec2::new(f64::MAX, f64::MAX), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
|
||||
let hi = pts.iter().fold(Vec2::new(f64::MIN, f64::MIN), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
|
||||
(pts, lo, hi)
|
||||
})
|
||||
.collect();
|
||||
let (mut open, mut islands) = (Vec::new(), Vec::new());
|
||||
for (i, piece) in after.iter().enumerate() {
|
||||
let p = piece.interior_point();
|
||||
let inside = |(pts, lo, hi): &(Vec<Vec2>, Vec2, Vec2)| (lo.x..=hi.x).contains(&p.x) && (lo.y..=hi.y).contains(&p.y) && point_in(pts, p);
|
||||
if outlines.iter().enumerate().any(|(j, o)| j != i && inside(o)) {
|
||||
islands.push(piece);
|
||||
} else {
|
||||
open.push(piece);
|
||||
}
|
||||
}
|
||||
let (mut joined, mut loose) = (false, Vec::new());
|
||||
for piece in open {
|
||||
if holds_base(piece, &mut work)? {
|
||||
joined = true;
|
||||
} else {
|
||||
loose.push(piece);
|
||||
}
|
||||
}
|
||||
if loose.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// The base may be an island itself (a cut all round it).
|
||||
for piece in islands {
|
||||
if joined {
|
||||
break;
|
||||
}
|
||||
joined = holds_base(piece, &mut work)?;
|
||||
}
|
||||
if !joined {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// A cut only takes material away: each piece lies in the piece it was part of.
|
||||
let mut held = Vec::new();
|
||||
for piece in before.flat_material()? {
|
||||
if holds_base(&piece, &mut work)? {
|
||||
held.push(piece);
|
||||
}
|
||||
}
|
||||
Ok(loose
|
||||
.into_iter()
|
||||
.filter(|piece| {
|
||||
let p = piece.interior_point();
|
||||
held.iter().any(|h| h.contains(p))
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Outline loops of the flat pattern (flat coordinates): the pieces' edges, shared ones left out.
|
||||
pub fn outline(&self) -> Result<Vec<Vec<Vec2>>> {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
|
||||
@@ -2201,7 +2307,7 @@ impl SheetBody {
|
||||
/// such end left as it is.
|
||||
fn relieve_bend_ends(&mut self, i: usize, free: (bool, bool)) -> Vec<String> {
|
||||
let Some(f) = self.flanges.get(i).cloned() else { return Vec::new() };
|
||||
if f.parent.is_some() || f.shape.is_some() {
|
||||
if f.parent.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (width, depth) = (self.bend_relief.0, self.bend_relief.1.max(0.0));
|
||||
@@ -2610,32 +2716,42 @@ impl SheetBody {
|
||||
fn close_corners(&mut self, made: &[usize]) -> Vec<String> {
|
||||
let corners = self.corners(made);
|
||||
let mut notes = Vec::new();
|
||||
let at = |s: &SheetBody, c: Vec2| {
|
||||
let w = apply_point(&s.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
};
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match self.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", at(self, k.c))),
|
||||
Err(why) => notes.push(format!("the flanges at {} are left open: {why}", self.shown(k.c))),
|
||||
}
|
||||
}
|
||||
notes.extend(self.relieve_corners(&corners, &closed));
|
||||
notes
|
||||
}
|
||||
|
||||
/// A point of the flat frame, in the part's coordinates, for a note.
|
||||
fn shown(&self, c: Vec2) -> String {
|
||||
let w = apply_point(&self.frame_mat(), v3(c));
|
||||
format!("({:.3}, {:.3}, {:.3})", w.x, w.y, w.z)
|
||||
}
|
||||
|
||||
/// Cut the rule's relief at each of `corners`, or tear those of them that are `closed`.
|
||||
/// Returns a note for every corner without its relief or not torn.
|
||||
fn relieve_corners(&mut self, corners: &[Corner], closed: &[Corner]) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
match self.relief {
|
||||
ReliefShape::Notch => {}
|
||||
// The reliefs come last: they move the bends' ends, which is how the corners were found.
|
||||
ReliefShape::Round | ReliefShape::Rectangular => {
|
||||
for k in &corners {
|
||||
if let Err(why) = self.relieve_corner(k, &corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", at(self, k.c)));
|
||||
for k in corners {
|
||||
if let Err(why) = self.relieve_corner(k, corners) {
|
||||
notes.push(format!("no corner relief at {}: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A tear cuts to the flanges' closed ends, so a corner left open gets none.
|
||||
ReliefShape::Tear => {
|
||||
for k in &closed {
|
||||
for k in closed {
|
||||
if let Err(why) = self.tear_corner(k) {
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", at(self, k.c)));
|
||||
notes.push(format!("the corner at {} keeps its notch: {why}", self.shown(k.c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3953,6 +4069,30 @@ fn polys_cross(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every segment of `lp` lies on one of `curves` (its ends and its middle, within
|
||||
/// 0.0001 mm): the loop is made of those curves, or of parts of them.
|
||||
pub fn loop_lies_on(lp: &Loop2, curves: &[Seg2]) -> bool {
|
||||
const TOL: f64 = 1e-4;
|
||||
let near = |p: Vec2| curves.iter().any(|c| c.point_at(c.closest_param(p)).dist(p) <= TOL);
|
||||
!lp.segs.is_empty() && lp.segs.iter().all(|g| near(g.start()) && near(g.mid()) && near(g.end()))
|
||||
}
|
||||
|
||||
/// Tells which loops of a sheet's flat pattern are letters (see [`SheetBody::letters`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Letters {
|
||||
/// Each text region's corners (lowest, highest) and the segments of its outlines.
|
||||
regions: Vec<(Vec2, Vec2, Vec<Seg2>)>,
|
||||
}
|
||||
|
||||
impl Letters {
|
||||
/// Whether `lp` is made of the outlines of text the sheet carries: a letter, or one of its
|
||||
/// counters.
|
||||
pub fn is_letter(&self, lp: &Loop2) -> bool {
|
||||
let Some(p) = lp.segs.first().map(Seg2::start) else { return false };
|
||||
self.regions.iter().any(|(lo, hi, curves)| p.x >= lo.x && p.x <= hi.x && p.y >= lo.y && p.y <= hi.y && loop_lies_on(lp, curves))
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded overlap predicate, including crossing-only cuts whose vertices lie outside a panel.
|
||||
pub(crate) fn flat_regions_overlap(a: &Region2, b: &Region2, work: &mut FlatWork) -> Result<bool> {
|
||||
let (a, b) = (planar_region(a)?, planar_region(b)?);
|
||||
@@ -3974,6 +4114,7 @@ pub fn base_flange(body: &str, rule: &str, rv: &RuleValues, plane: Plane, region
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -4031,6 +4172,7 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
pockets: Vec::new(),
|
||||
bores: Vec::new(),
|
||||
perforations: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
flat: false,
|
||||
gap: rv.gap,
|
||||
relief: rv.corner_relief_shape,
|
||||
@@ -4175,6 +4317,41 @@ mod tests {
|
||||
kernel::measure(b).map(|m| m.volume).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loops_lie_on_the_curves_they_are_made_of() {
|
||||
let circle = Loop2::circle(Vec2::new(5.0, 5.0), 3.0);
|
||||
let curves: Vec<Seg2> = circle.segs.clone();
|
||||
// The loop itself, started elsewhere and run the other way, and a loop of its halves.
|
||||
assert!(loop_lies_on(&circle, &curves));
|
||||
assert!(loop_lies_on(&Loop2::circle_from(Vec2::new(5.0, 5.0), 3.0, 1.0).reversed(), &curves));
|
||||
let halves: Vec<Seg2> = circle.segs.iter().flat_map(|g| [g.split_half().0, g.split_half().1]).collect();
|
||||
assert!(loop_lies_on(&Loop2 { segs: halves }, &curves));
|
||||
// Another circle, a square and an empty loop do not.
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 3.1), &curves));
|
||||
assert!(!loop_lies_on(&Loop2::circle(Vec2::new(5.0, 5.0), 2.0), &curves));
|
||||
assert!(!loop_lies_on(&rect(Vec2::ZERO, Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y).outer, &curves));
|
||||
assert!(!loop_lies_on(&Loop2 { segs: Vec::new() }, &curves));
|
||||
// A loop only partly on them (a letter across the sheet's edge) is not on them.
|
||||
let notch = Loop2 { segs: vec![circle.segs[0], Seg2::Line { a: circle.segs[0].end(), b: Vec2::new(50.0, 50.0) }] };
|
||||
assert!(!loop_lies_on(¬ch, &curves));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_knows_the_letters_it_carries() {
|
||||
// A recorded text region with a counter: its outline and its counter are letters, the same
|
||||
// circle elsewhere, a loop near it that is another size and a loop far away are not.
|
||||
let mut s = plate(100.0, 60.0);
|
||||
let counter = Loop2::circle(Vec2::new(20.0, 20.0), 2.0).reversed();
|
||||
s.labels.push(Region2 { outer: Loop2::circle(Vec2::new(20.0, 20.0), 5.0), holes: vec![counter.clone()] });
|
||||
let letters = s.letters();
|
||||
assert!(letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
assert!(letters.is_letter(&counter.reversed()));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 4.0)));
|
||||
assert!(!letters.is_letter(&Loop2::circle(Vec2::new(70.0, 20.0), 5.0)));
|
||||
// A sheet that carries none has no letters.
|
||||
assert!(!plate(100.0, 60.0).letters().is_letter(&Loop2::circle(Vec2::new(20.0, 20.0), 5.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_flange_matches_fusion() {
|
||||
// Oracle 68: 100 x 60 plate, 20 mm flange on the y = 0 top edge.
|
||||
@@ -7292,4 +7469,49 @@ mod tests {
|
||||
holds_the_flat_material(&s, &what);
|
||||
}
|
||||
}
|
||||
|
||||
/// The 100 x 60 plate with a 20 mm flange on its y = 0 edge (2.5 mm, R 2.5, K 0.44): in the
|
||||
/// flat the base starts at y = t + R = 5, the bend's zone is (π/2)·(R + K·t) long and the
|
||||
/// flange's panel 20 − 5, so its far edge lies at y = 5 − (π/2)·3.6 − 15.
|
||||
#[test]
|
||||
fn a_cut_names_the_pieces_it_parts_from_the_base_and_no_others() {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let far = 5.0 - FRAC_PI_2 * (2.5 + 0.44 * 2.5) - 15.0;
|
||||
let mut s = plate(100.0, 60.0);
|
||||
s.add_flange(Vec3::new(50.0, 0.0, 2.5), 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let r = |x0: f64, y0: f64, x1: f64, y1: f64| rect(Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1));
|
||||
let ring = |c: Vec2, ro: f64, ri: f64| Region2 { outer: Loop2::circle(c, ro), holes: vec![Loop2::circle(c, ri)] };
|
||||
let parted = |before: &SheetBody, holes: &[Region2]| {
|
||||
let mut after = before.clone();
|
||||
after.holes.extend_from_slice(holes);
|
||||
after.parted_from_base(&after.flat_material().unwrap(), before).unwrap()
|
||||
};
|
||||
// A slot across the flange beside its bend parts the rest of the flange: 100 × (−3 − far).
|
||||
let slot = r(-5.0, -3.0, 105.0, -1.0);
|
||||
let p = parted(&s, std::slice::from_ref(&slot));
|
||||
assert!(p.len() == 1 && (p[0].area() - 100.0 * (-3.0 - far)).abs() < 1e-6, "{p:?}");
|
||||
// So does a ring on the flange's far edge: the half of its inside on the flange, π·2²/2.
|
||||
let p = parted(&s, &[ring(Vec2::new(50.0, far), 4.0, 2.0)]);
|
||||
assert!(p.len() == 1 && (p[0].area() - 2.0 * PI).abs() < 1e-6, "{p:?}");
|
||||
// A ring's island, on the base or on the flange, has material round it; a strip across the
|
||||
// whole sheet leaves two halves that each keep their part of the base. None is named.
|
||||
for holes in [ring(Vec2::new(50.0, 30.0), 10.0, 5.0), ring(Vec2::new(50.0, -9.0), 4.0, 2.0), r(45.0, -30.0, 55.0, 70.0)] {
|
||||
assert!(parted(&s, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Pieces an earlier cut parted are not named again, nor when a later cut splits them.
|
||||
let mut cut = s.clone();
|
||||
cut.holes.push(slot);
|
||||
for holes in [Region2 { outer: Loop2::circle(Vec2::new(30.0, 30.0), 4.0), holes: vec![] }, r(45.0, -30.0, 55.0, -2.0)] {
|
||||
assert!(parted(&cut, std::slice::from_ref(&holes)).is_empty(), "{holes:?}");
|
||||
}
|
||||
// Nothing is named once no piece holds the base: a cut that takes the base and both bends
|
||||
// of a sheet with flanges on two sides leaves the two flanges.
|
||||
let mut two = plate(100.0, 60.0);
|
||||
two.add_flanges(&[Vec3::new(50.0, 0.0, 2.5), Vec3::new(50.0, 60.0, 2.5)], 20.0, FRAC_PI_2, 2.5, "inside", false).unwrap();
|
||||
let mut after = two.clone();
|
||||
after.holes.push(r(-5.0, -3.0, 105.0, 63.0));
|
||||
let material = after.flat_material().unwrap();
|
||||
assert_eq!(material.len(), 2);
|
||||
assert!(after.parted_from_base(&material, &two).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
//! two panels that is no bend is cut open: each panel ends square to the sheet, half the rule's
|
||||
//! gap from the plane halfway between the two.
|
||||
//!
|
||||
//! The bends on the fixed face are finished as the flanges of one edge flange feature are: where
|
||||
//! two meet at a corner of it, their walls are closed with the rule's gap and the corner gets the
|
||||
//! rule's corner relief (round, rectangular, a tear, or the notch the bends leave), and where one
|
||||
//! ends beside more of the fixed face, the rule's bend relief is cut there. A wall that stands on
|
||||
//! part of an edge of the fixed face (its outside in one plane with the plate's edge, which runs
|
||||
//! on past it a strip no taller than the bend's setback) bends only where it stands: the strip is
|
||||
//! the fixed face's own edge.
|
||||
//!
|
||||
//! A bend can also be a chamfer: a flat strip between two faces, as a model without bend radii
|
||||
//! draws a bend (the chord of its arc). Picked in its middle, the strip is no face of the sheet:
|
||||
//! the faces on either side are taken out to where their planes meet, and that line is the
|
||||
@@ -37,7 +45,7 @@
|
||||
//! Not supported yet: curved faces other than bends (rolled sheet), holes and pockets that reach
|
||||
//! into a bend, pockets deeper than half the sheet or with a step or a hole in their floor, round
|
||||
//! edges that end at a bend or a cut edge, loops of bends, two faces that meet along more than
|
||||
//! one edge.
|
||||
//! one edge, a wall on part of an edge of a face other than the fixed one.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::f64::consts::{FRAC_PI_2, PI, TAU};
|
||||
@@ -500,6 +508,96 @@ fn inset(pts: &[Vec2], by: &[f64]) -> Result<(Vec<Vec2>, Vec<Option<usize>>)> {
|
||||
Ok((out, from))
|
||||
}
|
||||
|
||||
/// A wall standing on part of an edge of the fixed face: where the outline of `wall`, the face
|
||||
/// across a bend from the fixed face `base` (`ids`: the two faces), leaves the bend's edge up a
|
||||
/// strip no taller than the bend's setback `by`, runs back along the edge and climbs the wall's
|
||||
/// end, the strip is the fixed face's own edge (a plate's edge face in one plane with the outside
|
||||
/// of a wall that stands on part of it), which the setback would use up. It is cut off the wall,
|
||||
/// whose bend's edge then ends where the line of the wall's end meets it, and the fixed face's
|
||||
/// edge is split there: the pieces beside the wall are no bend. `bend` says whether a face is a
|
||||
/// bend of the wall. Any other wall is left as it is.
|
||||
fn end_at_wall(base: &mut Face, wall: &mut Face, ids: (usize, usize), by: f64, bend: &dyn Fn(usize) -> bool, tol: f64) {
|
||||
let (m, n) = (wall.pts.len(), base.pts.len());
|
||||
let at_wall: Vec<usize> = (0..m).filter(|i| wall.across.get(*i).copied().flatten() == Some(ids.0)).collect();
|
||||
let at_base: Vec<usize> = (0..n).filter(|i| base.across.get(*i).copied().flatten() == Some(ids.1)).collect();
|
||||
let (&[k], &[i]) = (at_wall.as_slice(), at_base.as_slice()) else { return };
|
||||
if m < 5 {
|
||||
return;
|
||||
}
|
||||
// Corner `j` and edge `j` (from corner `j`) counted from the bend's edge on.
|
||||
let pt = |j: usize| wall.pts.get((k + j) % m).copied().unwrap_or_default();
|
||||
let (p, q) = (pt(0), pt(1));
|
||||
let Some(d) = (q - p).normalized() else { return };
|
||||
// Up the wall from the bend's edge (the outline runs counter-clockwise about the normal), and
|
||||
// along the edge.
|
||||
let up = |x: Vec3| (x - p).dot(wall.n.cross(d));
|
||||
let along = |x: Vec3| (x - p).dot(d);
|
||||
let free = |j: usize| {
|
||||
let j = (k + j) % m;
|
||||
wall.round.get(j).is_some_and(Option::is_none) && wall.across.get(j).copied().flatten().is_none_or(|g| !bend(g))
|
||||
};
|
||||
let low = |x: Vec3| up(x) > tol && up(x) <= by + tol;
|
||||
// A strip from an end of the bend's edge up to `x1`, back along the edge (`back`: the way it
|
||||
// runs) to `x2`, then the wall's end up to `x3`: where the line of that end meets the edge.
|
||||
let foot = |x1: Vec3, x2: Vec3, x3: Vec3, back: f64| {
|
||||
let rise = up(x3) - up(x2);
|
||||
(low(x1) && low(x2) && (x2 - x1).dot(d) * back > tol && rise > tol).then(|| x2 - (x3 - x2) * (up(x2) / rise))
|
||||
};
|
||||
// At the edge's end the strip is edges 1 and 2, the wall's end edge 3; at its start the strip
|
||||
// is edges m - 1 and m - 2, the wall's end edge m - 3.
|
||||
let ok = |js: [usize; 3]| js.iter().all(|j| free(*j));
|
||||
let end = if ok([1, 2, 3]) { foot(pt(2), pt(3), pt(4), -1.0) } else { None };
|
||||
let start = if ok([m - 1, m - 2, m - 3]) { foot(pt(m - 1), pt(m - 2), pt(m - 3), 1.0) } else { None };
|
||||
let (p1, q1) = (start.unwrap_or(p), end.unwrap_or(q));
|
||||
let len = along(q);
|
||||
let inside = |x: Option<Vec3>| x.is_none_or(|x| along(x) > tol && along(x) < len - tol);
|
||||
if (start.is_none() && end.is_none())
|
||||
|| !inside(start)
|
||||
|| !inside(end)
|
||||
|| along(q1) - along(p1) <= tol
|
||||
|| (start.is_some() && end.is_some() && m < 7)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// The wall: the strips' corners and edges go, the bend's edge runs from `p1` to `q1`.
|
||||
let (mut corners_out, mut edges_out) = (Vec::new(), Vec::new());
|
||||
if end.is_some() {
|
||||
corners_out.extend([2, 3]);
|
||||
edges_out.extend([1, 2]);
|
||||
}
|
||||
if start.is_some() {
|
||||
corners_out.extend([m - 1, m - 2]);
|
||||
edges_out.extend([m - 1, m - 2]);
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut(k) {
|
||||
*x = p1;
|
||||
}
|
||||
if let Some(x) = wall.pts.get_mut((k + 1) % m) {
|
||||
*x = q1;
|
||||
}
|
||||
let kept = |j: usize, out: &[usize]| !out.contains(&((j + m - k) % m));
|
||||
wall.pts = wall.pts.iter().enumerate().filter(|(j, _)| kept(*j, &corners_out)).map(|(_, x)| *x).collect();
|
||||
wall.across = wall.across.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
wall.round = wall.round.iter().enumerate().filter(|(j, _)| kept(*j, &edges_out)).map(|(_, x)| *x).collect();
|
||||
// The fixed face's edge: the bend from `p1` to `q1` (it runs the other way along it), no bend
|
||||
// beside it.
|
||||
let (Some(&a), Some(&b)) = (base.pts.get(i), base.pts.get((i + 1) % n)) else { return };
|
||||
let from_a = |x: Vec3| (x - a).dot(b - a);
|
||||
let (x, y) = if from_a(p1) < from_a(q1) { (p1, q1) } else { (q1, p1) };
|
||||
let mut pieces: Vec<(Vec3, Option<usize>)> = Vec::with_capacity(3);
|
||||
if x.dist(a) > tol {
|
||||
pieces.push((a, None));
|
||||
}
|
||||
pieces.push((if x.dist(a) > tol { x } else { a }, Some(ids.1)));
|
||||
if y.dist(b) > tol {
|
||||
pieces.push((y, None));
|
||||
}
|
||||
let count = pieces.len();
|
||||
base.pts.splice(i..=i, pieces.iter().map(|(x, _)| *x));
|
||||
base.across.splice(i..=i, pieces.iter().map(|(_, g)| *g));
|
||||
base.round.splice(i..=i, std::iter::repeat_n(None, count));
|
||||
}
|
||||
|
||||
/// Give flange `i` its panel: `outline` in flat coordinates, counter-clockwise, its edge at the
|
||||
/// bend first trimmed to the end of the bend zone.
|
||||
fn set_panel(sh: &mut SheetBody, i: usize, outline: &[Vec2]) {
|
||||
@@ -574,7 +672,8 @@ pub fn bend_edge_names(st: &crate::ModelState, body: &str, pts: &[Vec3]) -> Vec<
|
||||
/// Convert body `mb` to a sheet under the rule `rule` with values `rv`: `face` is a point on the
|
||||
/// fixed face and `bends` are points on the sharp edges that become bends, in the middle of
|
||||
/// chamfers or on bend cylinders that do (the body's coordinates, mm). See the module docs.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<SheetBody> {
|
||||
/// Returns the sheet and a note for every corner or end of a bend not finished as the rule says.
|
||||
pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, bends: &[Vec3]) -> Result<(SheetBody, Vec<String>)> {
|
||||
if bends.is_empty() || bends.len() > MAX_BENDS {
|
||||
return err(format!("pick 1…{MAX_BENDS} bend edges"));
|
||||
}
|
||||
@@ -696,6 +795,20 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
.and_then(|(a, b)| Some((*a, (*b - *a).normalized()?)))
|
||||
.and_then(|(a, x)| Plane::new(a, x, x.cross(base.face.n)))
|
||||
.ok_or_else(|| DocError::Invalid("the fixed face has no plane".into()))?;
|
||||
// A wall on part of an edge of the fixed face bends only where it stands (a bend picked as a
|
||||
// chamfer or a cylinder is taken as it is).
|
||||
let walls: Vec<(usize, f64)> = panels.iter().filter(|(_, p)| p.parent == Some(f0)).map(|(g, p)| (*g, p.setback)).collect();
|
||||
for (g, by) in walls {
|
||||
if chamfers.iter().any(|c| (c.a, c.b) == (f0, g) || (c.a, c.b) == (g, f0)) {
|
||||
continue;
|
||||
}
|
||||
let kids: Vec<usize> = panels.iter().filter(|(_, p)| p.parent == Some(g)).map(|(h, _)| *h).collect();
|
||||
let Some(mut wall) = panels.remove(&g) else { continue };
|
||||
if let Some(base) = panels.get_mut(&f0) {
|
||||
end_at_wall(&mut base.face, &mut wall.face, (f0, g), by, &|h| h == f0 || kids.contains(&h), tol);
|
||||
}
|
||||
panels.insert(g, wall);
|
||||
}
|
||||
let mut sh = base_flange(&mb.name, rule, &RuleValues { thickness: t, ..*rv }, frame, Region2::default());
|
||||
// How far behind a face of the sheet the body's face `g` starts and ends: a hole's wall
|
||||
// reaches through the sheet, a pocket's ends on its floor, an island's starts at the face.
|
||||
@@ -872,7 +985,25 @@ pub fn convert_body(mb: &ModelBody, rule: &str, rv: &RuleValues, face: Vec3, ben
|
||||
queue.push((g, Some(sh.flanges.len() - 1)));
|
||||
}
|
||||
}
|
||||
Ok(sh)
|
||||
// The bends on the fixed face finished as an edge flange feature finishes its own
|
||||
// (`SheetBody::add_flanges_each`): the rule's bend relief where one ends beside more of the
|
||||
// fixed face; where two meet at a corner of it, their walls closed with the rule's gap (which
|
||||
// the cuts above already are) and the rule's corner relief.
|
||||
let made: Vec<usize> = (0..sh.flanges.len()).collect();
|
||||
let mut notes = Vec::new();
|
||||
for &i in &made {
|
||||
notes.extend(sh.relieve_bend_ends(i, (false, false)));
|
||||
}
|
||||
let corners = sh.corners(&made);
|
||||
let mut closed = Vec::with_capacity(corners.len());
|
||||
for k in &corners {
|
||||
match sh.close_corner(k) {
|
||||
Ok(()) => closed.push(*k),
|
||||
Err(why) => notes.push(format!("the corner at {} stays cut open: {why}", sh.shown(k.c))),
|
||||
}
|
||||
}
|
||||
notes.extend(sh.relieve_corners(&corners, &closed));
|
||||
Ok((sh, notes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -128,8 +128,9 @@ fn xy(v: Vec2) -> Value {
|
||||
}
|
||||
|
||||
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
|
||||
/// the sketch id and a point inside the face (sketch coordinates). The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2)> {
|
||||
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
|
||||
/// The sketch is left active.
|
||||
pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec2, String)> {
|
||||
let none = || match face_body {
|
||||
Some(b) => bad(cmd, format!("no face of `{b}` at `face`")),
|
||||
None => bad(cmd, "no body face at `face`"),
|
||||
@@ -199,7 +200,7 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
|
||||
// The face's edges came in as linked projections: they follow the face when earlier
|
||||
// features change, so nothing is copied.
|
||||
if r.get("projected").is_some_and(|x| !x.is_null()) {
|
||||
return Ok((sid, plane.to_local(author_inside)));
|
||||
return Ok((sid, plane.to_local(author_inside), body));
|
||||
}
|
||||
for e in &edges {
|
||||
let pts: Vec<Vec2> = e.iter().map(|p| plane.to_local(*p)).collect();
|
||||
@@ -237,9 +238,13 @@ pub(super) fn sketch_of_face(s: &mut Session, point: Vec3, cmd: &str, face_body:
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok((sid, plane.to_local(author_inside)))
|
||||
Ok((sid, plane.to_local(author_inside), body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "face_join_tests.rs"]
|
||||
mod join_tests;
|
||||
@@ -0,0 +1,127 @@
|
||||
//! #226: a face's outward extrusion must not join an assembly neighbour it only touches.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, FeatureKind};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Value) -> Value {
|
||||
s.execute(command, ¶ms).unwrap_or_else(|error| panic!("{command}: {error}"))
|
||||
}
|
||||
|
||||
fn assembly(cap_first: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
let wall = json!({"length":40,"width":30,"height":20,"body_name":"Wall"});
|
||||
let cap = json!({"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"});
|
||||
for params in if cap_first { [cap, wall] } else { [wall, cap] } {
|
||||
run(&mut s, "solid.box", params);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn check_wall(s: &Session, count: usize) {
|
||||
assert!(s.model.results.iter().all(|result| result.error.is_none() && result.warning.is_none()), "{:?}", s.model.results);
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), count);
|
||||
let wall = state.body("Wall").unwrap();
|
||||
let measure = solidcraft_kernel::measure(&wall.body).unwrap();
|
||||
assert!((measure.volume - 30_000.0).abs() < 1e-6, "{measure:?}");
|
||||
assert!(measure.bbox.max.dist(Vec3::new(50.0, 30.0, 20.0)) < 1e-9);
|
||||
assert!(wall.body.contains(Vec3::new(49.0, 15.0, 10.0)));
|
||||
let cap = solidcraft_kernel::measure(&state.body("Cap").unwrap().body).unwrap();
|
||||
assert!((cap.volume - 6000.0).abs() < 1e-6);
|
||||
assert!(cap.bbox.max.dist(Vec3::new(40.0, 30.0, 25.0)) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_and_press_pull_leave_the_edge_touching_cap_in_either_body_order() {
|
||||
for cap_first in [true, false] {
|
||||
for operation in ["join", "auto", "press_pull"] {
|
||||
let mut s = assembly(cap_first);
|
||||
let before = s.model.state();
|
||||
let params = json!({"face":[40,15,10],"distance":10,"operation":operation});
|
||||
let command = if operation == "press_pull" { "solid.press_pull" } else { "solid.extrude" };
|
||||
let result = run(&mut s, command, params);
|
||||
assert_eq!(result["changed_bodies"], json!(["Wall"]));
|
||||
assert_eq!(result["removed_bodies"], json!([]));
|
||||
assert!(s.model.state().body("Cap").unwrap().body.same(&before.body("Cap").unwrap().body));
|
||||
check_wall(&s, 2);
|
||||
let saved = s.doc.to_json();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.model.state().body("Wall").unwrap().body.same(&before.body("Wall").unwrap().body));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
check_wall(&Session::new(Document::from_json(&saved).unwrap()), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_respects_explicit_targets_and_participants() {
|
||||
for key in ["targets", "participants"] {
|
||||
for all in [false, true] {
|
||||
let mut s = assembly(!all);
|
||||
let mut params = json!({"face":[40,15,10],"distance":10,"operation":"join"});
|
||||
// Joining the wall first permits the cap's full base face to join afterwards.
|
||||
params[key] = if all { json!(["*"]) } else { json!(["Wall", "Cap"]) };
|
||||
run(&mut s, "solid.extrude", params);
|
||||
let state = s.model.state();
|
||||
assert_eq!(state.bodies.len(), 1, "{key}");
|
||||
assert!((solidcraft_kernel::measure(&state.body("Wall").unwrap().body).unwrap().volume - 36_000.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_maps_the_picked_instance_body_and_point_together() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name":"Repeated"}))["component"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Wall"}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let occurrence = run(&mut s, "occurrence.copy", json!({"component":component,"translate":[100,0,0]}))["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "component.activate", json!({"component":component,"occurrence":occurrence}));
|
||||
let world = s.world_state();
|
||||
let body = world.bodies.iter().find(|body| body.body.contains(Vec3::new(139.0, 15.0, 10.0))).unwrap().name.clone();
|
||||
assert_ne!(body, "Wall", "exercise an instance body name");
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[140,15,10],"face_body":body,"distance":10,"operation":"join"}));
|
||||
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(targets, &["Wall"]);
|
||||
let world = s.world_state();
|
||||
assert_eq!(world.bodies.len(), 2);
|
||||
for point in [Vec3::new(49.0, 15.0, 10.0), Vec3::new(149.0, 15.0, 10.0)] {
|
||||
assert!(world.bodies.iter().any(|body| body.body.contains(point)), "{point:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn face_join_of_a_step_import_keeps_all_32_neighbours_unchanged() {
|
||||
let mut source = assembly(true);
|
||||
for i in 0..31 {
|
||||
run(&mut source, "solid.box", json!({"corner":[100+20*i,100,0],"length":5,"width":5,"height":5,"body_name":format!("Distant{i}")}));
|
||||
}
|
||||
let step = solidcraft_io::export(&source.model.state(), &[], solidcraft_io::Format::Step, "Original assembly").unwrap();
|
||||
let imported = solidcraft_io::step_import_feature(&step, "original-assembly.step").unwrap();
|
||||
let mut doc = Document::new("Imported assembly");
|
||||
let feature = doc.add_feature(imported.kind, None).unwrap();
|
||||
doc.feature_mut(feature).unwrap().body_names = imported.body_names;
|
||||
let mut s = Session::new(doc);
|
||||
assert_eq!(s.doc.features.len(), 1);
|
||||
let before = s.model.state();
|
||||
let result = run(&mut s, "solid.extrude", json!({"face":[40,15,10],"distance":"10 mm","direction":"positive","operation":"join"}));
|
||||
assert_eq!(result["changed_bodies"], json!(["Wall"]));
|
||||
check_wall(&s, 33);
|
||||
let reopened = Session::new(Document::from_json(&s.doc.to_json()).unwrap());
|
||||
check_wall(&reopened, 33);
|
||||
for body in before.bodies.iter().filter(|body| body.name != "Wall") {
|
||||
let after = s.model.state();
|
||||
assert!(body.body.same(&after.body(&body.name).unwrap().body), "{}", body.name);
|
||||
let cold = reopened.model.state();
|
||||
let original = solidcraft_kernel::measure(&body.body).unwrap();
|
||||
let restored = solidcraft_kernel::measure(&cold.body(&body.name).unwrap().body).unwrap();
|
||||
assert!((original.volume - restored.volume).abs() < 1e-9, "{}", body.name);
|
||||
assert!(original.bbox.min.dist(restored.bbox.min) < 1e-9 && original.bbox.max.dist(restored.bbox.max) < 1e-9);
|
||||
}
|
||||
let FeatureKind::Extrude { targets, .. } = &s.doc.feature(result["feature"].as_u64().unwrap()).unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(targets, &["Wall"]);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
|
||||
use solidcraft_doc::{
|
||||
AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
|
||||
};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
@@ -13,7 +15,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -21,7 +23,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("solid.sweep", "Sweep", sweep)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("sweep")
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?, targets?, name?, body_name?"),
|
||||
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
|
||||
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
|
||||
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
|
||||
@@ -512,12 +514,12 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
|
||||
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.extrude";
|
||||
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
|
||||
}
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
None => (feature_sketch(s, p, cmd)?, None, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
@@ -530,6 +532,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
|
||||
_ => None,
|
||||
};
|
||||
let operation = match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
};
|
||||
let mut targets = string_list(p, "targets");
|
||||
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
|
||||
// A face's outward extension joins that body. Assembly neighbours can touch only an
|
||||
// edge of the tool: trying to join them first fails, or absorbs unrelated parts (#226).
|
||||
// Store the resolved target so recomputes and reopening keep the same scope.
|
||||
targets.extend(face_body);
|
||||
}
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
@@ -538,11 +551,8 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
operation: match str_(p, "operation") {
|
||||
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
operation,
|
||||
targets,
|
||||
thin,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
@@ -657,7 +667,7 @@ fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A planar body face revolves like a profile (its edges projected into a sketch on it).
|
||||
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
|
||||
Some(fp) => {
|
||||
let (id, inside) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
|
||||
s.active_sketch = None;
|
||||
(id, Some(ProfileSel::Points { points: vec![inside] }))
|
||||
}
|
||||
@@ -1621,6 +1631,7 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if path.is_empty() {
|
||||
return Err(bad(cmd, "`path` must list curve ids"));
|
||||
}
|
||||
let (orientation, twist, scale) = sweep_options(s, p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
@@ -1629,12 +1640,58 @@ fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
profiles: profiles(p, cmd)?,
|
||||
path_sketch,
|
||||
path,
|
||||
orientation,
|
||||
twist,
|
||||
scale,
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
|
||||
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
|
||||
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<String>, Option<String>)> {
|
||||
let orientation = match p.get("orientation") {
|
||||
None | Some(Value::Null) => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
|
||||
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
|
||||
Some(v) => {
|
||||
let d = vec3(v)
|
||||
.or_else(|| v.get("direction").and_then(vec3))
|
||||
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
|
||||
if !(d.len() > 1e-9) {
|
||||
return Err(bad(cmd, "the `orientation` direction must not be zero"));
|
||||
}
|
||||
SweepOrientation::Direction(d)
|
||||
}
|
||||
};
|
||||
let twist = match p.get("twist") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
|
||||
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
|
||||
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
|
||||
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
let scale = match p.get("scale") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => {
|
||||
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
|
||||
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
|
||||
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
|
||||
if !(v.is_finite() && range.contains(&v)) {
|
||||
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
};
|
||||
Ok((orientation, twist, scale))
|
||||
}
|
||||
|
||||
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "solid.loft";
|
||||
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
|
||||
@@ -1753,6 +1810,10 @@ mod patterns_tests;
|
||||
#[path = "revolve_convention_tests.rs"]
|
||||
mod revolve_convention_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sweep_options_tests.rs"]
|
||||
mod sweep_options_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "offset_topology_tests.rs"]
|
||||
mod offset_topology_tests;
|
||||
|
||||
@@ -245,6 +245,10 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut v = if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) };
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("visible".into(), json!(s.body_visible(&b.name)));
|
||||
if s.body_removed_later(&b.name) {
|
||||
// Editing a sketch rolls the timeline back; this body is deleted further on.
|
||||
o.insert("removed_later".into(), json!(true));
|
||||
}
|
||||
}
|
||||
v
|
||||
})
|
||||
|
||||
@@ -318,8 +318,14 @@ fn add_joint(s: &mut Session, j: Joint, p: &Value) -> Result<Value> {
|
||||
if s.doc.assembly.joints.len() >= 10_000 {
|
||||
return Err(EngineError::Other("too many joints".into()));
|
||||
}
|
||||
// Several parts as bodies of one component (a multi-body STEP import, #1239): say how to
|
||||
// get two components.
|
||||
if j.a.occurrence == j.b.occurrence {
|
||||
return Err(bad("joint.create", "a joint needs two different occurrences"));
|
||||
return Err(bad(
|
||||
"joint.create",
|
||||
"a joint needs two different occurrences: both origins are on one component; give each part a component of its own first \
|
||||
(component.from_bodies)",
|
||||
));
|
||||
}
|
||||
let id = j.id;
|
||||
let name = j.name.clone();
|
||||
|
||||
@@ -247,6 +247,22 @@ fn faces_mate_by_default() {
|
||||
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 10.0)), Vec3::new(5.0, 5.0, 0.0)), "{:?}", place(&s, "B", Vec3::new(25.0, 5.0, 10.0)));
|
||||
}
|
||||
|
||||
/// #1239: two parts as bodies of one component (a multi-body STEP import). The refusal names the
|
||||
/// way out, and after Create Components from Bodies the same origins make the joint.
|
||||
#[test]
|
||||
fn bodies_of_one_component_get_components_first() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0]}));
|
||||
let p = json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}});
|
||||
let e = s.execute("joint.create", &p).map(|_| ()).unwrap_err().to_string();
|
||||
assert!(e.contains("two different occurrences") && e.contains("component.from_bodies"), "{e}");
|
||||
run(&mut s, "component.from_bodies", json!({"bodies": ["Body1", "Body2"]}));
|
||||
run(&mut s, "joint.create", p);
|
||||
// The first joint's A side stays; B lands on top of it.
|
||||
assert!(near(place(&s, "Body2", Vec3::new(25.0, 5.0, 0.0)), Vec3::new(5.0, 5.0, 10.0)));
|
||||
}
|
||||
|
||||
/// #527: an assembly with its fasteners passes 200 bodies quickly; the clash check must still
|
||||
/// cover the whole design. 201 separate 1 mm cubes 2 mm apart, plus one cube pushed half-way
|
||||
/// into the first: exactly that pair overlaps, by 0.5 mm³.
|
||||
|
||||
@@ -24,6 +24,8 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("name, new_name (expressions, feature inputs and dimensions that use it follow)"),
|
||||
CommandSpec::new("parameters.delete", "Delete Parameter", delete).params("name (refused while something uses it; the error lists the users)"),
|
||||
CommandSpec::new("parameters.favorite", "Favourite Parameter", favorite).params("name, favorite?: bool (default true)"),
|
||||
CommandSpec::new("parameters.move", "Move Parameter", move_param)
|
||||
.params("name, before | after: another user parameter; the user parameter is listed just before or after it"),
|
||||
CommandSpec::new("parameters.comment", "Comment Parameter", comment).params("name, comment"),
|
||||
CommandSpec::new("parameters.users", "Parameter Users", users)
|
||||
.noundo()
|
||||
@@ -179,6 +181,19 @@ fn favorite(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"name": name, "favorite": on}))
|
||||
}
|
||||
|
||||
fn move_param(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.move";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
let (target, after) = match (str_(p, "before"), str_(p, "after")) {
|
||||
(Some(t), None) => (t.to_string(), false),
|
||||
(None, Some(t)) => (t.to_string(), true),
|
||||
_ => return Err(bad(cmd, "give one of `before` or `after`: another user parameter")),
|
||||
};
|
||||
s.doc_mut().move_user_param(&name, &target, after)?;
|
||||
let order: Vec<&str> = s.doc.params.iter().filter(|q| !q.model).map(|q| q.name.as_str()).collect();
|
||||
Ok(json!({"name": name, "order": order}))
|
||||
}
|
||||
|
||||
fn comment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "parameters.comment";
|
||||
let name = param_name(cmd, p)?.to_string();
|
||||
|
||||
@@ -544,3 +544,52 @@ fn switching_configurations_back_reuses_the_later_features() {
|
||||
assert!(rel(volume(&mut s), 40.0 * 30.0 * 10.0) < 1e-9);
|
||||
assert!(hits(&s) > before, "{r}");
|
||||
}
|
||||
|
||||
/// The user parameters' names in the order the Parameters dialog lists them.
|
||||
fn user_order(s: &mut Session) -> Vec<String> {
|
||||
let v = run(s, "parameters.list", json!({"filter": "user"}));
|
||||
v["parameters"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_parameters_reorder_with_undo() {
|
||||
// #1187: move a user parameter before or after another; nothing else changes.
|
||||
let mut s = Session::default();
|
||||
for (n, e) in [("a", "1 mm"), ("b", "2 mm"), ("c", "a + b")] {
|
||||
run(&mut s, "parameters.add", json!({"name": n, "expression": e}));
|
||||
}
|
||||
assert_eq!(user_order(&mut s), ["a", "b", "c"]);
|
||||
let r = run(&mut s, "parameters.move", json!({"name": "c", "before": "a"}));
|
||||
assert_eq!(r["order"], json!(["c", "a", "b"]));
|
||||
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
|
||||
run(&mut s, "parameters.move", json!({"name": "c", "after": "a"}));
|
||||
assert_eq!(user_order(&mut s), ["a", "c", "b"]);
|
||||
// Values and references are untouched; the order is kept in the design file.
|
||||
let list = run(&mut s, "parameters.list", json!({"filter": "user"}));
|
||||
let c = list["parameters"].as_array().unwrap().iter().find(|r| r["name"] == "c").unwrap().clone();
|
||||
assert_eq!(c["expression"], "a + b");
|
||||
assert!((c["value"].as_f64().unwrap() - 3.0).abs() < 1e-9, "{c}");
|
||||
let back = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
let names: Vec<&str> = back.doc.params.iter().filter(|p| !p.model).map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "c", "b"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(user_order(&mut s), ["c", "a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_parameter_refuses_bad_targets() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "a", "expression": "1 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "b", "expression": "2 mm"}));
|
||||
let before = s.doc.to_json();
|
||||
for p in [
|
||||
json!({"name": "a", "before": "a"}),
|
||||
json!({"name": "a", "before": "nope"}),
|
||||
json!({"name": "nope", "before": "a"}),
|
||||
json!({"name": "a"}),
|
||||
json!({"name": "a", "before": "b", "after": "b"}),
|
||||
] {
|
||||
err(&mut s, "parameters.move", p);
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
+214
-32
@@ -64,7 +64,14 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those) → DXF (AutoCAD 2000, mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers, and pockets on POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM"),
|
||||
.params(
|
||||
"body?, path?, curves?: exact|arcs (default exact: an ellipse as ELLIPSE, splines, text outlines and other Béziers as SPLINE; arcs: tangent arcs and lines within 0.001 mm, for machines that read only those), \
|
||||
text?: cut|etch (default cut: letters that text cut through the sheet are cut contours on layer TEXT; etch: single contours on layer ETCH and the sheet keeps its material there; text machined to a depth is always on ETCH), \
|
||||
bend_notes?: bool (default false: a note beside each bend line on layer BEND_NOTES, `B1 UP 90%%d R1`), note_height?: mm (default 3), \
|
||||
per_body?: bool (a file for every sheet body, `<path>-<body>.dxf`, or their DXF texts without a path → files: [{body, path | dxf, bends}]; not with `body`) \
|
||||
→ DXF (AutoCAD 2000, mm): layers OUTLINE (the outer contour), CUT (what is cut out inside it), TEXT or ETCH (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES, \
|
||||
POCKET_<BOTTOM|TOP|INTERNAL>_Z<from>_TO_<to>_MM (one per side and depth) and PERFORATION; inner contours come before the outline",
|
||||
),
|
||||
CommandSpec::new("sheet.export_bend_table", "Export Bend Table", export_bend_table)
|
||||
.noundo()
|
||||
.params("body?, format?: csv|json (default csv), path? → bend table in mm and degrees, including rule and flat centre-line coordinates; a bend that cut-outs split has a row for each piece"),
|
||||
@@ -506,12 +513,79 @@ pub enum DxfCurves {
|
||||
Arcs,
|
||||
}
|
||||
|
||||
/// A DXF (AutoCAD 2000, mm) of the flat pattern on named layers: OUTLINE, BEND_UP and BEND_DOWN
|
||||
/// (centre lines), and one layer per pocket depth. Free-form curves as `curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, DxfShape};
|
||||
/// Where the letters that text cut through the sheet go in the flat DXF.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfText {
|
||||
/// Cut contours on layer TEXT: the sheet has holes in the shape of the letters.
|
||||
#[default]
|
||||
Cut,
|
||||
/// Single contours on layer ETCH: the sheet keeps its material there.
|
||||
Etch,
|
||||
}
|
||||
|
||||
/// What `sheet.export_dxf` is asked for.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct DxfOptions {
|
||||
pub curves: DxfCurves,
|
||||
pub text: DxfText,
|
||||
/// The height (mm) of the note beside each bend line, when it is to have one.
|
||||
pub bend_notes: Option<f64>,
|
||||
}
|
||||
|
||||
/// The layer of a role: its AutoCAD colour index (1 red, 2 yellow, 3 green, 4 cyan, 5 blue,
|
||||
/// 6 magenta, 7 white or black, 8 grey) and, for the bend lines, a dashed line. The pocket layers
|
||||
/// and PERFORATION are green.
|
||||
fn dxf_layer(name: &str) -> solidcraft_io::DxfLayer {
|
||||
use solidcraft_io::DxfLayer;
|
||||
match name {
|
||||
"OUTLINE" => DxfLayer::new(name, 7),
|
||||
"CUT" => DxfLayer::new(name, 4),
|
||||
"TEXT" => DxfLayer::new(name, 2),
|
||||
"ETCH" => DxfLayer::new(name, 6),
|
||||
"BEND_UP" => DxfLayer::new(name, 1).dashed(),
|
||||
"BEND_DOWN" => DxfLayer::new(name, 5).dashed(),
|
||||
"BEND_NOTES" => DxfLayer::new(name, 8),
|
||||
_ => DxfLayer::new(name, 3),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number in a bend note: at most three decimals, none when they are zeros (`90`, `2.5`).
|
||||
fn compact(v: f64) -> String {
|
||||
let t = format!("{v:.3}");
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t.is_empty() || t == "-0" { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// The note beside a bend line, `B1 UP 90%%d R2.5` (number, direction, angle, inner radius): as
|
||||
/// high as `height`, centred half a height beside the middle of the line, parallel to it and
|
||||
/// readable from below or from the right. (`%%d` is AutoCAD's degree sign.)
|
||||
fn bend_note(b: &solidcraft_doc::sheet::BendInfo, height: f64) -> solidcraft_io::DxfShape {
|
||||
let along = (b.end - b.start).normalized().unwrap_or(Vec2::X);
|
||||
let mut angle = along.angle().to_degrees();
|
||||
if angle > 90.0 + 1e-9 {
|
||||
angle -= 180.0;
|
||||
} else if angle <= -90.0 + 1e-9 {
|
||||
angle += 180.0;
|
||||
}
|
||||
let beside = Vec2::from_angle(angle.to_radians()).perp();
|
||||
solidcraft_io::DxfShape::Text {
|
||||
at: (b.start + b.end) * 0.5 + beside * (height * 0.5),
|
||||
height,
|
||||
angle_deg: angle,
|
||||
text: format!("B{} {} {}%%d R{}", b.index, if b.up { "UP" } else { "DOWN" }, compact(b.angle_deg), compact(b.radius)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A DXF (AutoCAD 2000, mm) of the flat pattern on layers by role: OUTLINE (the outer contour of
|
||||
/// each piece), CUT (what is cut out inside it, and the loose pieces of a cut), TEXT or ETCH
|
||||
/// (letters), BEND_UP and BEND_DOWN (dashed centre lines), BEND_NOTES (when asked for), one layer
|
||||
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
|
||||
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
|
||||
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
|
||||
use solidcraft_io::{DxfEntity, DxfShape};
|
||||
let regions = sh.flat_regions()?;
|
||||
let pockets = sh.machining()?;
|
||||
let letters = sh.letters();
|
||||
let pocket_layer = |p: &solidcraft_doc::sheet::SheetPocket| {
|
||||
let side = if p.z0 == 0.0 {
|
||||
"BOTTOM"
|
||||
@@ -522,10 +596,6 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
};
|
||||
format!("POCKET_{side}_Z{:.6}_TO_{:.6}_MM", p.z0, p.z1)
|
||||
};
|
||||
let mut layers = std::collections::BTreeSet::from(["OUTLINE".to_string(), "BEND_UP".to_string(), "BEND_DOWN".to_string()]);
|
||||
for p in &pockets {
|
||||
layers.insert(pocket_layer(p));
|
||||
}
|
||||
let perforation: Vec<(Vec2, f64)> = sh
|
||||
.perforations
|
||||
.iter()
|
||||
@@ -534,29 +604,54 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
if !perforation.is_empty() {
|
||||
layers.insert("PERFORATION".to_string());
|
||||
}
|
||||
let layers: Vec<DxfLayer> = layers
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let color = match name.as_str() {
|
||||
"OUTLINE" => 7,
|
||||
"BEND_UP" => 1,
|
||||
"BEND_DOWN" => 5,
|
||||
_ => 3,
|
||||
};
|
||||
DxfLayer { name, color }
|
||||
// A piece inside another piece's hole (a letter's counter, the middle of a ring cut) is no
|
||||
// outline of the sheet but a loose piece the cut leaves.
|
||||
let nested: Vec<bool> = regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, r)| {
|
||||
let probe = r.outer.segs.first().map(solidcraft_geom::Seg2::mid);
|
||||
probe.is_some_and(|p| regions.iter().enumerate().any(|(i, other)| i != j && other.outer.contains(p)))
|
||||
})
|
||||
.collect();
|
||||
let mut loops: Vec<_> = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|lp| (lp, "OUTLINE".to_string())).collect();
|
||||
for p in &pockets {
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
loops.push((lp, pocket_layer(p)));
|
||||
// The loops, in the order they are written: inner contours, markings, pockets, the outline.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Pass {
|
||||
Cut,
|
||||
Etch,
|
||||
Pocket,
|
||||
Outline,
|
||||
}
|
||||
let mut loops: Vec<(Pass, &solidcraft_geom::Loop2, String)> = Vec::new();
|
||||
for (j, r) in regions.iter().enumerate() {
|
||||
for (k, lp) in std::iter::once(&r.outer).chain(&r.holes).enumerate() {
|
||||
let (pass, layer) = if k == 0 && !nested.get(j).copied().unwrap_or(false) {
|
||||
(Pass::Outline, "OUTLINE")
|
||||
} else if letters.is_letter(lp) {
|
||||
match o.text {
|
||||
DxfText::Cut => (Pass::Cut, "TEXT"),
|
||||
DxfText::Etch => (Pass::Etch, "ETCH"),
|
||||
}
|
||||
} else {
|
||||
(Pass::Cut, "CUT")
|
||||
};
|
||||
loops.push((pass, lp, layer.to_string()));
|
||||
}
|
||||
}
|
||||
for p in &pockets {
|
||||
let layer = pocket_layer(p);
|
||||
for lp in std::iter::once(&p.region.outer).chain(&p.region.holes) {
|
||||
// Text machined to a depth is a marking, whatever its depth.
|
||||
if letters.is_letter(lp) {
|
||||
loops.push((Pass::Etch, lp, "ETCH".to_string()));
|
||||
} else {
|
||||
loops.push((Pass::Pocket, lp, layer.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
loops.sort_by_key(|(pass, ..)| *pass);
|
||||
let mut entities = Vec::new();
|
||||
for (h, layer) in loops {
|
||||
for (_, h, layer) in loops {
|
||||
// A circle is often represented by two semicircles. Keep the whole analytic
|
||||
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
|
||||
if let Some((center, radius)) = loop_circle(h) {
|
||||
@@ -564,7 +659,7 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
continue;
|
||||
}
|
||||
let fitted;
|
||||
let h = match curves {
|
||||
let h = match o.curves {
|
||||
DxfCurves::Arcs if !h.is_line_arc() => {
|
||||
fitted = h
|
||||
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
|
||||
@@ -578,23 +673,110 @@ pub fn flat_dxf(sh: &SheetBody, curves: DxfCurves) -> Result<String> {
|
||||
}
|
||||
for b in sh.bends() {
|
||||
entities.push(DxfEntity { layer: if b.up { "BEND_UP" } else { "BEND_DOWN" }.into(), shape: DxfShape::Line(b.start, b.end) });
|
||||
if let Some(height) = o.bend_notes {
|
||||
entities.push(DxfEntity { layer: "BEND_NOTES".into(), shape: bend_note(&b, height) });
|
||||
}
|
||||
}
|
||||
for (c, r) in perforation {
|
||||
entities.push(DxfEntity { layer: "PERFORATION".into(), shape: DxfShape::Circle { c, r } });
|
||||
}
|
||||
// The four layers of every flat pattern, and those the rest is on.
|
||||
let mut names: std::collections::BTreeSet<String> = ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"].map(String::from).into();
|
||||
names.extend(entities.iter().map(|e| e.layer.clone()));
|
||||
let layers: Vec<_> = names.iter().map(|name| dxf_layer(name)).collect();
|
||||
Ok(solidcraft_io::write_dxf_drawing(&layers, &entities))
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_dxf";
|
||||
/// What a `sheet.export_dxf` call asks for; a value it cannot use is refused.
|
||||
fn dxf_options(p: &Value, cmd: &str) -> Result<DxfOptions> {
|
||||
let curves = match str_(p, "curves").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("exact") => DxfCurves::Exact,
|
||||
Some("arcs") => DxfCurves::Arcs,
|
||||
Some(_) => return Err(bad(cmd, "`curves` must be exact or arcs")),
|
||||
};
|
||||
let text = match p.get("text").filter(|v| !v.is_null()) {
|
||||
None => DxfText::Cut,
|
||||
Some(v) => match v.as_str().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cut") => DxfText::Cut,
|
||||
Some("etch") => DxfText::Etch,
|
||||
_ => return Err(bad(cmd, "`text` must be cut or etch")),
|
||||
},
|
||||
};
|
||||
let height = match p.get("note_height").filter(|v| !v.is_null()) {
|
||||
None => 3.0,
|
||||
Some(v) => {
|
||||
v.as_f64().filter(|h| (0.1..=100.0).contains(h)).ok_or_else(|| bad(cmd, "`note_height` must be a number of mm from 0.1 to 100"))?
|
||||
}
|
||||
};
|
||||
let bend_notes = match p.get("bend_notes").filter(|v| !v.is_null()) {
|
||||
None | Some(Value::Bool(false)) => None,
|
||||
Some(Value::Bool(true)) => Some(height),
|
||||
Some(_) => return Err(bad(cmd, "`bend_notes` must be true or false")),
|
||||
};
|
||||
Ok(DxfOptions { curves, text, bend_notes })
|
||||
}
|
||||
|
||||
/// The files of an export of every sheet body: `path` with `-<body>` before its extension
|
||||
/// (`.dxf` when it has none). Characters a file name cannot hold become `_`, and a name another
|
||||
/// body's file already has (in any case) gets `_2`, `_3`…
|
||||
fn body_paths(path: &str, bodies: &[String]) -> Vec<String> {
|
||||
let p = std::path::Path::new(path);
|
||||
let stem = p.file_stem().map_or_else(|| "flat".to_string(), |s| s.to_string_lossy().to_string());
|
||||
let ext = p.extension().map_or_else(|| "dxf".to_string(), |e| e.to_string_lossy().to_string());
|
||||
let mut taken: Vec<String> = Vec::new();
|
||||
bodies
|
||||
.iter()
|
||||
.map(|body| {
|
||||
let clean: String = body.chars().map(|c| if c.is_control() || "<>:\"/\\|?*".contains(c) { '_' } else { c }).collect();
|
||||
let clean = clean.trim().trim_end_matches(['.', ' ']);
|
||||
let base = if clean.is_empty() { "body" } else { clean };
|
||||
let mut name = base.to_string();
|
||||
let mut n = 2;
|
||||
while taken.contains(&name.to_lowercase()) {
|
||||
name = format!("{base}_{n}");
|
||||
n += 1;
|
||||
}
|
||||
taken.push(name.to_lowercase());
|
||||
p.with_file_name(format!("{stem}-{name}.{ext}")).to_string_lossy().to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_dxf";
|
||||
let options = dxf_options(p, cmd)?;
|
||||
let per_body = match p.get("per_body").filter(|v| !v.is_null()) {
|
||||
None => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`per_body` must be true or false")),
|
||||
};
|
||||
let path = str_(p, "path").filter(|x| !x.trim().is_empty());
|
||||
if per_body {
|
||||
if p.get("body").is_some_and(|b| !b.is_null()) {
|
||||
return Err(bad(cmd, "`per_body` writes every sheet body: leave `body` out"));
|
||||
}
|
||||
let sheets = &s.model.state().sheets;
|
||||
if sheets.is_empty() {
|
||||
return Err(bad(cmd, "there is no sheet metal body"));
|
||||
}
|
||||
// Every file is made before the first is written.
|
||||
let texts = sheets.iter().map(|sh| flat_dxf(sh, &options)).collect::<Result<Vec<_>>>()?;
|
||||
let paths = path.map(|path| body_paths(path, &sheets.iter().map(|sh| sh.body.clone()).collect::<Vec<_>>()));
|
||||
let mut files = Vec::new();
|
||||
for (i, (sh, text)) in sheets.iter().zip(&texts).enumerate() {
|
||||
let bends = sh.bends().len();
|
||||
if let Some(file) = paths.as_ref().and_then(|paths| paths.get(i)) {
|
||||
solidcraft_io::vfs::write(file, text.as_bytes()).map_err(|e| EngineError::Other(format!("{file}: {e}")))?;
|
||||
files.push(json!({"body": sh.body, "path": file, "bends": bends}));
|
||||
} else {
|
||||
files.push(json!({"body": sh.body, "dxf": text, "bends": bends}));
|
||||
}
|
||||
}
|
||||
return Ok(json!({"files": files}));
|
||||
}
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let text = flat_dxf(&sh, curves)?;
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
let text = flat_dxf(&sh, &options)?;
|
||||
if let Some(path) = path {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "bends": sh.bends().len()}));
|
||||
}
|
||||
|
||||
@@ -1492,3 +1492,232 @@ fn holes_beside_chamfer_bends_land_from_the_virtual_sharps() {
|
||||
let expect = t * 100.0 * (2.0 * (30.0 - (r + t)) + (40.0 - 2.0 * (r + t)) + PI * (r + t / 2.0)) - 2.0 * t * 9.0 * PI;
|
||||
assert!((volume(&mut s) - expect).abs() < 2e-3 * expect, "{} vs {expect}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// The rule of [`rule`] with its corner relief: `shape` 8 mm across.
|
||||
fn relief_rule(s: &mut Session, shape: &str) {
|
||||
rule(s, "Test", 2.0, 0.44, 2.0, 0.5);
|
||||
run(s, "sheet.manage_rules", json!({"name": "Test", "corner_relief_shape": shape, "corner_relief": "8 mm"}));
|
||||
}
|
||||
|
||||
/// The flat outline's corners, from the low corner of their box.
|
||||
fn outline_corners(s: &mut Session) -> Vec<[f64; 2]> {
|
||||
let f = flat(s);
|
||||
let loops = f["outline"].as_array().cloned().unwrap_or_default();
|
||||
let pts: Vec<[f64; 2]> =
|
||||
loops.iter().flat_map(|l| l.as_array().cloned().unwrap_or_default()).filter_map(|q| Some([q[0].as_f64()?, q[1].as_f64()?])).collect();
|
||||
let lo = [pts.iter().map(|q| q[0]).fold(f64::MAX, f64::min), pts.iter().map(|q| q[1]).fold(f64::MAX, f64::min)];
|
||||
pts.iter().map(|q| [q[0] - lo[0], q[1] - lo[1]]).collect()
|
||||
}
|
||||
|
||||
/// Whether the flat outline has a corner at each of `want` (from the low corner of its box), the
|
||||
/// flat as it lies or turned in its box: half round, or seen from its other face. (The flat frame
|
||||
/// runs along a longest edge of the fixed face, which may be either of two, and from a shell's
|
||||
/// inner skin it is seen from the other side.)
|
||||
fn outline_has(s: &mut Session, want: &[[f64; 2]]) -> bool {
|
||||
let pts = outline_corners(s);
|
||||
let hi = [pts.iter().map(|q| q[0]).fold(0.0, f64::max), pts.iter().map(|q| q[1]).fold(0.0, f64::max)];
|
||||
let turned = |q: [f64; 2], (fx, fy): (bool, bool)| [if fx { hi[0] - q[0] } else { q[0] }, if fy { hi[1] - q[1] } else { q[1] }];
|
||||
[(false, false), (true, true), (true, false), (false, true)].iter().any(|&turn| {
|
||||
want.iter().all(|w| {
|
||||
pts.iter().any(|q| {
|
||||
let p = turned(*q, turn);
|
||||
(p[0] - w[0]).abs() < 1e-6 && (p[1] - w[1]).abs() < 1e-6
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The corners of a converted tray are a flanged tray's: closed with the rule's gap and given the
|
||||
/// rule's corner relief, in the folded part and the flat pattern. The shelled tray of [`tray`] and
|
||||
/// the same tray made from a base and four 30 mm edge flanges, T 2, R 2, K 0.44, gap 0.5, a relief
|
||||
/// 8 mm across: the bends meet at the base's corners, R + T = 4 in from the sharps, and are
|
||||
/// BA = π/2 (R + K T) = 1.44 π wide in the flat. A notch leaves them there (92 and 52 long); a
|
||||
/// round or a square relief takes 4 off each bend at each corner and r²·π/4 or r² (r = 4) of the
|
||||
/// base; a tear runs each bend on to its corner and cuts its end from there to where its wall's
|
||||
/// closed end starts, g/√2 + T from the sharp: R − g/√2 longer. The conversion used to bend every
|
||||
/// corner as a notch, whatever the rule said.
|
||||
#[test]
|
||||
fn a_converted_trays_corners_are_closed_and_relieved_as_a_flanged_trays_are() {
|
||||
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let grow = r - gap / SQRT_2;
|
||||
// (bend lengths, flat area taken off the notch's at each corner)
|
||||
let cases = [
|
||||
("notch", (92.0, 52.0), 0.0),
|
||||
("round", (84.0, 44.0), 4.0 * PI + 8.0 * ba),
|
||||
("rectangular", (84.0, 44.0), 16.0 + 8.0 * ba),
|
||||
("tear", (92.0 + grow, 52.0 + grow), -grow * ba),
|
||||
];
|
||||
let cut = t + gap / SQRT_2;
|
||||
let walls = 26.0 * 2.0 * ((100.0 - 2.0 * cut) + (60.0 - 2.0 * cut));
|
||||
let flat_notch = t * (92.0 * 52.0 + ba * 2.0 * (92.0 + 52.0) + walls);
|
||||
for (shape, (long, short), taken) in cases {
|
||||
let mut s = Session::default();
|
||||
relief_rule(&mut s, shape);
|
||||
tray(&mut s, t);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Tray", "face": [50, 30, 0], "bends": BOTTOM_EDGES}));
|
||||
assert!(out["warning"].is_null(), "{shape}: {out}");
|
||||
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
|
||||
lengths.sort_by(f64::total_cmp);
|
||||
let want = [short, short, long, long];
|
||||
assert!(lengths.len() == 4 && lengths.iter().zip(want).all(|(l, w)| (l - w).abs() < 1e-6), "{shape}: {lengths:?}");
|
||||
// The same tray made from flanges.
|
||||
let mut f = Session::default();
|
||||
relief_rule(&mut f, shape);
|
||||
run(&mut f, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut f, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut f, "sketch.finish", json!({}));
|
||||
run(&mut f, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut f, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2], [0, 30, 2]], "height": 30}));
|
||||
let mut flanged: Vec<f64> = bend_rows(&mut f).iter().map(|row| row.2).collect();
|
||||
flanged.sort_by(f64::total_cmp);
|
||||
assert!(lengths.iter().zip(&flanged).all(|(a, b)| (a - b).abs() < 1e-9), "{shape}: {lengths:?} vs {flanged:?}");
|
||||
// The flat patterns are congruent, corner for corner.
|
||||
let corners = outline_corners(&mut f);
|
||||
assert!(
|
||||
outline_corners(&mut s).len() == corners.len() && outline_has(&mut s, &corners),
|
||||
"{shape}: {:?} vs {corners:?}",
|
||||
outline_corners(&mut s)
|
||||
);
|
||||
let (v, vf) = (volume(&mut s), volume(&mut f));
|
||||
assert!((v - vf).abs() < 1e-6 * vf, "{shape}: {v} vs {vf}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
run(&mut f, "sheet.unfold", json!({}));
|
||||
let (flat_v, flat_f) = (volume(&mut s), volume(&mut f));
|
||||
assert!((flat_v - flat_f).abs() < 1e-6 * flat_f, "{shape}: {flat_v} vs {flat_f}");
|
||||
// The flat takes the relief off the notch's (whose walls end g/√2 + T from the sharps).
|
||||
let want = flat_notch - 4.0 * t * taken;
|
||||
assert!((flat_v - want).abs() < 0.1, "{shape}: flat {flat_v} vs {want}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{shape}");
|
||||
// A round relief is an arc of radius 4 about each point where two bends meet: the base's
|
||||
// corners R + T in from the sharps, 92 and 52 apart.
|
||||
if shape == "round" {
|
||||
let dxf = run(&mut s, "sheet.export_dxf", json!({}))["dxf"].as_str().unwrap_or_default().to_string();
|
||||
let arcs: Vec<(solidcraft_geom::Vec2, f64)> = solidcraft_io::read_dxf(&dxf)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|g| if let solidcraft_io::Geom2::Arc { c, a, .. } = g { Some((*c, a.dist(*c))) } else { None })
|
||||
.collect();
|
||||
assert_eq!(arcs.len(), 4, "{arcs:?}");
|
||||
let lo = solidcraft_geom::Vec2::new(
|
||||
arcs.iter().map(|(c, _)| c.x).fold(f64::MAX, f64::min),
|
||||
arcs.iter().map(|(c, _)| c.y).fold(f64::MAX, f64::min),
|
||||
);
|
||||
for (x, y) in [(0.0, 0.0), (92.0, 0.0), (92.0, 52.0), (0.0, 52.0)] {
|
||||
let at = lo + solidcraft_geom::Vec2::new(x, y);
|
||||
assert!(arcs.iter().any(|(c, r)| c.dist(at) < 1e-6 && (r - 4.0).abs() < 1e-6), "({x}, {y}): {arcs:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plate 80 x 50, `plate` thick, with a wall 2 thick and 30 high standing on its edge y = 0
|
||||
/// from `x0` to `x1`, joined (with a 2 mm plate, a shell).
|
||||
fn bracket(s: &mut Session, x0: f64, x1: f64, plate: f64) {
|
||||
run(s, "solid.box", json!({"length": 80, "width": 50, "height": plate, "body_name": "Bracket"}));
|
||||
run(s, "solid.box", json!({"length": x1 - x0, "width": 2, "height": 30, "corner": [x0, 0, 0], "operation": "join", "targets": ["Bracket"]}));
|
||||
}
|
||||
|
||||
/// A wall standing on part of an edge (an L-bracket whose flange covers part of the plate's edge,
|
||||
/// a tab): the plate's edge face and the wall's outside are one face, the plate's edge running on
|
||||
/// past the wall a strip T high. Its bend ends where the wall does, and the rule's bend relief
|
||||
/// (2 wide, from 1 past the bend line through the plate's edge) is cut into the plate beside each
|
||||
/// end of the bend that has more plate beside it, as for an edge flange. The strip, lower than the
|
||||
/// bend's setback R + T = 4, was used up by it and the conversion refused; from the inner skin the
|
||||
/// bend ended against the plate without a relief. T 2, R 2, K 0.44: BA = 1.44 π, BD = 8 − BA.
|
||||
#[test]
|
||||
fn a_bend_ends_where_its_wall_ends_with_the_rules_bend_relief_beside_it() {
|
||||
let (t, r, k) = (2.0, 2.0, 0.44);
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let bd = deduction(FRAC_PI_2, r, t, k);
|
||||
// Folded and flat: the plate less the bend's setback under the wall and the reliefs (2 x 5
|
||||
// each), the wall past the bend (30 − 4 high), and the bend on its neutral radius or as its
|
||||
// allowance.
|
||||
let parts = |wall: f64, reliefs: f64| {
|
||||
let flat = 80.0 * 50.0 - 4.0 * wall - 10.0 * reliefs + 26.0 * wall;
|
||||
(t * flat + FRAC_PI_2 * t * (r + t / 2.0) * wall, t * (flat + ba * wall))
|
||||
};
|
||||
// From the flat's low corner (the wall's far edge, 26 + BA − 4 past the plate's edge).
|
||||
let edge = 26.0 + ba - 4.0;
|
||||
// The wall in the middle, picked from the outside or the inside: the same sheet, the reliefs
|
||||
// x 18 … 20 and 60 … 62, through the plate's edge to 1 past the bend line.
|
||||
for (face, bend, way) in [([40.0, 25.0, 0.0], [40.0, 0.0, 0.0], "up"), ([40.0, 25.0, 2.0], [40.0, 2.0, 2.0], "down")] {
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
bracket(&mut s, 20.0, 60.0, 2.0);
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": face, "bends": [bend]}));
|
||||
assert!(out["warning"].is_null(), "{way}: {out}");
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 1 && rows[0].0 == 90 && rows[0].1 == way && (rows[0].2 - 40.0).abs() < 1e-6, "{way}: {rows:?}");
|
||||
assert!(same_size(flat_size(&mut s), (80.0, 80.0 - bd), 1e-6), "{way}: {:?}", flat_size(&mut s));
|
||||
let reliefs = [[18.0, edge], [18.0, edge + 5.0], [20.0, edge + 5.0], [60.0, edge + 5.0], [62.0, edge + 5.0], [62.0, edge]];
|
||||
assert!(outline_has(&mut s, &reliefs), "{way}: {:?}", outline_corners(&mut s));
|
||||
let (folded, flat) = parts(40.0, 2.0);
|
||||
let v = volume(&mut s);
|
||||
assert!((v - folded).abs() < 1e-4 * folded, "{way}: {v} vs {folded}");
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
assert!((volume(&mut s) - flat).abs() < 1e-6 * flat, "{way}: {} vs {flat}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v, "{way}");
|
||||
}
|
||||
// A wall at the plate's end: its bend runs from the plate's corner, one relief at its end.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
bracket(&mut s, 0.0, 40.0, 2.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[20, 0, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 1 && (rows[0].2 - 40.0).abs() < 1e-6, "{rows:?}");
|
||||
assert!(outline_has(&mut s, &[[0.0, 0.0], [40.0, 0.0], [40.0, edge + 5.0], [42.0, edge + 5.0], [42.0, edge]]), "{:?}", outline_corners(&mut s));
|
||||
let (folded, _) = parts(40.0, 1.0);
|
||||
assert!((volume(&mut s) - folded).abs() < 1e-4 * folded, "{} vs {folded}", volume(&mut s));
|
||||
// A strip taller than the setback (a 5 mm plate, the rule's T 2 taken into the block) stays a
|
||||
// low wall of the sheet beside the wall, as before: one bend the plate's length, no relief.
|
||||
let mut s = Session::default();
|
||||
rule(&mut s, "Test", t, k, r, 0.5);
|
||||
bracket(&mut s, 20.0, 60.0, 5.0);
|
||||
run(&mut s, "sheet.convert", json!({"body": "Bracket", "face": [40, 25, 0], "bends": [[40, 0, 0]]}));
|
||||
let rows = bend_rows(&mut s);
|
||||
assert!(rows.len() == 1 && (rows[0].2 - 80.0).abs() < 1e-6, "{rows:?}");
|
||||
let low = t * (80.0 * 46.0 + 80.0 * 1.0 + 40.0 * 25.0) + FRAC_PI_2 * t * (r + t / 2.0) * 80.0;
|
||||
assert!((volume(&mut s) - low).abs() < 1e-4 * low, "{} vs {low}", volume(&mut s));
|
||||
}
|
||||
|
||||
/// Both together: a plate 100 x 60 x 2 with a wall along its whole edge y = 0 and one on its edge
|
||||
/// x = 100 from y = 0 to 30 only, a round corner relief 8 across. The walls meet at the corner,
|
||||
/// closed with the gap (each ends g/√2 + T from the other's outside) and relieved there; the
|
||||
/// shorter one's bend ends at y = 30 with a bend relief beside it. In the flat from the plate's
|
||||
/// corner: the plate from y = 4 and up to x = 96 beside the short wall, 100 past it; bends 96 − 4
|
||||
/// and 26 − 4 long; the bend relief x 95 … 100, y 30 … 32.
|
||||
#[test]
|
||||
fn a_wall_on_part_of_an_edge_meets_a_wall_at_a_relieved_corner_and_is_relieved_at_its_end() {
|
||||
let (t, r, k, gap) = (2.0, 2.0, 0.44, 0.5);
|
||||
let ba = FRAC_PI_2 * (r + k * t);
|
||||
let mut s = Session::default();
|
||||
relief_rule(&mut s, "round");
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 2, "body_name": "Part"}));
|
||||
run(&mut s, "solid.box", json!({"length": 100, "width": 2, "height": 30, "operation": "join", "targets": ["Part"]}));
|
||||
run(&mut s, "solid.box", json!({"length": 2, "width": 30, "height": 30, "corner": [98, 0, 0], "operation": "join", "targets": ["Part"]}));
|
||||
let out = run(&mut s, "sheet.convert", json!({"body": "Part", "face": [50, 30, 0], "bends": [[50, 0, 0], [100, 15, 0]]}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
let mut lengths: Vec<f64> = bend_rows(&mut s).iter().map(|row| row.2).collect();
|
||||
lengths.sort_by(f64::total_cmp);
|
||||
assert!(lengths.len() == 2 && (lengths[0] - 22.0).abs() < 1e-6 && (lengths[1] - 92.0).abs() < 1e-6, "{lengths:?}");
|
||||
// The plate less the setbacks, the round relief and the bend relief; the walls 26 high, each
|
||||
// from the corner's closed end to its own end.
|
||||
let cut = t + gap / SQRT_2;
|
||||
let base = 100.0 * 56.0 - 4.0 * 26.0 - 4.0 * PI - 10.0;
|
||||
let walls = 26.0 * ((100.0 - cut) + (30.0 - cut));
|
||||
let folded = t * (base + walls) + FRAC_PI_2 * t * (r + t / 2.0) * 114.0;
|
||||
let v = volume(&mut s);
|
||||
assert!((v - folded).abs() < 1e-4 * folded, "{v} vs {folded}");
|
||||
// From the flat's low corner (the long wall's far edge, 26 + BA − 4 past y = 0): the bend
|
||||
// relief's corners.
|
||||
let edge = 26.0 + ba - 4.0;
|
||||
assert!(outline_has(&mut s, &[[95.0, edge + 30.0], [95.0, edge + 32.0], [100.0, edge + 32.0]]), "{:?}", outline_corners(&mut s));
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
let flat = t * (base + walls + ba * 114.0);
|
||||
assert!((volume(&mut s) - flat).abs() < 0.1, "{} vs {flat}", volume(&mut s));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert!((volume(&mut s) - v).abs() < 1e-6 * v);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities.
|
||||
//! The flat pattern's DXF keeps ellipses and free-form curves as ELLIPSE and SPLINE entities, and
|
||||
//! a laser-cutting shop can use it as it is: layers by role, bend notes, letters cut or etched,
|
||||
//! one file per sheet body.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
@@ -16,11 +18,18 @@ fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
|
||||
/// A 100 × 60 base flange (the default 2.5 mm rule) with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_with(curves: &[(&str, Value)]) -> Session {
|
||||
let mut s = Session::default();
|
||||
plate_in(Session::default(), curves)
|
||||
}
|
||||
|
||||
/// A 100 × 60 base flange in `s` (of its rule), with `curves` drawn in a sketch and cut through it.
|
||||
fn plate_in(mut s: Session, curves: &[(&str, Value)]) -> Session {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
if curves.is_empty() {
|
||||
return s;
|
||||
}
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
for (command, params) in curves {
|
||||
run(&mut s, command, params.clone());
|
||||
@@ -30,6 +39,31 @@ fn plate_with(curves: &[(&str, Value)]) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// A session whose active rule is 2 mm sheet with an inner bend radius of 1 mm.
|
||||
fn sheet_of_2mm() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "thickness": "2 mm", "k_factor": "0.5", "bend_radius": "1 mm", "active": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A 100 × 60 tray of 2 mm sheet with `curves` cut through its base: three 20 mm walls bend up and
|
||||
/// one bends down.
|
||||
fn tray_with(curves: &[(&str, Value)]) -> Session {
|
||||
let mut s = plate_in(sheet_of_2mm(), curves);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2], [100, 30, 2], [50, 60, 2]], "height": 20}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[0, 30, 2]], "height": 20, "flip": true}));
|
||||
s
|
||||
}
|
||||
|
||||
/// What a tray is marked with: a Ø5 hole, a 20 × 5 slot and the label "AB-12", 10 mm high at (20, 28).
|
||||
fn marks() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("sketch.circle.center", json!({"center": [15, 15], "diameter": 5})),
|
||||
("sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]})),
|
||||
("sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10})),
|
||||
]
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, params: Value) -> String {
|
||||
run(s, "sheet.export_dxf", params)["dxf"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
@@ -217,5 +251,272 @@ fn bend_lines_and_layers_are_still_there() {
|
||||
let es = entities(out["dxf"].as_str().unwrap_or_default());
|
||||
let on = |layer: &str| es.iter().filter(|e| e.1.contains(&(8, layer.to_string()))).count();
|
||||
assert_eq!(on("BEND_UP") + on("BEND_DOWN"), out["bends"].as_u64().unwrap_or(0) as usize);
|
||||
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("OUTLINE") == es.len() - on("BEND_UP") - on("BEND_DOWN"));
|
||||
// The ellipse is a cut-out, on CUT; the outline is the plate's.
|
||||
assert!(on("BEND_UP") + on("BEND_DOWN") > 0 && count(&es, "ELLIPSE") == 1 && on("CUT") == 1);
|
||||
assert_eq!(on("OUTLINE") + on("CUT"), es.len() - on("BEND_UP") - on("BEND_DOWN"));
|
||||
}
|
||||
|
||||
/// The layer an entity is on.
|
||||
fn layer_of(e: &Entity) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == 8).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The text of an entity's group code.
|
||||
fn string(e: &Entity, code: i32) -> String {
|
||||
e.1.iter().find(|(c, _)| *c == code).map(|(_, v)| v.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What the entities of a layer are, without their handles and the layer: to compare layers of two files.
|
||||
fn on_layer(es: &[Entity], layer: &str) -> Vec<(String, Vec<(i32, String)>)> {
|
||||
es.iter().filter(|e| layer_of(e) == layer).map(|e| (e.0.clone(), e.1.iter().filter(|(c, _)| ![5, 8].contains(c)).cloned().collect())).collect()
|
||||
}
|
||||
|
||||
/// The LAYER table: name → (colour, linetype).
|
||||
fn layer_table(dxf: &str) -> std::collections::BTreeMap<String, (i64, String)> {
|
||||
let lines: Vec<&str> = dxf.lines().collect();
|
||||
let pairs: Vec<(i32, &str)> = lines.chunks(2).filter_map(|c| Some((c.first()?.trim().parse().ok()?, c.get(1)?.trim()))).collect();
|
||||
let start = pairs.iter().position(|p| *p == (2, "LAYER")).unwrap_or(0);
|
||||
let mut records: Vec<Vec<(i32, &str)>> = Vec::new();
|
||||
for p in pairs.iter().skip(start + 1) {
|
||||
match *p {
|
||||
(0, "ENDTAB") => break,
|
||||
(0, _) => records.push(Vec::new()),
|
||||
_ => {
|
||||
if let Some(record) = records.last_mut() {
|
||||
record.push(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
records
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let get = |code: i32| r.iter().find(|(c, _)| *c == code).map(|(_, v)| *v);
|
||||
Some((get(2)?.to_string(), (get(62)?.parse().ok()?, get(6)?.to_string())))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn vec2(v: &Value) -> Vec2 {
|
||||
Vec2::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
|
||||
let mut s = tray_with(&marks());
|
||||
let out = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let dxf = out["dxf"].as_str().unwrap_or_default();
|
||||
let es = entities(dxf);
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
// OUTLINE: the tray's outer contour alone, one entity for each of its segments (lines and arcs).
|
||||
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
|
||||
let plate =
|
||||
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
|
||||
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
|
||||
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
|
||||
let cut = on("CUT");
|
||||
assert_eq!((count_of(&cut, "CIRCLE"), count_of(&cut, "LINE"), cut.len()), (1, 4, 5), "{:?}", cut.iter().map(|e| &e.0).collect::<Vec<_>>());
|
||||
// TEXT: the letters of "AB-12" and the counters of A and B, all within the label's box (5 letters
|
||||
// of at most 10 mm from (20, 28)).
|
||||
let letters = on("TEXT");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
// The bend lines by direction: three walls up, one down.
|
||||
assert_eq!((on("BEND_UP").len(), on("BEND_DOWN").len(), out["bends"].as_u64()), (3, 1, Some(4)));
|
||||
// Inner contours are written before the outline, so a program that follows the file cuts inside-out.
|
||||
let last_inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT"));
|
||||
let first_outline = es.iter().position(|e| layer_of(e) == "OUTLINE");
|
||||
let (Some(last_inner), Some(first_outline)) = (last_inner, first_outline) else { panic!("{last_inner:?} {first_outline:?}") };
|
||||
assert!(last_inner < first_outline, "an inner contour at {last_inner}, the outline from {first_outline}");
|
||||
// The layers, with the colours and linetypes the guide gives; nothing else is declared.
|
||||
let want = [
|
||||
("0", 7, "Continuous"),
|
||||
("OUTLINE", 7, "Continuous"),
|
||||
("CUT", 4, "Continuous"),
|
||||
("TEXT", 2, "Continuous"),
|
||||
("BEND_UP", 1, "DASHED"),
|
||||
("BEND_DOWN", 5, "DASHED"),
|
||||
];
|
||||
assert_eq!(layer_table(dxf), want.map(|(n, c, l)| (n.to_string(), (c, l.to_string()))).into_iter().collect());
|
||||
}
|
||||
|
||||
/// How many entities of `kind` there are among `list`.
|
||||
fn count_of(list: &[&Entity], kind: &str) -> usize {
|
||||
list.iter().filter(|e| e.0 == kind).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_can_be_etched_instead_of_cut() {
|
||||
let mut s = tray_with(&marks());
|
||||
let cut = entities(&export(&mut s, json!({})));
|
||||
let etched_dxf = export(&mut s, json!({"text": "etch"}));
|
||||
let etched = entities(&etched_dxf);
|
||||
// The same contours, on ETCH instead of TEXT: nothing of them is cut.
|
||||
assert!(!on_layer(&cut, "TEXT").is_empty());
|
||||
assert_eq!(on_layer(&etched, "ETCH"), on_layer(&cut, "TEXT"));
|
||||
assert!(on_layer(&etched, "TEXT").is_empty());
|
||||
// The hole and the slot are no text: they stay where they were, as do the outline and the bends.
|
||||
for layer in ["OUTLINE", "CUT", "BEND_UP", "BEND_DOWN"] {
|
||||
assert_eq!(on_layer(&etched, layer), on_layer(&cut, layer), "{layer}");
|
||||
}
|
||||
let layers = layer_table(&etched_dxf);
|
||||
assert_eq!(layers.get("ETCH"), Some(&(6, "Continuous".to_string())));
|
||||
assert!(!layers.contains_key("TEXT"));
|
||||
// `cut` is the default, and a value that is neither is refused.
|
||||
assert_eq!(export(&mut s, json!({"text": "cut"})), export(&mut s, json!({})));
|
||||
assert!(s.execute("sheet.export_dxf", &json!({"text": "fill"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engraved_text_is_etched_and_other_pockets_keep_their_depth_layer() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Marks"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
|
||||
run(&mut s, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Part way through the sheet: pockets, 1 mm deep from the bottom.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
|
||||
let dxf = export(&mut s, json!({}));
|
||||
let es = entities(&dxf);
|
||||
// The rectangle is a pocket of the depth its layer names; the letters are a marking, on ETCH.
|
||||
let pocket = "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM";
|
||||
let on = |layer: &str| es.iter().filter(|e| layer_of(e) == layer).collect::<Vec<_>>();
|
||||
assert_eq!((count_of(&on(pocket), "LINE"), on(pocket).len()), (4, 4));
|
||||
let letters = on("ETCH");
|
||||
assert!(letters.len() > 20, "{} letter entities", letters.len());
|
||||
for e in &letters {
|
||||
let p = point(e, 10);
|
||||
assert!(p.x > 19.0 && p.x < 80.0 && p.y > 27.0 && p.y < 39.0, "{} at {p:?}", e.0);
|
||||
}
|
||||
let layers = layer_table(&dxf);
|
||||
assert!(layers.contains_key(pocket) && layers.contains_key("ETCH") && !layers.contains_key("TEXT"), "{layers:?}");
|
||||
// `text` is about letters cut through: it changes nothing here.
|
||||
assert_eq!(on_layer(&entities(&export(&mut s, json!({"text": "etch"}))), "ETCH"), on_layer(&es, "ETCH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bend_note_sits_beside_each_bend_line_when_asked_for() {
|
||||
let mut s = tray_with(&[]);
|
||||
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
|
||||
let bends = flat["bends"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(bends.len(), 4);
|
||||
// Not unless asked for: a program that follows the file would etch them.
|
||||
let plain = export(&mut s, json!({}));
|
||||
assert!(entities(&plain).iter().all(|e| e.0 != "TEXT") && !layer_table(&plain).contains_key("BEND_NOTES"));
|
||||
let dxf = export(&mut s, json!({"bend_notes": true}));
|
||||
let notes: Vec<Entity> = entities(&dxf).into_iter().filter(|e| e.0 == "TEXT").collect();
|
||||
assert_eq!(notes.len(), 4);
|
||||
assert!(notes.iter().all(|e| layer_of(e) == "BEND_NOTES"));
|
||||
assert_eq!(layer_table(&dxf).get("BEND_NOTES"), Some(&(8, "Continuous".to_string())));
|
||||
for b in &bends {
|
||||
// The walls bend by 90° round an inner radius of 1 mm: "B1 UP 90° R1" (`%%d` is the degree sign).
|
||||
let up = b["direction"] == "up";
|
||||
let text = format!("B{} {} 90%%d R1", b["bend"].as_u64().unwrap_or(0), if up { "UP" } else { "DOWN" });
|
||||
let Some(note) = notes.iter().find(|e| string(e, 1) == text) else {
|
||||
panic!("no note `{text}` among {:?}", notes.iter().map(|e| string(e, 1)).collect::<Vec<_>>())
|
||||
};
|
||||
// Centred (72 = 1), 3 mm high, parallel to the line: along x it reads left to right, along y upwards.
|
||||
let (start, end) = (vec2(&b["start"]), vec2(&b["end"]));
|
||||
let angle = if (end - start).x.abs() > (end - start).y.abs() { 0.0 } else { 90.0 };
|
||||
assert!(values(note, 72) == [1.0] && values(note, 40) == [3.0] && (values(note, 50)[0] - angle).abs() < 1e-9, "{note:?}");
|
||||
// On the left of the way it reads, half its height from the middle of the line.
|
||||
let off = point(note, 11) - (start + end) * 0.5;
|
||||
assert!(off.dist(Vec2::from_angle(angle.to_radians()).perp() * 1.5) < 1e-9, "{text}: {off:?}");
|
||||
}
|
||||
// The height is the caller's.
|
||||
let tall = entities(&export(&mut s, json!({"bend_notes": true, "note_height": 5})));
|
||||
let note = tall.iter().find(|e| e.0 == "TEXT");
|
||||
assert!(note.is_some_and(|e| values(e, 40) == [5.0]));
|
||||
}
|
||||
|
||||
/// Two sheet bodies: a lid and, 200 mm away, a side wall with one bend.
|
||||
fn lid_and_wall() -> Session {
|
||||
let mut s = Session::default();
|
||||
for (sketch, p0, p1, body) in [("Lid", [0, 0], [100, 60], "Lid"), ("Wall", [200, 0], [250, 40], "Side Wall")] {
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": sketch}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": p0, "p1": p1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": sketch, "body_name": body}));
|
||||
}
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[225, 0, 2.5]], "height": 15, "body": "Side Wall"}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_sheet_body_can_have_a_file_of_its_own() {
|
||||
let mut s = lid_and_wall();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true}));
|
||||
let files = r["files"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap_or_default()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
|
||||
assert_eq!(files.iter().map(|f| f["bends"].as_u64()).collect::<Vec<_>>(), [Some(0), Some(1)]);
|
||||
// Each is the file `body` asks for: the lid's has no bend line, the wall's has its one.
|
||||
for f in &files {
|
||||
let single = export(&mut s, json!({"body": f["body"]}));
|
||||
assert_eq!(f["dxf"].as_str(), Some(single.as_str()), "{}", f["body"]);
|
||||
}
|
||||
let bend_lines = |f: &Value| entities(f["dxf"].as_str().unwrap_or_default()).iter().filter(|e| layer_of(e) == "BEND_UP").count();
|
||||
assert_eq!((bend_lines(&files[0]), bend_lines(&files[1])), (0, 1));
|
||||
// With a path, each body is written to a file named after it, beside the path, and no text comes back.
|
||||
let dir = solidcraft_io::vfs::temp_dir().join(format!("solidcraft-dxf-per-body-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_default();
|
||||
let path = dir.join("panel.dxf").to_string_lossy().to_string();
|
||||
let r = run(&mut s, "sheet.export_dxf", json!({"per_body": true, "path": path}));
|
||||
let written = r["files"].as_array().cloned().unwrap_or_default();
|
||||
let names: Vec<_> = written
|
||||
.iter()
|
||||
.map(|f| std::path::Path::new(f["path"].as_str().unwrap_or_default()).file_name().map(|n| n.to_string_lossy().to_string()))
|
||||
.collect();
|
||||
assert_eq!(names, [Some("panel-Lid.dxf".to_string()), Some("panel-Side Wall.dxf".to_string())]);
|
||||
for (f, written) in files.iter().zip(&written) {
|
||||
assert!(written.get("dxf").is_none());
|
||||
let on_disk = std::fs::read_to_string(written["path"].as_str().unwrap_or_default()).unwrap_or_default();
|
||||
assert_eq!(f["dxf"].as_str(), Some(on_disk.as_str()));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_export_options_are_refused_not_ignored() {
|
||||
let mut s = tray_with(&[]);
|
||||
for p in [
|
||||
json!({"text": "fill"}),
|
||||
json!({"text": 1}),
|
||||
json!({"bend_notes": "yes"}),
|
||||
json!({"bend_notes": 1}),
|
||||
json!({"note_height": 0}),
|
||||
json!({"note_height": -3}),
|
||||
json!({"note_height": 1e9}),
|
||||
json!({"note_height": "tall"}),
|
||||
json!({"per_body": "yes"}),
|
||||
json!({"per_body": true, "body": "Body1"}),
|
||||
] {
|
||||
assert!(s.execute("sheet.export_dxf", &p).is_err(), "{p}");
|
||||
}
|
||||
// Nothing was left half done.
|
||||
assert!(s.execute("sheet.export_dxf", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_of_a_body_is_named_after_it_and_no_two_share_a_name() {
|
||||
// (Windows writes the separator it adds as `\`.)
|
||||
let names = |path: &str, bodies: &[&str]| -> Vec<String> {
|
||||
super::body_paths(path, &bodies.iter().map(|b| b.to_string()).collect::<Vec<_>>()).into_iter().map(|p| p.replace('\\', "/")).collect()
|
||||
};
|
||||
assert_eq!(names("out/panel.dxf", &["Lid", "Side Wall"]), ["out/panel-Lid.dxf", "out/panel-Side Wall.dxf"]);
|
||||
// The extension is the path's, and .dxf when it has none.
|
||||
assert_eq!(names("out/panel", &["Lid"]), ["out/panel-Lid.dxf"]);
|
||||
assert_eq!(names("panel.DXF", &["Lid"]), ["panel-Lid.DXF"]);
|
||||
// What a file name cannot hold becomes `_`; a name that is nothing but that stays a name.
|
||||
assert_eq!(names("p.dxf", &["a/b:c", " ", "wall."]), ["p-a_b_c.dxf", "p-body.dxf", "p-wall.dxf"]);
|
||||
// Two bodies that would share a file (in any case) get a number.
|
||||
assert_eq!(names("p.dxf", &["Lid", "lid", "LID", "Lid_2"]), ["p-Lid.dxf", "p-lid_2.dxf", "p-LID_3.dxf", "p-Lid_2_2.dxf"]);
|
||||
}
|
||||
@@ -1551,7 +1551,7 @@ fn flat_dxf_preserves_exact_cutout_entities() {
|
||||
outer: Loop2 { segs: vec![Seg2::Arc { center: Vec2::new(40.0, 30.0), radius: 5.0, start: -PI, sweep: -2.0 * PI }] },
|
||||
holes: Vec::new(),
|
||||
}];
|
||||
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
|
||||
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).unwrap();
|
||||
assert_eq!(dxf.matches("0\nCIRCLE\n").count(), 1, "{dxf}");
|
||||
assert!(!dxf.contains("0\nARC\n"), "a full circle must not become a zero-length ARC");
|
||||
let read = solidcraft_io::read_dxf(&dxf).unwrap();
|
||||
@@ -1563,7 +1563,7 @@ fn flat_dxf_preserves_exact_cutout_entities() {
|
||||
Seg2::Arc { center: Vec2::new(20.0, 10.0), radius: 10.0, start: 0.0, sweep: PI },
|
||||
],
|
||||
};
|
||||
let dxf = super::flat_dxf(&sh, super::DxfCurves::Exact).unwrap();
|
||||
let dxf = super::flat_dxf(&sh, &super::DxfOptions::default()).unwrap();
|
||||
assert_eq!(dxf.matches("0\nLINE\n").count(), 5, "four outline lines and one exact cutout line");
|
||||
assert_eq!(dxf.matches("0\nARC\n").count(), 1);
|
||||
assert!(dxf.contains(" 50\n0.0\n 51\n180.0\n"), "{dxf}");
|
||||
@@ -2001,7 +2001,9 @@ fn dxf_outline_only(text: &str) -> String {
|
||||
for end in 1..=pairs.len() {
|
||||
if end == pairs.len() || pairs.get(end).is_some_and(|p| p.0 == "0") {
|
||||
let entity = &pairs[start..end];
|
||||
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE")) && entity.iter().any(|p| p.0 == "8" && p.1 == "OUTLINE") {
|
||||
if entity.first().is_some_and(|p| matches!(p.1, "LINE" | "ARC" | "CIRCLE"))
|
||||
&& entity.iter().any(|p| p.0 == "8" && matches!(p.1, "OUTLINE" | "CUT"))
|
||||
{
|
||||
for (code, value) in entity {
|
||||
out.push_str(&format!("{code}\n{value}\n"));
|
||||
}
|
||||
@@ -4145,3 +4147,149 @@ fn perforate_supports_a_square_pattern_and_refuses_impossible_values() {
|
||||
assert!(s.execute("sheet.perforate", &bad).is_err(), "{bad} must be refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 100 x 60 tray with one 45° flange 40 high on its y = 0 edge (2.5 mm, R 2.5, K 0.44), and on
|
||||
/// it a return 10 high when asked. Returns the far edge of the last panel in the flat pattern:
|
||||
/// the base starts at the setback s = (t + R)·tan 22.5°, each bend's zone is (π/4)·(R + K·t) long,
|
||||
/// and a panel h high is h − s long, or h − 2s with a return on its far edge.
|
||||
fn tray_with_a_45_degree_flange(s: &mut Session, with_return: bool) -> f64 {
|
||||
base(s, 100.0, 60.0);
|
||||
run(s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 40, "angle": 45}));
|
||||
let (setback, ba) = (5.0 * (PI / 8.0).tan(), PI / 4.0 * (2.5 + 0.44 * 2.5));
|
||||
if with_return {
|
||||
run(s, "sheet.flange", json!({"edges": [[50, -27.5, 26.5]], "height": 10, "angle": 45}));
|
||||
return setback - ba - (40.0 - 2.0 * setback) - ba - (10.0 - setback);
|
||||
}
|
||||
setback - ba - (40.0 - setback)
|
||||
}
|
||||
|
||||
/// A point on that flange's inside face, the plane y + z = 2.5·√2 (tangent to the bend's inside
|
||||
/// radius, 2.5 about y = 5·tan 22.5°, z = 5).
|
||||
const FLANGE_FACE: [f64; 3] = [50.0, 2.5 * std::f64::consts::SQRT_2 - 15.0, 15.0];
|
||||
|
||||
fn last_warning(s: &Session) -> String {
|
||||
s.model.results.last().and_then(|r| r.warning.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn parted_note(area: f64, at: [f64; 2]) -> String {
|
||||
format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near ({:.1}, {:.1}) in the flat pattern); it is kept", at[0], at[1])
|
||||
}
|
||||
|
||||
fn flat_pieces(s: &Session) -> Vec<f64> {
|
||||
s.model.state().sheets[0].flat_regions().unwrap().iter().map(solidcraft_geom::Region2::area).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_that_parts_a_flange_from_the_base_keeps_it_and_says_so_folded_or_flat() {
|
||||
// A slot across the flange beside its bend (flat y −3 to −1.5) leaves the flange's panel past
|
||||
// it, with any return on it, joined to nothing: 100 × (−3 − far). Cut on the folded flange's
|
||||
// face or in the unfolded sheet, the piece stays, and the cut names it in its reply and on the
|
||||
// timeline, with the middle of the piece in the flat pattern.
|
||||
for (with_return, unfolded) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let what = format!("return {with_return}, unfolded {unfolded}");
|
||||
let mut s = Session::default();
|
||||
let far = tray_with_a_45_degree_flange(&mut s, with_return);
|
||||
let out = if unfolded {
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
rectangle_tool(&mut s, "Slot", [-5.0, -3.0], [105.0, -1.5]);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}))
|
||||
} else {
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Slot"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-5, -3], "p1": [105, -1.5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "negative", "operation": "cut"}))
|
||||
};
|
||||
let note = parted_note(100.0 * (-3.0 - far), [50.0, (far - 3.0) / 2.0]);
|
||||
assert!(last_warning(&s).contains(¬e), "{what}: {}", last_warning(&s));
|
||||
assert_eq!(out["warning"], json!(last_warning(&s)), "{what}: {out}");
|
||||
// Both pieces stay, folded and flat: the base with its bend left, and the flange.
|
||||
let pieces = flat_pieces(&s);
|
||||
assert!(pieces.len() == 2 && pieces.iter().any(|a| (a - 100.0 * (-3.0 - far)).abs() < 1e-6), "{what}: {pieces:?}");
|
||||
for command in if unfolded { ["sheet.refold", "sheet.unfold"] } else { ["sheet.unfold", "sheet.refold"] } {
|
||||
run(&mut s, command, json!({}));
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2), "{what} {command}");
|
||||
assert_eq!(flat_pieces(&s), pieces, "{what} {command}");
|
||||
if command == "sheet.unfold" {
|
||||
assert!((volume(&mut s) - pieces.iter().sum::<f64>() * 2.5).abs() < 1e-3, "{what}: {}", volume(&mut s));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_over_a_flanges_edge_names_the_piece_of_its_inside_on_the_flange_but_not_an_island() {
|
||||
// A ring Ø16 / Ø10 cut on the folded flange's face, centred on its far edge: the half of its
|
||||
// inside on the flange (π·5²/2, its centroid 4·5/3π inside the edge) has nothing round it,
|
||||
// and is named. Centred on the flange, its inside is an island, and nothing is named.
|
||||
for (y, named) in [(None, true), (Some(-20.0), false)] {
|
||||
let mut s = Session::default();
|
||||
let far = tray_with_a_45_degree_flange(&mut s, false);
|
||||
let y = y.unwrap_or(far);
|
||||
run(&mut s, "sketch.create", json!({"plane": {"face": FLANGE_FACE}, "name": "Ring"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 16}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, y], "diameter": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Ring", "profiles": [{"point": [56.5, y]}], "through_all": true, "direction": "negative", "operation": "cut"}),
|
||||
);
|
||||
let warning = out["warning"].as_str().unwrap_or_default().to_string();
|
||||
let note = parted_note(12.5 * PI, [50.0, far + 20.0 / (3.0 * PI)]);
|
||||
assert_eq!(warning.contains(¬e), named, "{y}: {warning}");
|
||||
assert!(named || !warning.contains("no longer joined"), "{y}: {warning}");
|
||||
assert_eq!(flat_pieces(&s).len(), 2, "{y}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_that_split_a_sheet_or_cut_it_beside_the_base_name_only_the_pieces_they_part() {
|
||||
// Unfolded, a strip across tray and flange leaves two pieces that each keep their part of
|
||||
// the base: nothing to name. A box over the smaller piece's base and bend then leaves its
|
||||
// flange panel, 26 × (−3 − far), named; a hole in the base after it names nothing again.
|
||||
let mut s = Session::default();
|
||||
let far = tray_with_a_45_degree_flange(&mut s, false);
|
||||
run(&mut s, "sheet.unfold", json!({}));
|
||||
rectangle_tool(&mut s, "Strip", [70.0, -60.0], [74.0, 70.0]);
|
||||
let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert!(out["warning"].is_null() && last_warning(&s).is_empty(), "{out}");
|
||||
rectangle_tool(&mut s, "Box", [72.0, -3.0], [101.0, 61.0]);
|
||||
let out = run(&mut s, "solid.extrude", json!({"sketch": "Box", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert_eq!(out["warning"], json!(parted_note(26.0 * (-3.0 - far), [87.0, (far - 3.0) / 2.0])), "{out}");
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Hole"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [30, 30], "diameter": 8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let out = run(&mut s, "solid.extrude", json!({"sketch": "Hole", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert!(out["warning"].is_null(), "{out}");
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
assert_eq!(flat(&mut s)["outline"].as_array().map(Vec::len), Some(2));
|
||||
// Folded, a slot across the base beside the bend leaves the flange with a strip of the base;
|
||||
// a plain plate cut in two halves. Neither names anything.
|
||||
let mut s = Session::default();
|
||||
tray_with_a_45_degree_flange(&mut s, false);
|
||||
rectangle_tool(&mut s, "Slot", [-5.0, 3.0], [105.0, 6.0]);
|
||||
let out = run(&mut s, "solid.extrude", json!({"sketch": "Slot", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}");
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
rectangle_tool(&mut s, "Strip", [45.0, -5.0], [55.0, 65.0]);
|
||||
let out = run(&mut s, "solid.extrude", json!({"sketch": "Strip", "through_all": true, "direction": "symmetric", "operation": "cut"}));
|
||||
assert!(out["warning"].is_null() && flat_pieces(&s).len() == 2, "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hole_that_parts_a_flange_from_the_base_names_it() {
|
||||
// A 10 x 60 plate with a 90° flange 40 high on its y = 0 edge: the flange's panel runs from
|
||||
// flat y = 5 − BA (world z = 5) for 35 mm. A Ø12 hole drilled into it at z = 15 (flat
|
||||
// y = 5 − BA − 10) takes its whole width, and leaves the 25 mm below the hole's centre less
|
||||
// the half disc there: 10 × 25 − ∫ √(36 − u²) du over |u| ≤ 5 = 250 − (5√11 + 36·asin(5/6)).
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 10.0, 60.0);
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[5, 0, 2.5]], "height": 40}));
|
||||
let out = run(&mut s, "solid.hole", json!({"position": [5, 0, 15], "diameter": 12}));
|
||||
let area = 250.0 - (5.0 * 11f64.sqrt() + 36.0 * (5.0f64 / 6.0).asin());
|
||||
let warning = out["warning"].as_str().unwrap_or_default();
|
||||
assert!(warning.starts_with(&format!("1 piece of `Body1` is no longer joined to its base ({area:.2} mm², near (5.0, ")), "{warning}");
|
||||
assert_eq!(out["warning"], json!(last_warning(&s)));
|
||||
assert_eq!(flat_pieces(&s).len(), 2);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("surface.sweep", "Surface Sweep", surface_sweep)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_sweep")
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); name?, body_name? (a surface body, no caps)"),
|
||||
.params("sketch?: id|name; curves?: [curve ids] and/or profiles? (as surface.extrude); path_sketch: sketch; path: [curve ids in order] (a path with corners, or 3D curves, gives ruled walls mitred at the corners); orientation?, twist?, scale? (as solid.sweep); name?, body_name? (a surface body, no caps)"),
|
||||
CommandSpec::new("surface.loft", "Surface Loft", surface_loft)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("surface_loft")
|
||||
@@ -192,7 +192,8 @@ fn surface_sweep(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
|
||||
}
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path })
|
||||
let (orientation, twist, scale) = super::features::sweep_options(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SurfaceSweep { sketch, profiles, curves, path_sketch, path, orientation, twist, scale })
|
||||
}
|
||||
|
||||
fn surface_loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Sweep options through the commands: orientation, twist and end scale on solid.sweep and
|
||||
//! surface.sweep, checked against analytic values; naming, saving and hostile values.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// A circle `C` (radius 10, curve `c`), a square `Q` (side 10, curves `a`…`d`) on XY, and a
|
||||
/// path `P`: a line 40 up the Z axis (`up`).
|
||||
fn setup() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "C"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10, "id": "c"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Q"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-5, -5], [5, -5], [5, 5], [-5, 5]], "closed": true, "ids": ["a", "b", "c", "d"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "P"}));
|
||||
// The XZ sketch's y runs down world Z.
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [0, -40]], "ids": ["up"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
fn measure(s: &Session, body: &str) -> solidcraft_kernel::BodyMeasure {
|
||||
solidcraft_kernel::measure(&s.world_state().body(body).unwrap().body).unwrap()
|
||||
}
|
||||
|
||||
fn sweep(s: &mut Session, sketch: &str, body: &str, extra: Value) -> Result<Value, String> {
|
||||
let mut p = json!({"sketch": sketch, "path_sketch": "P", "path": ["up"], "operation": "new", "body_name": body});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("solid.sweep", &p).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn face_names(s: &mut Session, body: &str) -> Vec<String> {
|
||||
let f = run(s, "model.faces", json!({ "body": body }));
|
||||
let mut n: Vec<String> = f["faces"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|x| x["name"].as_str())
|
||||
// Without the feature's own prefix (`F7:`), to compare features.
|
||||
.map(|x| x.split_once(':').map_or(x, |(_, rest)| rest).to_string())
|
||||
.collect();
|
||||
n.sort();
|
||||
n
|
||||
}
|
||||
|
||||
/// A circle swept up a line to end scale 0.5 is a cone frustum; the surface sweep of its curve
|
||||
/// is the frustum's lateral face. Saved and reopened, the design gives the same solid.
|
||||
#[test]
|
||||
fn scaled_sweep_is_a_frustum() {
|
||||
let mut s = setup();
|
||||
let (big, small, h) = (10.0, 5.0, 40.0);
|
||||
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
|
||||
let lateral = PI * (big + small) * (h * h + (big - small) * (big - small)).sqrt();
|
||||
sweep(&mut s, "C", "Frustum", json!({"scale": 0.5})).unwrap();
|
||||
let m = measure(&s, "Frustum");
|
||||
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
|
||||
assert!((m.area - PI * (big * big + small * small) - lateral).abs() < 2e-3 * lateral, "area {}", m.area);
|
||||
run(&mut s, "surface.sweep", json!({"sketch": "C", "curves": ["c"], "path_sketch": "P", "path": ["up"], "scale": "1/2", "body_name": "Cone"}));
|
||||
let a = measure(&s, "Cone").area;
|
||||
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
|
||||
// The options are saved with the feature and evaluate the same when reopened.
|
||||
let doc = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
let reopened = Session::new(doc);
|
||||
let again = measure(&reopened, "Frustum");
|
||||
assert!((again.volume - m.volume).abs() < 1e-9 * volume);
|
||||
}
|
||||
|
||||
/// A twisted square keeps its faces' names (each wall after its profile curve), and the twist is
|
||||
/// a parameter: editing it changes the solid, a plain sweep has the same names.
|
||||
#[test]
|
||||
fn twisted_sweep_faces_follow_the_profile_curves() {
|
||||
let mut s = setup();
|
||||
sweep(&mut s, "Q", "Plain", json!({})).unwrap();
|
||||
sweep(&mut s, "Q", "Twisted", json!({"twist": "90 deg", "name": "Twist"})).unwrap();
|
||||
let (plain, twisted) = (face_names(&mut s, "Plain"), face_names(&mut s, "Twisted"));
|
||||
assert_eq!(plain.len(), 6, "{plain:?}");
|
||||
assert_eq!(plain, twisted);
|
||||
for curve in ["a", "b", "c", "d"] {
|
||||
assert!(twisted.iter().any(|n| n.ends_with(&format!(":{curve}"))), "{curve} in {twisted:?}");
|
||||
}
|
||||
// The end cap turns with the twist: at 90 deg a square's corners are back where they were,
|
||||
// at 45 deg its top reaches out to the corner radius along the axes.
|
||||
let reach = |s: &Session| {
|
||||
let mesh = s.world_state().body("Twisted").unwrap().body.tessellate(0.01).unwrap();
|
||||
mesh.positions.iter().filter(|p| (p.z - 40.0).abs() < 1e-6).map(|p| p.x).fold(f64::MIN, f64::max)
|
||||
};
|
||||
assert!((reach(&s) - 5.0).abs() < 1e-3, "{}", reach(&s));
|
||||
run(&mut s, "timeline.edit", json!({"feature": "Twist", "set": {"twist": "45 deg"}}));
|
||||
assert!((reach(&s) - 50f64.sqrt()).abs() < 1e-2, "{}", reach(&s));
|
||||
let v = measure(&s, "Twisted").volume;
|
||||
assert!((v - 4000.0).abs() < 0.01 * 4000.0, "{v}");
|
||||
}
|
||||
|
||||
/// Parallel along a straight path leaning out of the profile's plane gives a sheared prism (the
|
||||
/// volume of the upright one); so does a direction, along a straight path.
|
||||
#[test]
|
||||
fn orientations() {
|
||||
let mut s = setup();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Lean"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, -40]], "ids": ["lean"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let lean = |s: &mut Session, body: &str, o: Value| {
|
||||
s.execute(
|
||||
"solid.sweep",
|
||||
&json!({"sketch": "Q", "path_sketch": "Lean", "path": ["lean"], "operation": "new", "body_name": body, "orientation": o}),
|
||||
)
|
||||
};
|
||||
lean(&mut s, "Sheared", json!("parallel")).unwrap();
|
||||
let m = measure(&s, "Sheared");
|
||||
assert!((m.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", m.volume);
|
||||
// Its ends stay level: flat at z = 0 and z = 40.
|
||||
assert!(m.bbox.min.z.abs() < 1e-6 && (m.bbox.max.z - 40.0).abs() < 1e-6, "{:?}", m.bbox);
|
||||
// Along a straight path every frame is the first: a direction changes nothing, the profile
|
||||
// keeps its relation to the path as in a plain sweep.
|
||||
lean(&mut s, "Directed", json!([0, 1, 0])).unwrap();
|
||||
let d = measure(&s, "Directed");
|
||||
assert!((d.volume - 4000.0).abs() < 2e-3 * 4000.0, "{}", d.volume);
|
||||
assert!(d.bbox.max.y <= 5.0 + 1e-6 && d.bbox.min.y >= -5.0 - 1e-6);
|
||||
}
|
||||
|
||||
/// Hostile options are refused with an error and leave the design as it was.
|
||||
#[test]
|
||||
fn hostile_sweep_options_are_refused() {
|
||||
let mut s = setup();
|
||||
let before = s.doc.features.len();
|
||||
for bad in [
|
||||
json!({"twist": "NaN"}),
|
||||
json!({"twist": 1e300}),
|
||||
json!({"twist": "1e9 deg"}),
|
||||
json!({"twist": "1441 deg"}),
|
||||
json!({"twist": "nope"}),
|
||||
json!({"twist": [1, 2]}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": -1}),
|
||||
json!({"scale": "0"}),
|
||||
json!({"scale": "1e9"}),
|
||||
json!({"scale": "1/0"}),
|
||||
json!({"orientation": "sideways"}),
|
||||
json!({"orientation": [0, 0, 0]}),
|
||||
json!({"orientation": [1e308, 0, 0]}),
|
||||
json!({"orientation": {}}),
|
||||
json!({"orientation": 7}),
|
||||
// Along the path itself: no turn about the path is defined.
|
||||
json!({"orientation": [0, 0, 1]}),
|
||||
] {
|
||||
assert!(sweep(&mut s, "Q", "Bad", bad.clone()).is_err(), "{bad}");
|
||||
let mut p = json!({"sketch": "Q", "curves": ["a", "b"], "path_sketch": "P", "path": ["up"]});
|
||||
p.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone());
|
||||
assert!(s.execute("surface.sweep", &p).is_err(), "surface {bad}");
|
||||
assert_eq!(s.doc.features.len(), before, "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ use K::*;
|
||||
/// The parameters each command takes in world coordinates.
|
||||
fn table(id: &str) -> &'static [(&'static str, K)] {
|
||||
match id {
|
||||
"solid.extrude" => &[("face", Pick), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
|
||||
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
|
||||
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
|
||||
@@ -390,7 +390,7 @@ pub fn to_local(s: &Session, id: &str, params: &Value) -> Option<Value> {
|
||||
command_frames.sketch = frame;
|
||||
}
|
||||
let mut m = Mapper { s, f: command_frames, first: None, changed: false, here: false };
|
||||
let body = obj.get("body").and_then(Value::as_str).map(str::to_string);
|
||||
let body = obj.get("body").or_else(|| obj.get("face_body")).and_then(Value::as_str).map(str::to_string);
|
||||
let mut out: Map<String, Value> = obj.clone();
|
||||
// Picks first: their frame turns the directions.
|
||||
for pass in [true, false] {
|
||||
|
||||
@@ -219,6 +219,7 @@ pub struct Session {
|
||||
pub pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
|
||||
world_cache: std::sync::Mutex<Option<WorldCache>>,
|
||||
occurrence_visibility_cache: std::sync::Mutex<Option<occurrences::VisibilityCache>>,
|
||||
removed_later_cache: std::sync::Mutex<Option<occurrences::RemovedLaterCache>>,
|
||||
/// Features copied with `timeline.copy` (pasted by `timeline.paste`).
|
||||
pub clipboard: Vec<solidcraft_doc::Feature>,
|
||||
/// Section Analysis: a view cut by a plane (origin, unit normal; the normal side is hidden).
|
||||
@@ -323,6 +324,7 @@ impl Session {
|
||||
pending_moves: Default::default(),
|
||||
world_cache: Default::default(),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
removed_later_cache: Default::default(),
|
||||
clipboard: Vec::new(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
@@ -788,6 +790,7 @@ impl Session {
|
||||
pending_moves: self.pending_moves.clone(),
|
||||
world_cache: std::sync::Mutex::new(self.world_cache.lock().ok().and_then(|cache| cache.clone())),
|
||||
occurrence_visibility_cache: Default::default(),
|
||||
removed_later_cache: Default::default(),
|
||||
clipboard: self.clipboard.clone(),
|
||||
section: None,
|
||||
analysis: None,
|
||||
@@ -946,6 +949,8 @@ mod profile_curves_tests;
|
||||
#[cfg(test)]
|
||||
mod profile_refs_tests;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_removed_tests;
|
||||
#[cfg(test)]
|
||||
mod split_face_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Exact placement identity and session view/authoring state.
|
||||
use crate::{EngineError, Result, Sel, Session};
|
||||
use solidcraft_doc::{IDENTITY, Mat, mat_mul};
|
||||
use solidcraft_doc::{FeatureKind, IDENTITY, Mat, PlaneRef, mat_mul};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OccurrenceBody {
|
||||
@@ -31,6 +31,14 @@ pub(crate) struct VisibilityCache {
|
||||
bodies: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// [`Session::body_removed_later`] for one revision, sketch and history marker.
|
||||
pub(crate) struct RemovedLaterCache {
|
||||
revision: u64,
|
||||
sketch: u64,
|
||||
marker: usize,
|
||||
bodies: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Document with uncaptured occurrence positions applied, without changing saved geometry.
|
||||
pub fn placed_document(&self) -> std::borrow::Cow<'_, solidcraft_doc::Document> {
|
||||
@@ -183,7 +191,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub fn body_visible(&self, name: &str) -> bool {
|
||||
if self.visibility.hidden_bodies.iter().any(|n| n == name) {
|
||||
if self.visibility.hidden_bodies.iter().any(|n| n == name) || self.body_removed_later(name) {
|
||||
return false;
|
||||
}
|
||||
if self.visibility.hidden_occurrences.is_empty() {
|
||||
@@ -205,6 +213,66 @@ impl Session {
|
||||
self.doc.body_frames(&self.model.state()).iter().find(|(n, _, _, _)| n == name).is_none_or(|(_, _, path, _)| self.occurrence_visible(path))
|
||||
}
|
||||
|
||||
/// Is this body deleted by a feature the history marker has rolled off, while a sketch is
|
||||
/// being edited? Edit Sketch rolls the timeline back to the sketch, so a body made before the
|
||||
/// sketch and removed after it is part of the shown model again; it stays out of the view,
|
||||
/// as it does once the timeline runs to the end. Nothing else changes: the model, its state
|
||||
/// and every command still see the history as it was rolled to. A body the sketch is
|
||||
/// built on (the face it sits on, an edge it projects) stays.
|
||||
pub fn body_removed_later(&self, name: &str) -> bool {
|
||||
let (Some(sketch), Some(marker)) = (self.active_sketch, self.doc.marker) else { return false };
|
||||
if marker >= self.doc.features.len() {
|
||||
return false;
|
||||
}
|
||||
let Ok(mut cache) = self.removed_later_cache.lock() else { return false };
|
||||
if cache.as_ref().is_none_or(|c| c.revision != self.revision || c.sketch != sketch || c.marker != marker) {
|
||||
*cache = Some(RemovedLaterCache { revision: self.revision, sketch, marker, bodies: self.removed_later_bodies(sketch, marker) });
|
||||
}
|
||||
cache.as_ref().is_some_and(|c| c.bodies.contains(name))
|
||||
}
|
||||
|
||||
/// The names (as shown in the world) of the bodies that features from `marker` on remove,
|
||||
/// leaving out the bodies `sketch` is built on.
|
||||
fn removed_later_bodies(&self, sketch: u64, marker: usize) -> std::collections::BTreeSet<String> {
|
||||
let none = std::collections::BTreeSet::new();
|
||||
let Some(FeatureKind::Sketch { plane, sketch: made }) = self.doc.feature(sketch).map(|f| &f.kind) else { return none };
|
||||
let mut kept: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
|
||||
let mut plane = plane;
|
||||
loop {
|
||||
match plane {
|
||||
PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } => plane = base,
|
||||
// A face attachment that names no exact source: which body it sits on is unknown,
|
||||
// so none is taken from the view.
|
||||
PlaneRef::Face { anchor: None, .. } => return none,
|
||||
PlaneRef::Face { anchor: Some(anchor), .. } => {
|
||||
kept.insert(anchor.target.body.as_str());
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
for link in &made.links {
|
||||
kept.extend(link.source.bodies());
|
||||
}
|
||||
let shown = self.model.state();
|
||||
let mut removed = std::collections::BTreeSet::new();
|
||||
for feature in self.doc.features.iter().skip(marker).filter(|f| !f.suppressed) {
|
||||
if let FeatureKind::Remove { bodies } = &feature.kind {
|
||||
removed.extend(bodies.iter().filter(|b| !kept.contains(b.as_str()) && shown.body(b).is_some()).cloned());
|
||||
}
|
||||
}
|
||||
if removed.is_empty() || self.doc.occurrences.is_empty() {
|
||||
return removed;
|
||||
}
|
||||
// A component's bodies are shown once per placement, each under its own name.
|
||||
self.doc
|
||||
.body_frames_with_source(&shown)
|
||||
.into_iter()
|
||||
.filter(|(_, source, _, _, _)| removed.contains(source))
|
||||
.map(|(world, ..)| world)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn prune_occurrence_state(&mut self) {
|
||||
self.pending_moves.retain(|id, _| self.doc.occurrences.iter().any(|o| o.id == *id));
|
||||
if self.active_component != 0 && !self.doc.components.iter().any(|c| c.id == self.active_component) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Edit Sketch rolls the timeline back to the sketch. A body made before the sketch and deleted
|
||||
//! after it is then part of the model again, but it must not be shown while the sketch is edited
|
||||
//! (#1167): the history is rolled back, the view of it is not. A body the sketch is built on stays.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_sketch::LinkSource;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{id} {p}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 10 x 10 x 5 block `name`, drawn on the XY plane at `x`.
|
||||
fn block(s: &mut Session, name: &str, x: f64) {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": format!("{name}Sketch")}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [x, 0], "p1": [x + 10.0, 10]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.extrude", json!({"sketch": format!("{name}Sketch"), "distance": 5, "body_name": name}));
|
||||
}
|
||||
|
||||
/// Whether `inspect` lists the body, and what it says of it.
|
||||
fn inspected(s: &mut Session, body: &str) -> Value {
|
||||
let r = run(s, "document.inspect", json!({"measure": false, "sketches": false}));
|
||||
r["bodies"].as_array().and_then(|bodies| bodies.iter().find(|b| b["name"] == body)).cloned().unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
/// `Kept` and `Gone` (removed after the sketch `Edited`), then Edit Sketch on `Edited`.
|
||||
fn edit_with_a_later_removal(s: &mut Session) {
|
||||
block(s, "Kept", 0.0);
|
||||
block(s, "Gone", 20.0);
|
||||
run(s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Edited"}));
|
||||
run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
run(s, "solid.remove", json!({"bodies": ["Gone"]}));
|
||||
run(s, "timeline.roll_to", json!({"feature": "Edited"}));
|
||||
run(s, "sketch.edit", json!({"sketch": "Edited"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_deleted_later_is_not_shown_while_a_sketch_is_edited() {
|
||||
let mut s = Session::default();
|
||||
edit_with_a_later_removal(&mut s);
|
||||
// The rolled-back history still has the body: commands and measurements see it.
|
||||
assert!(s.model.state().body("Gone").is_some());
|
||||
assert!(!s.body_visible("Gone"));
|
||||
assert!(s.body_removed_later("Gone"));
|
||||
let gone = inspected(&mut s, "Gone");
|
||||
assert_eq!(gone["visible"], json!(false), "{gone}");
|
||||
assert_eq!(gone["removed_later"], json!(true), "{gone}");
|
||||
// What is not deleted is shown as before.
|
||||
assert!(s.body_visible("Kept"));
|
||||
let kept = inspected(&mut s, "Kept");
|
||||
assert_eq!(kept["visible"], json!(true), "{kept}");
|
||||
assert!(kept.get("removed_later").is_none(), "{kept}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_body_is_shown_again_when_the_sketch_is_finished() {
|
||||
let mut s = Session::default();
|
||||
edit_with_a_later_removal(&mut s);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
// Rolled back and not editing: the history as it was, which has the body.
|
||||
assert!(s.body_visible("Gone"));
|
||||
assert!(inspected(&mut s, "Gone").get("removed_later").is_none());
|
||||
// Rolled forward, it is gone.
|
||||
run(&mut s, "timeline.roll_to", json!({}));
|
||||
assert!(s.model.state().body("Gone").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_back_by_hand_shows_the_history() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s, "Gone", 0.0);
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": 2}));
|
||||
assert!(s.body_visible("Gone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_suppressed_removal_does_not_hide_the_body() {
|
||||
let mut s = Session::default();
|
||||
edit_with_a_later_removal(&mut s);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let removal = s.doc.features.iter().find(|f| f.kind.type_name() == "RemoveFeature").map(|f| f.id).unwrap_or(0);
|
||||
run(&mut s, "timeline.suppress", json!({"feature": removal, "suppressed": true}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
|
||||
assert!(s.body_visible("Gone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_the_sketch_projects_stays_shown() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s, "Gone", 0.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "5 mm", "name": "Edited"}));
|
||||
run(&mut s, "sketch.project", json!({"refs": [{"edge": [10, 5, 5], "body": "Gone"}]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
|
||||
assert!(s.body_visible("Gone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_the_sketch_sits_on_stays_shown() {
|
||||
let mut s = Session::default();
|
||||
block(&mut s, "Gone", 0.0);
|
||||
let top = {
|
||||
let world = s.world_state();
|
||||
let face = world
|
||||
.body("Gone")
|
||||
.and_then(|body| body.body.faces(0.01).ok())
|
||||
.and_then(|faces| faces.into_iter().find(|f| f.plane_normal.is_some_and(|n| n.dot(Vec3::Z) > 0.999)))
|
||||
.unwrap();
|
||||
json!({"face": [face.centroid.x, face.centroid.y, face.centroid.z], "body": "Gone", "index": face.index})
|
||||
};
|
||||
run(&mut s, "sketch.create", json!({"plane": top, "name": "Edited"}));
|
||||
let anchored = match s.doc.find_feature("Edited").map(|f| &f.kind) {
|
||||
Some(solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor, .. }, .. }) => anchor.is_some(),
|
||||
other => panic!("not attached to a face: {other:?}"),
|
||||
};
|
||||
assert!(anchored, "the face attachment names its body");
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
|
||||
assert!(s.body_visible("Gone"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_placement_of_a_component_body_is_left_out() {
|
||||
let mut s = Session::default();
|
||||
let component = run(&mut s, "component.create", json!({"name": "Block"}))["component"].as_u64().unwrap_or(0);
|
||||
block(&mut s, "Gone", 0.0);
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "30 mm", "name": "Edited"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [5, 5]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.remove", json!({"bodies": ["Gone"]}));
|
||||
run(&mut s, "occurrence.copy", json!({"component": component, "translate": [50, 0, 0]}));
|
||||
run(&mut s, "timeline.roll_to", json!({"feature": "Edited"}));
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Edited"}));
|
||||
let world = s.world_state();
|
||||
assert_eq!(world.bodies.len(), 2, "one body, placed twice");
|
||||
for body in &world.bodies {
|
||||
assert!(!s.body_visible(&body.name), "{} is deleted later", body.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_reads_the_bodies_of_its_sources() {
|
||||
let at = Vec3::ZERO;
|
||||
let edge = LinkSource::Edge { body: "A".into(), at };
|
||||
let face = LinkSource::Face { body: "B".into(), at };
|
||||
assert_eq!(edge.bodies(), ["A"]);
|
||||
assert_eq!(LinkSource::Intersection { a: Box::new(edge), b: Box::new(face) }.bodies(), ["A", "B"]);
|
||||
assert!(LinkSource::Origin.bodies().is_empty());
|
||||
}
|
||||
@@ -769,6 +769,9 @@ fn hostile_params_never_panic() {
|
||||
"filter",
|
||||
"kind",
|
||||
"prefix",
|
||||
"orientation",
|
||||
"twist",
|
||||
"scale",
|
||||
];
|
||||
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
|
||||
let ids: Vec<&str> = command_specs()
|
||||
|
||||
+196
-9
@@ -7,18 +7,50 @@
|
||||
//! what the entities refer to and what release 2000 needs to read them: the version, the
|
||||
//! handle seed, the units and the extents; the linetype, layer and block-record tables; the
|
||||
//! model and paper space blocks; handles, owners and the subclass markers of each entity; the
|
||||
//! root dictionary. Group codes follow Autodesk's public DXF reference.
|
||||
//! root dictionary. Layers can be dashed and a drawing can carry TEXT (for annotations). Group
|
||||
//! codes follow Autodesk's public DXF reference.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use solidcraft_geom::{Loop2, Seg2, Vec2};
|
||||
|
||||
/// A layer: its name and AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black).
|
||||
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfLinetype {
|
||||
#[default]
|
||||
Continuous,
|
||||
/// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm.
|
||||
Dashed,
|
||||
}
|
||||
|
||||
impl DxfLinetype {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
DxfLinetype::Continuous => "Continuous",
|
||||
DxfLinetype::Dashed => "DASHED",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer: its name, AutoCAD colour index (1 red, 3 green, 5 blue, 7 white or black) and linetype.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DxfLayer {
|
||||
pub name: String,
|
||||
pub color: i16,
|
||||
pub linetype: DxfLinetype,
|
||||
}
|
||||
|
||||
impl DxfLayer {
|
||||
/// A continuous layer.
|
||||
pub fn new(name: impl Into<String>, color: i16) -> DxfLayer {
|
||||
DxfLayer { name: name.into(), color, linetype: DxfLinetype::Continuous }
|
||||
}
|
||||
|
||||
/// The same layer with a dashed line.
|
||||
pub fn dashed(self) -> DxfLayer {
|
||||
DxfLayer { linetype: DxfLinetype::Dashed, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
/// The geometry of one entity (millimetres).
|
||||
@@ -54,6 +86,15 @@ pub enum DxfShape {
|
||||
ctrl: Vec<Vec2>,
|
||||
weights: Option<Vec<f64>>,
|
||||
},
|
||||
/// One line of text, centred on `at` (its baseline) and turned `angle_deg` counter-clockwise.
|
||||
/// `%%d`, `%%p` and `%%c` are AutoCAD's degree, plus-minus and diameter signs; other letters
|
||||
/// outside ASCII are written as `\U+XXXX`.
|
||||
Text {
|
||||
at: Vec2,
|
||||
height: f64,
|
||||
angle_deg: f64,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// An entity on a layer.
|
||||
@@ -180,6 +221,13 @@ impl DxfShape {
|
||||
}
|
||||
// A spline lies within the hull of its control points.
|
||||
DxfShape::Spline { ctrl, .. } => out.extend(ctrl.iter().copied()),
|
||||
// Where a viewer puts the letters depends on its font: 0.8 heights for each.
|
||||
DxfShape::Text { at, height, angle_deg, text } => {
|
||||
let (along, up) = (Vec2::from_angle(angle_deg.to_radians()), Vec2::from_angle(angle_deg.to_radians()).perp());
|
||||
let half = 0.4 * height * text.chars().count() as f64;
|
||||
out.extend([-half, half].map(|s| *at + along * s));
|
||||
out.extend([-half, half].map(|s| *at + along * s + up * *height));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +304,19 @@ impl Out {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text on one line, as a TEXT entity holds it: at most 250 characters, control characters as
|
||||
/// spaces, letters outside ASCII as `\U+XXXX` (`?` beyond the first 65536).
|
||||
fn text_value(s: &str) -> String {
|
||||
s.chars()
|
||||
.take(250)
|
||||
.map(|c| match c {
|
||||
c if c.is_ascii_control() => " ".to_string(),
|
||||
c if c.is_ascii() => c.to_string(),
|
||||
c => u16::try_from(u32::from(c)).map_or_else(|_| "?".to_string(), |u| format!("\\U+{u:04X}")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A DXF drawing (AutoCAD 2000, millimetres) of `entities`. `layers` are the layers they are on
|
||||
/// (layer `0` is always there).
|
||||
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String {
|
||||
@@ -271,7 +332,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
|
||||
})
|
||||
.unwrap_or((Vec2::ZERO, Vec2::ZERO));
|
||||
|
||||
let mut layer_list = vec![DxfLayer { name: "0".into(), color: 7 }];
|
||||
let mut layer_list = vec![DxfLayer::new("0", 7)];
|
||||
for l in layers {
|
||||
if !layer_list.iter().any(|x| x.name == l.name) {
|
||||
layer_list.push(l.clone());
|
||||
@@ -285,15 +346,27 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
|
||||
|
||||
o.code(0, "SECTION");
|
||||
o.code(2, "TABLES");
|
||||
let t = o.table("LTYPE", 3);
|
||||
for (name, description) in [("ByBlock", ""), ("ByLayer", ""), ("Continuous", "Solid line")] {
|
||||
// The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths
|
||||
// (a gap is negative) after the pattern's total.
|
||||
let linetypes: [(&str, &str, &[f64]); 4] = [
|
||||
("ByBlock", "", &[]),
|
||||
("ByLayer", "", &[]),
|
||||
("Continuous", "Solid line", &[]),
|
||||
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
|
||||
];
|
||||
let t = o.table("LTYPE", linetypes.len());
|
||||
for (name, description, pattern) in linetypes {
|
||||
o.record("LTYPE", "AcDbLinetypeTableRecord", &t);
|
||||
o.code(2, name);
|
||||
o.code(70, 0);
|
||||
o.code(3, description);
|
||||
o.code(72, 65);
|
||||
o.code(73, 0);
|
||||
o.num(40, 0.0);
|
||||
o.code(73, pattern.len());
|
||||
o.num(40, pattern.iter().map(|d| d.abs()).sum());
|
||||
for d in pattern {
|
||||
o.num(49, *d);
|
||||
o.code(74, 0);
|
||||
}
|
||||
}
|
||||
o.code(0, "ENDTAB");
|
||||
let t = o.table("LAYER", layer_list.len());
|
||||
@@ -302,9 +375,22 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
|
||||
o.code(2, &l.name);
|
||||
o.code(70, 0);
|
||||
o.code(62, l.color);
|
||||
o.code(6, "Continuous");
|
||||
o.code(6, l.linetype.name());
|
||||
}
|
||||
o.code(0, "ENDTAB");
|
||||
// The one text style, which a TEXT entity uses when it names none.
|
||||
let t = o.table("STYLE", 1);
|
||||
o.record("STYLE", "AcDbTextStyleTableRecord", &t);
|
||||
o.code(2, "Standard");
|
||||
o.code(70, 0);
|
||||
o.num(40, 0.0);
|
||||
o.num(41, 1.0);
|
||||
o.num(50, 0.0);
|
||||
o.code(71, 0);
|
||||
o.num(42, 2.5);
|
||||
o.code(3, "txt");
|
||||
o.code(4, "");
|
||||
o.code(0, "ENDTAB");
|
||||
let t = o.table("BLOCK_RECORD", 2);
|
||||
for (h, name) in [(&model, "*Model_Space"), (&paper, "*Paper_Space")] {
|
||||
o.code(0, "BLOCK_RECORD");
|
||||
@@ -332,6 +418,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
|
||||
DxfShape::Arc { .. } => "ARC",
|
||||
DxfShape::Ellipse { .. } => "ELLIPSE",
|
||||
DxfShape::Spline { .. } => "SPLINE",
|
||||
DxfShape::Text { .. } => "TEXT",
|
||||
};
|
||||
let h = o.handle();
|
||||
o.code(0, kind);
|
||||
@@ -391,6 +478,19 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
|
||||
o.point(10, *p);
|
||||
}
|
||||
}
|
||||
DxfShape::Text { at, height, angle_deg, text } => {
|
||||
o.code(100, "AcDbText");
|
||||
o.point(10, *at);
|
||||
o.num(40, *height);
|
||||
o.code(1, text_value(text));
|
||||
o.num(50, *angle_deg);
|
||||
// Centred (72 = 1): a reader that places the text by the second alignment point
|
||||
// and one that takes the first both put its middle on `at`.
|
||||
o.code(72, 1);
|
||||
o.point(11, *at);
|
||||
o.code(100, "AcDbText");
|
||||
o.code(73, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
o.code(0, "ENDSEC");
|
||||
@@ -561,7 +661,7 @@ mod tests {
|
||||
DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Circle { c: Vec2::new(5.0, 5.0), r: 4.0 } },
|
||||
DxfEntity { layer: "CUT".into(), shape: DxfShape::Line(Vec2::ZERO, Vec2::X) },
|
||||
];
|
||||
let dxf = write_dxf_drawing(&[DxfLayer { name: "OUTLINE".into(), color: 7 }, DxfLayer { name: "CUT".into(), color: 1 }], &ents);
|
||||
let dxf = write_dxf_drawing(&[DxfLayer::new("OUTLINE", 7), DxfLayer::new("CUT", 1)], &ents);
|
||||
let p = pairs(&dxf);
|
||||
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
|
||||
assert_eq!(after("$ACADVER")[0], (1, "AC1015".to_string()));
|
||||
@@ -667,4 +767,91 @@ mod tests {
|
||||
let last = |code: i32| e.1.iter().rposition(|(c, _)| *c == code).unwrap();
|
||||
assert!(last(40) < at(41) && last(41) < at(10), "{:?}", e.1);
|
||||
}
|
||||
|
||||
/// The records of a symbol table, each as its group codes after the type.
|
||||
fn table_records(p: &[(i32, String)], table: &str) -> Vec<Vec<(i32, String)>> {
|
||||
let start = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, table)).unwrap();
|
||||
let mut out: Vec<Vec<(i32, String)>> = Vec::new();
|
||||
for (c, v) in &p[start + 1..] {
|
||||
match (*c, v.as_str()) {
|
||||
(0, "ENDTAB") => break,
|
||||
(0, _) => out.push(Vec::new()),
|
||||
_ => {
|
||||
if let Some(record) = out.last_mut() {
|
||||
record.push((*c, v.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn field(record: &[(i32, String)], code: i32) -> &str {
|
||||
record.iter().find(|(c, _)| *c == code).map_or("", |(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dashed_layer_names_its_linetype_and_the_table_defines_it() {
|
||||
let dxf = write_dxf_drawing(&[DxfLayer::new("BEND", 1).dashed(), DxfLayer::new("CUT", 4)], &[]);
|
||||
let p = pairs(&dxf);
|
||||
// The linetypes of every drawing, then DASHED: a dash of 12.7 mm and a gap of 6.35 mm, 19.05 mm in all.
|
||||
let types = table_records(&p, "LTYPE");
|
||||
assert_eq!(types.iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["ByBlock", "ByLayer", "Continuous", "DASHED"]);
|
||||
let dashed = &types[3];
|
||||
let numbers = |code: i32| dashed.iter().filter(|(c, _)| *c == code).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>();
|
||||
assert!(near(&numbers(49), &[12.7, -6.35]) && near(&numbers(40), &[19.05]) && near(&numbers(73), &[2.0]) && near(&numbers(72), &[65.0]));
|
||||
// The table says how many records it holds.
|
||||
let at = p.iter().position(|(c, v)| (*c, v.as_str()) == (2, "LTYPE")).unwrap();
|
||||
assert_eq!(p[at..].iter().find(|(c, _)| *c == 70).map(|(_, v)| v.as_str()), Some("4"));
|
||||
// Each layer names its linetype.
|
||||
let layers = table_records(&p, "LAYER");
|
||||
assert_eq!(
|
||||
layers.iter().map(|r| (field(r, 2), field(r, 62), field(r, 6))).collect::<Vec<_>>(),
|
||||
[("0", "7", "Continuous"), ("BEND", "1", "DASHED"), ("CUT", "4", "Continuous")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_centred_on_its_point_and_the_reader_gets_it_back() {
|
||||
let at = Vec2::new(30.0, 12.0);
|
||||
let note = DxfShape::Text { at, height: 3.0, angle_deg: 90.0, text: "AB%%d\nC\u{b0}".into() };
|
||||
let dxf = write_dxf_drawing(&[DxfLayer::new("NOTES", 8)], &[DxfEntity { layer: "NOTES".into(), shape: note }]);
|
||||
let file = entities(&dxf);
|
||||
let [text] = file.as_slice() else { panic!("{file:?}") };
|
||||
assert_eq!(text.0, "TEXT");
|
||||
// One line (the newline is a space), the sign outside ASCII a code of its own, `%%d` AutoCAD's degree sign.
|
||||
assert_eq!(text.1.iter().find(|(c, _)| *c == 1).map(|(_, v)| v.as_str()), Some("AB%%d C\\U+00B0"));
|
||||
// Centred (72 = 1) on the second alignment point, which is the first as well; 3 mm high, turned 90°.
|
||||
assert!(
|
||||
near(&values(text, 11), &[30.0])
|
||||
&& near(&values(text, 21), &[12.0])
|
||||
&& near(&values(text, 10), &[30.0])
|
||||
&& near(&values(text, 20), &[12.0])
|
||||
);
|
||||
assert!(
|
||||
near(&values(text, 72), &[1.0]) && near(&values(text, 73), &[0.0]) && near(&values(text, 40), &[3.0]) && near(&values(text, 50), &[90.0])
|
||||
);
|
||||
// Its style is in the STYLE table, and the layer it is on in the LAYER table.
|
||||
let p = pairs(&dxf);
|
||||
assert_eq!(table_records(&p, "STYLE").iter().map(|r| field(r, 2)).collect::<Vec<_>>(), ["Standard"]);
|
||||
assert!(text.1.contains(&(8, "NOTES".to_string())));
|
||||
// The reader finds the text where it was put, as high and as turned.
|
||||
let back = read_dxf(&dxf).unwrap();
|
||||
assert!(
|
||||
matches!(&back[..], [Geom2::Text { at: found, height, angle, .. }] if found.dist(at) < 1e-9 && (height - 3.0).abs() < 1e-9 && (angle - FRAC_PI_2).abs() < 1e-9),
|
||||
"{back:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_extents_hold_the_text() {
|
||||
// Five letters of 3 mm, turned to read upwards: 0.8 × 3 × 5 = 12 mm long around y = 12, and
|
||||
// 3 mm to the left of the baseline at x = 30.
|
||||
let shape = DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text: "ABCDE".into() };
|
||||
let dxf = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]);
|
||||
let p = pairs(&dxf);
|
||||
let after = |var: &str| &p[p.iter().position(|(c, v)| (*c, v.as_str()) == (9, var)).unwrap() + 1..];
|
||||
assert_eq!(&after("$EXTMIN")[..2], &[(10, "27.0".to_string()), (20, "6.0".to_string())]);
|
||||
assert_eq!(&after("$EXTMAX")[..2], &[(10, "30.0".to_string()), (20, "18.0".to_string())]);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ pub mod urdf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
|
||||
@@ -1786,6 +1786,38 @@ pub(crate) fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||||
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||||
}
|
||||
|
||||
/// The path as a polyline: its first point, then each segment's points (arcs within 0.01 mm of
|
||||
/// their chords).
|
||||
pub(crate) fn path_polyline(path: &[PathSeg]) -> Result<Vec<Vec3>> {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// [`sweep`] with sweep options. The default options are exactly [`sweep`]; any other
|
||||
/// orientation, a twist or an end scale follow the path as a polyline through stations (see
|
||||
/// [`crate::sweep_path_with`]).
|
||||
pub fn sweep_with(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
if ctl.is_default() {
|
||||
return sweep(plane, region, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
region_ok(region)?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
crate::sweep_path::sweep_path_with(plane, region, &path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||||
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||||
/// is swept as a polyline, mitred at the corners.
|
||||
@@ -1800,15 +1832,7 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||||
if corner {
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||||
return crate::sweep_path::sweep_path(plane, region, &path_polyline(path)?);
|
||||
}
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let profile = region_wires(plane, &oriented(region)?)?;
|
||||
|
||||
@@ -118,7 +118,8 @@ pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fill
|
||||
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
|
||||
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
|
||||
pub use build::{
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep, torus,
|
||||
PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, revolve, sphere, sweep,
|
||||
sweep_with, torus,
|
||||
};
|
||||
pub use delete_face::delete_faces;
|
||||
pub use emboss::{cylinder_emboss_anchor, emboss_cylinder, emboss_cylinder_at, has_cylinder_emboss_wall, infer_cylinder_emboss};
|
||||
@@ -148,9 +149,9 @@ pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface};
|
||||
pub use surface_untrim::{UntrimHoles, untrim};
|
||||
pub use surfaces::{
|
||||
copy_faces, extend, extrude_sheet, loft_sheet, patch_edges, patch_region, remove_faces, reverse_faces, revolve_sheet, stitch, sweep_sheet,
|
||||
sweep_sheet_points, thicken, trim, unstitch,
|
||||
sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch,
|
||||
};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
pub use tspline::tspline_sheet;
|
||||
|
||||
@@ -356,6 +356,11 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
|
||||
// whether the pieces' boundaries come out consistent: try a few tolerances, and take the
|
||||
// first whose result is watertight and agrees with the complementary boolean (A − B and
|
||||
// A ∩ B add up to A; A ∪ B is A plus B less A ∩ B).
|
||||
// The BSP takes each list's first polygon as its splitting plane. Facets in sweep order
|
||||
// (a thread's groove, turn after turn) make every splitter cut nothing, so the tree is as
|
||||
// deep as the list is long: quadratic time and memory, and a long thread ran out of depth
|
||||
// (#1250). The same lists in a fixed shuffled order give a shallow tree.
|
||||
let (pa, pb) = (shuffled(pa), shuffled(pb));
|
||||
let (va, vb) = (polys_volume(&pa), polys_volume(&pb));
|
||||
let mut last = KernelError::Failed("faceted boolean".into());
|
||||
for rel in [1e-8, 1e-7, 3e-8, 1e-9, 3e-7] {
|
||||
@@ -383,6 +388,23 @@ pub fn faceted_boolean(a: &Body, b: &Mesh, op: BoolOp, tol: f64) -> Result<Optio
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// `polys` in a fixed pseudo-random order (the same every run, so results are reproducible).
|
||||
fn shuffled(mut polys: Vec<Poly>) -> Vec<Poly> {
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64 ^ polys.len() as u64;
|
||||
let mut next = || {
|
||||
// SplitMix64.
|
||||
state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
let mut z = state;
|
||||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
z ^ (z >> 31)
|
||||
};
|
||||
for i in (1..polys.len()).rev() {
|
||||
polys.swap(i, (next() % (i as u64 + 1)) as usize);
|
||||
}
|
||||
polys
|
||||
}
|
||||
|
||||
/// Boolean of two all-planar bodies.
|
||||
pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
a.require_brep("a boolean")?;
|
||||
|
||||
@@ -211,16 +211,8 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
}
|
||||
let ends: Vec<(Vec3, Vec3)> =
|
||||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||||
let first = match path.first() {
|
||||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||||
};
|
||||
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
|
||||
let mut pts = vec![first];
|
||||
for seg in path {
|
||||
pts.extend(seg.points_after_start(1e-2));
|
||||
}
|
||||
return sweep_sheet_points(plane, chains, &pts);
|
||||
return sweep_sheet_points(plane, chains, &crate::build::path_polyline(path)?);
|
||||
}
|
||||
let mut sides = Vec::new();
|
||||
let mut offset = 0;
|
||||
@@ -254,16 +246,39 @@ pub fn sweep_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg])
|
||||
/// B-spline wall per segment where the path has no corners, else ruled walls between the path's
|
||||
/// points, mitred at the corners (see [`sweep_sheet`]).
|
||||
pub fn sweep_sheet_points(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Body> {
|
||||
sweep_sheet_points_with(plane, chains, path, &crate::SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`sweep_sheet`] with sweep options. The default options are exactly [`sweep_sheet`]; any
|
||||
/// other orientation, a twist or an end scale follow the path as a polyline through stations
|
||||
/// (see [`sweep_sheet_points_with`]).
|
||||
pub fn sweep_sheet_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
if ctl.is_default() {
|
||||
return sweep_sheet(plane, chains, path);
|
||||
}
|
||||
ctl.check()?;
|
||||
if path.is_empty() || path.len() > 1000 {
|
||||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||||
}
|
||||
if path.iter().any(|s| s.tangents().is_none()) {
|
||||
return Err(KernelError::Invalid("a path segment has no direction".into()));
|
||||
}
|
||||
sweep_sheet_points_with(plane, chains, &crate::build::path_polyline(path)?, ctl)
|
||||
}
|
||||
|
||||
/// [`sweep_sheet_points`] with sweep options: orientation, twist and end scale.
|
||||
pub fn sweep_sheet_points_with(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &crate::SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
if !crate::build::frame_ok(plane) {
|
||||
return Err(KernelError::Invalid("sweep frame is not finite and orthonormal".into()));
|
||||
}
|
||||
if chains.is_empty() || chains.len() > MAX_CHAINS {
|
||||
return Err(KernelError::Invalid("pick the curves or profiles to sweep".into()));
|
||||
}
|
||||
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path)?)?;
|
||||
let ruled = sheet_body(crate::sweep_path::ruled_sheet(plane, chains, path, ctl)?)?;
|
||||
// Smooth walls where the path has no corners, kept when their area agrees with the ruled
|
||||
// walls' (a fit that went wrong falls back to those).
|
||||
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path)
|
||||
if let Ok(Some(sides)) = crate::sweep_path::smooth_sheet(plane, chains, path, ctl)
|
||||
&& let Ok(smooth) = sheet_body(sides)
|
||||
&& let (Ok(a), Ok(b)) = (crate::measure(&smooth), crate::measure(&ruled))
|
||||
&& (a.area - b.area).abs() <= 0.02 * b.area.abs()
|
||||
|
||||
+387
-16
@@ -1,7 +1,8 @@
|
||||
//! Sweeps along 3D point paths (3D sketch curves: projected, intersection and included curves).
|
||||
//! The profile follows the path with a rotation-minimising frame. Smooth paths get one B-spline
|
||||
//! wall per profile segment; paths with sharp corners (or profiles with holes) get ruled walls
|
||||
//! between stations, mitred at the corners.
|
||||
//! between stations, mitred at the corners. Sweep options (orientation, twist, end scale; see
|
||||
//! [`SweepControl`]) reshape the stations; their default leaves them exactly as described.
|
||||
|
||||
use mt::builder;
|
||||
use solidcraft_geom::{Plane, Region2, Seg2, Vec2, Vec3};
|
||||
@@ -23,6 +24,83 @@ const MAX_RULED_STATIONS: usize = 2000;
|
||||
const CORNER: f64 = 0.35;
|
||||
/// Samples per profile segment of the smooth walls.
|
||||
const SEG_SAMPLES: usize = 12;
|
||||
/// Stations of a smooth sweep that twists or scales, at the least.
|
||||
const MIN_CONTROLLED_STATIONS: usize = 16;
|
||||
/// Stations of a smooth sweep that twists or scales, at the most.
|
||||
const MAX_CONTROLLED_STATIONS: usize = 160;
|
||||
/// Stations of a ruled sweep that twists or scales, at the least.
|
||||
const MIN_CONTROLLED_RULED: usize = 64;
|
||||
/// The most twist one station of a smooth sweep may carry (radians).
|
||||
const SMOOTH_TWIST_STEP: f64 = std::f64::consts::PI / 18.0;
|
||||
/// The most twist one station of a ruled sweep may carry (radians).
|
||||
const RULED_TWIST_STEP: f64 = std::f64::consts::PI / 36.0;
|
||||
|
||||
/// How the profile turns as it follows a sweep path.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum SweepOrientation {
|
||||
/// Square to the path, turned along it by a rotation-minimising frame.
|
||||
#[default]
|
||||
Perpendicular,
|
||||
/// As drawn: the profile only moves along the path, never turning.
|
||||
Parallel,
|
||||
/// Square to the path, its turn about the path fixed by a direction: the direction's
|
||||
/// projection onto the profile's plane stays where it was at the start.
|
||||
Direction(Vec3),
|
||||
}
|
||||
|
||||
/// Sweep options beyond following the path. `twist` turns the profile about the path by that
|
||||
/// many radians over the path's length (right hand about the direction of travel, in proportion
|
||||
/// to the length run); `scale` is the profile's size at the end, reached linearly along the
|
||||
/// path's length, about the path's start point. The default (perpendicular, no twist, scale 1)
|
||||
/// is a plain sweep.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SweepControl {
|
||||
pub orientation: SweepOrientation,
|
||||
pub twist: f64,
|
||||
pub scale: f64,
|
||||
}
|
||||
|
||||
impl Default for SweepControl {
|
||||
fn default() -> Self {
|
||||
SweepControl { orientation: SweepOrientation::Perpendicular, twist: 0.0, scale: 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepControl {
|
||||
/// The most twist accepted: four turns (radians).
|
||||
pub const MAX_TWIST: f64 = 4.0 * std::f64::consts::TAU;
|
||||
/// The smallest end scale accepted.
|
||||
pub const MIN_SCALE: f64 = 0.01;
|
||||
/// The largest end scale accepted.
|
||||
pub const MAX_SCALE: f64 = 100.0;
|
||||
|
||||
/// A plain sweep: the options change nothing.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == SweepControl::default()
|
||||
}
|
||||
|
||||
/// Whether the profile changes along the path (it twists or scales).
|
||||
fn varies(&self) -> bool {
|
||||
self.twist != 0.0 || self.scale != 1.0
|
||||
}
|
||||
|
||||
/// Refuses options out of range: a twist beyond [`Self::MAX_TWIST`], a scale outside
|
||||
/// [`Self::MIN_SCALE`]…[`Self::MAX_SCALE`], a direction that is not finite or has no length.
|
||||
pub fn check(&self) -> Result<()> {
|
||||
if !(self.twist.is_finite() && self.twist.abs() <= Self::MAX_TWIST) {
|
||||
return Err(KernelError::Invalid("the sweep twist must be within four turns (1440 deg)".into()));
|
||||
}
|
||||
if !(self.scale.is_finite() && (Self::MIN_SCALE..=Self::MAX_SCALE).contains(&self.scale)) {
|
||||
return Err(KernelError::Invalid("the sweep end scale must be between 0.01 and 100".into()));
|
||||
}
|
||||
if let SweepOrientation::Direction(d) = self.orientation
|
||||
&& !(d.is_finite() && d.len() > 1e-9 && d.len() < 1e9)
|
||||
{
|
||||
return Err(KernelError::Invalid("the sweep orientation direction must be a finite, non-zero vector".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A linear map of world vectors (columns) plus the station it is centred at.
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -127,6 +205,124 @@ fn stations(pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>]) -> R
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Frames square to the path whose turn about it keeps `d`'s projection: none where the path
|
||||
/// runs along `d`.
|
||||
fn frames_toward(tangents: &[Vec3], d: Vec3) -> Result<Vec<[Vec3; 3]>> {
|
||||
let d = d.normalized().ok_or_else(|| KernelError::Invalid("the sweep orientation direction has no length".into()))?;
|
||||
tangents
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let across = d - *t * t.dot(d);
|
||||
// Within about 0.06 degrees of the direction the profile's turn is undefined.
|
||||
if across.len() < 1e-3 {
|
||||
return Err(KernelError::Invalid("the sweep path runs along the orientation direction".into()));
|
||||
}
|
||||
let r = across.normalized().ok_or_else(|| KernelError::Invalid("the sweep path runs along the orientation direction".into()))?;
|
||||
Ok([*t, r, t.cross(r)])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stations shaped by sweep options: each maps a world vector at the first station to its image
|
||||
/// at its own (twisted and scaled about the profile's normal through the first station, then
|
||||
/// turned by the orientation's frames), optionally stretched across a corner (mitre). Twist and
|
||||
/// scale grow with the length run along `pts`.
|
||||
fn controlled_stations(plane: &Plane, pts: &[Vec3], tangents: &[Vec3], mitre: &[Option<(Vec3, f64)>], ctl: &SweepControl) -> Result<Vec<Station>> {
|
||||
let bad = || KernelError::Invalid("sweep path".into());
|
||||
let t0 = *tangents.first().ok_or_else(bad)?;
|
||||
let f: Vec<[Vec3; 3]> = match ctl.orientation {
|
||||
SweepOrientation::Perpendicular => frames(pts, tangents).ok_or_else(|| KernelError::Invalid("the sweep path doubles back".into()))?,
|
||||
SweepOrientation::Parallel => vec![[Vec3::X, Vec3::Y, Vec3::Z]; pts.len()],
|
||||
SweepOrientation::Direction(d) => frames_toward(tangents, d)?,
|
||||
};
|
||||
let f0 = *f.first().ok_or_else(bad)?;
|
||||
// Twist and scale act about the profile's normal, pointing the way the path leaves it.
|
||||
let n = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("sweep frame".into()))?;
|
||||
let axis = if n.dot(t0) < 0.0 { n * -1.0 } else { n };
|
||||
// Kept as drawn, the profile flattens to nothing where the path runs along its plane: the
|
||||
// path has to cross it at 5 degrees or more all the way.
|
||||
if ctl.orientation == SweepOrientation::Parallel && tangents.iter().any(|t| t.dot(axis) < 5f64.to_radians().sin()) {
|
||||
return Err(KernelError::Invalid(
|
||||
"kept parallel, the profile would lie along the sweep path: the path must keep crossing the profile's plane (by 5 deg or more)".into(),
|
||||
));
|
||||
}
|
||||
let mut run = Vec::with_capacity(pts.len());
|
||||
let mut total = 0.0;
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| pts.get(j)) {
|
||||
total += prev.dist(*p);
|
||||
}
|
||||
run.push(total);
|
||||
}
|
||||
if !(total > 1e-12) {
|
||||
return Err(KernelError::Invalid("the sweep path has no length".into()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.len());
|
||||
for (i, fi) in f.iter().enumerate() {
|
||||
let u = run.get(i).copied().unwrap_or(total) / total;
|
||||
let (sin, cos) = (ctl.twist * u).sin_cos();
|
||||
let k = 1.0 + (ctl.scale - 1.0) * u;
|
||||
let shape = |v: Vec3| {
|
||||
let along = axis * v.dot(axis);
|
||||
let r = (v - along) * k;
|
||||
along + r * cos + axis.cross(r) * sin
|
||||
};
|
||||
let col = |e: Vec3| {
|
||||
let v = shape(e);
|
||||
fi[0] * f0[0].dot(v) + fi[1] * f0[1].dot(v) + fi[2] * f0[2].dot(v)
|
||||
};
|
||||
let mut m = [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)];
|
||||
if ctl.orientation != SweepOrientation::Parallel
|
||||
&& let Some(Some((b, s))) = mitre.get(i)
|
||||
{
|
||||
for c in &mut m {
|
||||
*c += *b * ((s - 1.0) * c.dot(*b));
|
||||
}
|
||||
}
|
||||
out.push(Station { at: *pts.get(i).ok_or_else(bad)?, m });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The volume a profile of `area` on `plane` sweeps through the stations: each station's
|
||||
/// section area times how squarely the path crosses it, summed along the path (trapezoids).
|
||||
fn swept_volume(plane: &Plane, area: f64, st: &[Station], tangents: &[Vec3]) -> f64 {
|
||||
let rate = |i: usize| match (st.get(i), tangents.get(i)) {
|
||||
(Some(s), Some(t)) => area * s.apply(plane.x).cross(s.apply(plane.y)).dot(*t).abs(),
|
||||
_ => 0.0,
|
||||
};
|
||||
st.windows(2).enumerate().map(|(i, w)| 0.5 * (rate(i) + rate(i + 1)) * w[0].at.dist(w[1].at)).sum()
|
||||
}
|
||||
|
||||
/// The stations of a smooth sweep: as many as the path has points (4…[`MAX_SMOOTH_STATIONS`]),
|
||||
/// more when the profile twists or scales.
|
||||
fn smooth_count(points: usize, ctl: &SweepControl) -> usize {
|
||||
let n = points.saturating_sub(1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
if !ctl.varies() {
|
||||
return n;
|
||||
}
|
||||
let twist = (ctl.twist.abs() / SMOOTH_TWIST_STEP).ceil() as usize;
|
||||
n.max(MIN_CONTROLLED_STATIONS).max(twist).min(MAX_CONTROLLED_STATIONS)
|
||||
}
|
||||
|
||||
/// The path with points added along its straight runs (its corners kept) when the profile
|
||||
/// twists or scales, so ruled walls follow the change.
|
||||
fn densify(pts: &[Vec3], ctl: &SweepControl) -> Vec<Vec3> {
|
||||
if !ctl.varies() {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let total: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let want = MIN_CONTROLLED_RULED.max((ctl.twist.abs() / RULED_TWIST_STEP).ceil() as usize).min(MAX_RULED_STATIONS / 2);
|
||||
let mut out = Vec::with_capacity(want + pts.len());
|
||||
out.extend(pts.first().copied());
|
||||
for w in pts.windows(2) {
|
||||
let pieces = if total > 0.0 { ((w[0].dist(w[1]) / total) * want as f64).ceil() as usize } else { 1 };
|
||||
let pieces = pieces.clamp(1, want.max(1));
|
||||
out.extend((1..=pieces).map(|j| w[0].lerp(w[1], j as f64 / pieces as f64)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The profile plane carried to a station.
|
||||
fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
|
||||
Plane { origin: st.at + st.apply(plane.origin - s0), x: st.apply(plane.x), y: st.apply(plane.y) }
|
||||
@@ -135,6 +331,12 @@ fn station_plane(plane: &Plane, s0: Vec3, st: &Station) -> Plane {
|
||||
/// Sweep a planar region along a 3D polyline path. The profile keeps its relation to the path
|
||||
/// (it need not touch it); the path should start near the profile.
|
||||
pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body> {
|
||||
sweep_path_with(plane, region, path, &SweepControl::default())
|
||||
}
|
||||
|
||||
/// [`sweep_path`] with sweep options: orientation, twist and end scale.
|
||||
pub fn sweep_path_with(plane: &Plane, region: &Region2, path: &[Vec3], ctl: &SweepControl) -> Result<Body> {
|
||||
ctl.check()?;
|
||||
if region.area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("profile has no area".into()));
|
||||
}
|
||||
@@ -147,16 +349,21 @@ pub fn sweep_path(plane: &Plane, region: &Region2, path: &[Vec3]) -> Result<Body
|
||||
if !corner && region.holes.is_empty() {
|
||||
// Smooth: evenly spaced stations, tangents from neighbours.
|
||||
let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum();
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let n = smooth_count(pts.len(), ctl);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let expected = region.area().abs() * len * start_cos(plane, &tangents);
|
||||
let (st, expected) = if ctl.is_default() {
|
||||
(stations(&sp, &tangents, &[])?, region.area().abs() * len * start_cos(plane, &tangents))
|
||||
} else {
|
||||
let st = controlled_stations(plane, &sp, &tangents, &[], ctl)?;
|
||||
let v = swept_volume(plane, region.area().abs(), &st, &tangents);
|
||||
(st, v)
|
||||
};
|
||||
if let Ok(b) = smooth_walls(plane, &profile.outer, &st, expected) {
|
||||
return Ok(b);
|
||||
}
|
||||
}
|
||||
ruled(plane, &profile, &pts)
|
||||
ruled(plane, &profile, &pts, ctl)
|
||||
}
|
||||
|
||||
fn central_tangents(p: &[Vec3]) -> Result<Vec<Vec3>> {
|
||||
@@ -177,8 +384,9 @@ fn start_cos(plane: &Plane, tangents: &[Vec3]) -> f64 {
|
||||
|
||||
/// Stations at the path's points (at most [`MAX_RULED_STATIONS`]), mitred at corners, with the
|
||||
/// points they stand at.
|
||||
fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(pts, MAX_RULED_STATIONS) } else { pts.to_vec() };
|
||||
fn ruled_stations(plane: &Plane, pts: &[Vec3], ctl: &SweepControl) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
let pts = densify(pts, ctl);
|
||||
let pts: Vec<Vec3> = if pts.len() > MAX_RULED_STATIONS { resample(&pts, MAX_RULED_STATIONS) } else { pts };
|
||||
let n = pts.len();
|
||||
let seg_dir = |i: usize| -> Option<Vec3> { (*pts.get(i + 1)? - *pts.get(i)?).normalized() };
|
||||
let mut tangents = Vec::with_capacity(n);
|
||||
@@ -201,15 +409,16 @@ fn ruled_stations(pts: &[Vec3]) -> Result<(Vec<Vec3>, Vec<Station>)> {
|
||||
tangents.push(t);
|
||||
mitre.push(m);
|
||||
}
|
||||
let st = stations(&pts, &tangents, &mitre)?;
|
||||
let st = if ctl.is_default() { stations(&pts, &tangents, &mitre)? } else { controlled_stations(plane, &pts, &tangents, &mitre, ctl)? };
|
||||
Ok((pts, st))
|
||||
}
|
||||
|
||||
/// Walls of chains of sketch segments on `plane` (open, or closed) carried along a 3D polyline
|
||||
/// path that starts near them: ruled between stations at the path's points, mitred at corners,
|
||||
/// with no caps. Neighbouring walls share their edges.
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Vec<Side>> {
|
||||
let (pts, st) = ruled_stations(&clean(path)?)?;
|
||||
pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Vec<Side>> {
|
||||
ctl.check()?;
|
||||
let (pts, st) = ruled_stations(plane, &clean(path)?, ctl)?;
|
||||
let edges: usize = chains.iter().map(Vec::len).sum();
|
||||
if st.len().saturating_mul(edges) > 400_000 {
|
||||
return Err(KernelError::Invalid("the sweep is too detailed (curves × path points)".into()));
|
||||
@@ -249,7 +458,8 @@ pub(crate) fn ruled_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) ->
|
||||
/// turn sharper than [`CORNER`]): one B-spline wall per segment through evenly spaced,
|
||||
/// rotation-minimising stations, neighbouring walls sharing their seam edges; no caps. `None`
|
||||
/// for a path with corners (see [`ruled_sheet`]).
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -> Result<Option<Vec<Side>>> {
|
||||
pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3], ctl: &SweepControl) -> Result<Option<Vec<Side>>> {
|
||||
ctl.check()?;
|
||||
let pts = clean(path)?;
|
||||
if pts.windows(3).any(|w| turn(w[1] - w[0], w[2] - w[1]) > CORNER) {
|
||||
return Ok(None);
|
||||
@@ -258,10 +468,10 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
|
||||
if chains.iter().map(Vec::len).sum::<usize>() > 4096 {
|
||||
return Err(fail("too many curves for a smooth sweep"));
|
||||
}
|
||||
let n = (pts.len() - 1).clamp(4, MAX_SMOOTH_STATIONS);
|
||||
let n = smooth_count(pts.len(), ctl);
|
||||
let sp = resample(&pts, n);
|
||||
let tangents = central_tangents(&sp)?;
|
||||
let st = stations(&sp, &tangents, &[])?;
|
||||
let st = if ctl.is_default() { stations(&sp, &tangents, &[])? } else { controlled_stations(plane, &sp, &tangents, &[], ctl)? };
|
||||
let s0 = st.first().map(|s| s.at).unwrap_or_default();
|
||||
let at = |i: usize, q: Vec2| st.get(i).map(|s| s.at + s.apply(plane.to_world(q) - s0)).unwrap_or_default();
|
||||
let n = st.len() - 1;
|
||||
@@ -340,8 +550,8 @@ pub(crate) fn smooth_sheet(plane: &Plane, chains: &[Vec<Seg2>], path: &[Vec3]) -
|
||||
|
||||
/// Ruled walls between stations at the path's points (mitred at corners), every loop of the
|
||||
/// (oriented) profile.
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3]) -> Result<Body> {
|
||||
let (pts, st) = ruled_stations(pts)?;
|
||||
fn ruled(plane: &Plane, profile: &NumberedRegion, pts: &[Vec3], ctl: &SweepControl) -> Result<Body> {
|
||||
let (pts, st) = ruled_stations(plane, pts, ctl)?;
|
||||
let s0 = pts.first().copied().unwrap_or_default();
|
||||
let loops: Vec<&NumberedLoop> = profile.loops().collect();
|
||||
let mut wires: Vec<Vec<mt::Wire>> = Vec::with_capacity(st.len());
|
||||
@@ -438,9 +648,11 @@ fn smooth_walls(plane: &Plane, profile: &NumberedLoop, st: &[Station], expected:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4, PI};
|
||||
|
||||
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
|
||||
|
||||
use super::sweep_path;
|
||||
use super::{SweepControl, SweepOrientation, sweep_path, sweep_path_with};
|
||||
|
||||
fn circle(r: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: Vec::new() }
|
||||
@@ -476,4 +688,163 @@ mod tests {
|
||||
assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO]).is_err());
|
||||
assert!(sweep_path(&plane, ®ion, &[Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 0.0)]).is_err());
|
||||
}
|
||||
|
||||
fn square(h: f64) -> Region2 {
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(-h, -h), Vec2::new(h, -h), Vec2::new(h, h), Vec2::new(-h, h)]), holes: Vec::new() }
|
||||
}
|
||||
|
||||
fn up(h: f64) -> [crate::PathSeg; 1] {
|
||||
[crate::PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, h) }]
|
||||
}
|
||||
|
||||
/// A circle swept along a line to an end scale is a cone frustum: its volume and lateral
|
||||
/// area are analytic, for the solid and for the surface sweep.
|
||||
#[test]
|
||||
fn scaled_sweep_is_a_frustum() {
|
||||
let (big, h, k) = (10.0, 40.0, 0.5);
|
||||
let small = big * k;
|
||||
let ctl = SweepControl { scale: k, ..SweepControl::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &circle(big), &up(h), &ctl).unwrap();
|
||||
let m = b.tessellate(0.005).unwrap().measure();
|
||||
let volume = PI * h * (big * big + big * small + small * small) / 3.0;
|
||||
let lateral = PI * (big + small) * (h * h + (big - small).powi(2)).sqrt();
|
||||
let caps = PI * (big * big + small * small);
|
||||
assert!((m.volume - volume).abs() < 2e-3 * volume, "volume {} vs {volume}", m.volume);
|
||||
assert!((m.area - caps - lateral).abs() < 2e-3 * lateral, "lateral {} vs {lateral}", m.area - caps);
|
||||
// The end cap is the scaled circle.
|
||||
let top = b.tessellate(0.005).unwrap().positions.iter().filter(|p| (p.z - h).abs() < 1e-6).map(|p| p.x.hypot(p.y)).fold(0.0, f64::max);
|
||||
assert!((top - small).abs() < 1e-3, "end radius {top}");
|
||||
// The surface sweep of the circle's curve: the frustum's lateral face alone.
|
||||
let sheet = crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(big).outer.segs), &up(h), &ctl).unwrap();
|
||||
let a = crate::measure(&sheet).unwrap().area;
|
||||
assert!((a - lateral).abs() < 2e-3 * lateral, "sheet {a} vs {lateral}");
|
||||
}
|
||||
|
||||
/// The corners of a twisted square: each seam edge's angle at `z`.
|
||||
fn corner_angles(b: &crate::Body, z: f64) -> Vec<f64> {
|
||||
let mesh = b.tessellate(0.002).unwrap();
|
||||
let mut out = Vec::new();
|
||||
for e in &mesh.edges {
|
||||
for w in e.windows(2) {
|
||||
let (a, c) = (w[0], w[1]);
|
||||
if (a.z - z) * (c.z - z) <= 0.0 && (a.z - c.z).abs() > 1e-9 {
|
||||
let p = a.lerp(c, (z - a.z) / (c.z - a.z));
|
||||
out.push(p.y.atan2(p.x));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A square swept along a line with a twist: the section halfway along is turned by half the
|
||||
/// twist, the end by all of it; the volume is the prism's.
|
||||
#[test]
|
||||
fn twisted_sweep_turns_in_proportion() {
|
||||
let (h, twist) = (40.0, 90f64.to_radians());
|
||||
let ctl = SweepControl { twist, ..SweepControl::default() };
|
||||
let b = crate::sweep_with(&Plane::XY, &square(5.0), &up(h), &ctl).unwrap();
|
||||
// Twisted walls are saddles: a fine mesh to measure them.
|
||||
let v = b.tessellate(0.0005).unwrap().measure().volume;
|
||||
assert!((v - 100.0 * h).abs() < 2e-3 * 100.0 * h, "volume {v}");
|
||||
// Corners start at 45 deg (and 135, -45, -135): each seam turns by the twist so far.
|
||||
let off = |a: f64, want: f64| {
|
||||
let d = (a - want).rem_euclid(FRAC_PI_2);
|
||||
d.min(FRAC_PI_2 - d)
|
||||
};
|
||||
for (z, turned) in [(h / 2.0, twist / 2.0), (h, twist), (h / 4.0, twist / 4.0)] {
|
||||
let angles = corner_angles(&b, z);
|
||||
assert!(angles.len() >= 4, "seams at {z}: {angles:?}");
|
||||
for a in &angles {
|
||||
assert!(off(*a, FRAC_PI_4 + turned) < 0.2f64.to_radians(), "at {z}: {} vs {}", a.to_degrees(), (FRAC_PI_4 + turned).to_degrees());
|
||||
}
|
||||
}
|
||||
// Right hand about the direction of travel: a positive twist going up turns
|
||||
// counter-clockwise seen from above (the corner at 45 deg reaches 90 deg + 45 deg).
|
||||
let end = corner_angles(&b, h);
|
||||
assert!(end.iter().any(|a| (a - (FRAC_PI_4 + twist)).abs() < 0.2f64.to_radians()), "{end:?}");
|
||||
// One wall per side and two caps, as an untwisted sweep.
|
||||
assert_eq!(b.faces(0.01).unwrap().len(), 6);
|
||||
}
|
||||
|
||||
/// Parallel keeps the profile as drawn along a curved path: the volume is its area times
|
||||
/// the path's rise across the profile's plane.
|
||||
#[test]
|
||||
fn parallel_sweep_keeps_the_profile_square_to_its_normal() {
|
||||
// An eighth of a circle: the path ends leaning 45 deg from the profile's normal.
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_4;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let rise = r * FRAC_PI_4.sin();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Parallel, ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let v = mesh.measure().volume;
|
||||
let want = 36.0 * rise;
|
||||
assert!((v - want).abs() < 5e-3 * want, "volume {v} vs {want}");
|
||||
// The end cap is still horizontal: the body tops out flat at the path's end.
|
||||
let top: Vec<&Vec3> = mesh.positions.iter().filter(|p| (p.z - rise).abs() < 1e-6).collect();
|
||||
assert!(top.len() >= 4, "flat top");
|
||||
assert!(mesh.positions.iter().all(|p| p.z <= rise + 1e-6));
|
||||
// A path that turns to run along the profile's plane (a quarter circle) is refused.
|
||||
let quarter: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
assert!(sweep_path_with(&Plane::XY, &square(3.0), &quarter, &ctl).is_err());
|
||||
}
|
||||
|
||||
/// A direction keeps the square's sides lined up with it along a curved path; in a plane
|
||||
/// square to the direction, that is the rotation-minimising frame too.
|
||||
#[test]
|
||||
fn direction_orientation_follows_the_vector() {
|
||||
let r = 30.0;
|
||||
let path: Vec<Vec3> = (0..=60)
|
||||
.map(|i| {
|
||||
let t = i as f64 / 60.0 * FRAC_PI_2;
|
||||
Vec3::new(r * (1.0 - t.cos()), 0.0, r * t.sin())
|
||||
})
|
||||
.collect();
|
||||
let ctl = SweepControl { orientation: SweepOrientation::Direction(Vec3::Y), ..SweepControl::default() };
|
||||
let b = sweep_path_with(&Plane::XY, &square(3.0), &path, &ctl).unwrap();
|
||||
let mesh = b.tessellate(0.005).unwrap();
|
||||
let len = r * FRAC_PI_2;
|
||||
assert!((mesh.measure().volume - 36.0 * len).abs() < 5e-3 * 36.0 * len);
|
||||
// The sides facing ±Y stay at y = ±3.
|
||||
assert!(mesh.positions.iter().all(|p| p.y.abs() <= 3.0 + 1e-6));
|
||||
// The path running along the direction is refused.
|
||||
let along = SweepControl { orientation: SweepOrientation::Direction(Vec3::Z), ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &square(3.0), &up(10.0), &along).is_err());
|
||||
}
|
||||
|
||||
/// Out-of-range options are refused, never a panic; the default options are a plain sweep.
|
||||
#[test]
|
||||
fn sweep_options_refuse_hostile_values() {
|
||||
let bad = [
|
||||
SweepControl { twist: f64::NAN, ..SweepControl::default() },
|
||||
SweepControl { twist: 1e9, ..SweepControl::default() },
|
||||
SweepControl { twist: SweepControl::MAX_TWIST * 1.01, ..SweepControl::default() },
|
||||
SweepControl { scale: 0.0, ..SweepControl::default() },
|
||||
SweepControl { scale: -1.0, ..SweepControl::default() },
|
||||
SweepControl { scale: f64::INFINITY, ..SweepControl::default() },
|
||||
SweepControl { scale: 1e6, ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::ZERO), ..SweepControl::default() },
|
||||
SweepControl { orientation: SweepOrientation::Direction(Vec3::new(f64::NAN, 0.0, 1.0)), ..SweepControl::default() },
|
||||
];
|
||||
for ctl in bad {
|
||||
assert!(crate::sweep_with(&Plane::XY, &circle(2.0), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(crate::sweep_sheet_with(&Plane::XY, std::slice::from_ref(&circle(2.0).outer.segs), &up(10.0), &ctl).is_err(), "{ctl:?}");
|
||||
assert!(sweep_path_with(&Plane::XY, &circle(2.0), &[Vec3::ZERO, Vec3::new(0.0, 0.0, 10.0)], &ctl).is_err(), "{ctl:?}");
|
||||
}
|
||||
// The largest twist still builds (a holed profile: ruled walls through dense stations).
|
||||
let holed = Region2 { outer: square(3.0).outer, holes: vec![Loop2::circle(Vec2::ZERO, 1.0).reversed()] };
|
||||
let max = SweepControl { twist: SweepControl::MAX_TWIST, scale: 0.5, ..SweepControl::default() };
|
||||
assert!(crate::sweep_with(&Plane::XY, &holed, &up(10.0), &max).is_ok());
|
||||
assert!(SweepControl::default().is_default());
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,21 @@ fn faceted_cut_into_a_bore() {
|
||||
let want = 4.0 * PI * 0.2 * (1.5f64.powi(2) - 1.23f64.powi(2));
|
||||
assert!((removed - want).abs() < 0.03 * want, "{removed} vs {want}");
|
||||
}
|
||||
|
||||
/// #1250: a long thread (48 turns of an M8 groove on a 60 mm shaft). The groove's facets come in
|
||||
/// sweep order; taken as BSP splitters in that order they made the tree as deep as the facet
|
||||
/// list ("planar boolean: tree too deep", after minutes and gigabytes). It cuts now, closed.
|
||||
#[test]
|
||||
fn a_long_thread_cuts_without_a_deep_tree() {
|
||||
let (r, len, p) = (4.0, 60.0, 1.25);
|
||||
let shaft = cylinder(Vec3::ZERO, Vec3::new(0.0, 0.0, 1.0), r, len).unwrap();
|
||||
let k = Vec3::new(0.0, 0.0, 1.0);
|
||||
let base = Vec3::new(0.0, 0.0, -p);
|
||||
let plane = Plane { origin: base, x: Vec3::new(1.0, 0.0, 0.0), y: k };
|
||||
let section = [Vec2::new(3.3, -0.08), Vec2::new(4.1, -0.5), Vec2::new(4.1, 0.5), Vec2::new(3.3, 0.08)];
|
||||
let groove = sweep_helix_mesh(&plane, §ion, base, k, p, len / p + 2.0, 48).unwrap();
|
||||
let cut = faceted_boolean(&shaft, &groove, BoolOp::Cut, p * 0.005).unwrap().unwrap();
|
||||
let v = cut.tessellate(0.01).unwrap().measure().volume;
|
||||
let v0 = PI * r * r * len;
|
||||
assert!(v < v0 * 0.95 && v > v0 * 0.6, "{v} of {v0}");
|
||||
}
|
||||
@@ -178,10 +178,51 @@ fn arc_pts(c: Vec2, r: f64, a0: f64, sweep: f64) -> Vec<Vec2> {
|
||||
(0..=n).map(|i| c + Vec2::from_angle(a0 + sweep * i as f64 / n as f64) * r).collect()
|
||||
}
|
||||
|
||||
/// The lines a linear dimension's two extension lines may start from (#1361), for its `p0` and
|
||||
/// `p1` sides: a dimension to a line reaches the nearest part of that line, not the foot of the
|
||||
/// measured point, and a dimension between two parallel lines reaches the nearest part of the
|
||||
/// line whose end it measures from. `None` for a side that measures a point.
|
||||
pub fn dim_reach(sk: &Sketch, k: &ConstraintKind) -> [Option<(Vec2, Vec2)>; 2] {
|
||||
use ConstraintKind::*;
|
||||
let (p, l, to_line) = match *k {
|
||||
PointLineDistance { p, l, .. } => (p, l, true),
|
||||
LinearDiameter { p, l, .. } => (p, l, false),
|
||||
_ => return [None, None],
|
||||
};
|
||||
let Some((a, b)) = line_ends(sk, l) else { return [None, None] };
|
||||
let d = b - a;
|
||||
// A line ending at `p` that runs parallel to `l`.
|
||||
let parallel = sk.curves.iter().enumerate().find_map(|(i, c)| match c.kind {
|
||||
CurveKind::Line { a: pa, b: pb } if i != l && (pa == p || pb == p) => {
|
||||
let (u, v) = line_ends(sk, i)?;
|
||||
let e = v - u;
|
||||
(e.len() > 1e-12 && d.cross(e).abs() <= 1e-9 * d.len() * e.len()).then_some((u, v))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
[to_line.then_some((a, b)), parallel]
|
||||
}
|
||||
|
||||
/// The point of segment `s` nearest `q`.
|
||||
fn nearest_on(s: (Vec2, Vec2), q: Vec2) -> Vec2 {
|
||||
let d = s.1 - s.0;
|
||||
let l2 = d.dot(d);
|
||||
if !(l2 > 1e-24) {
|
||||
return s.0;
|
||||
}
|
||||
s.0 + d * ((q - s.0).dot(d) / l2).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Lay a dimension out. `text` is the stored text position (None: the default place), `px`
|
||||
/// the sketch length of one screen pixel and `half` the half size of the text box (sketch
|
||||
/// units), which the dimension line leaves a gap for.
|
||||
pub fn dim_layout(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2) -> DimLayout {
|
||||
dim_layout_reaching(f, text, px, half, [None, None])
|
||||
}
|
||||
|
||||
/// [`dim_layout`] with the extension lines of a linear dimension starting from the nearest
|
||||
/// point of the geometry in `reach` ([`dim_reach`]) instead of the measured points.
|
||||
pub fn dim_layout_reaching(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2, reach: [Option<(Vec2, Vec2)>; 2]) -> DimLayout {
|
||||
let px = if px.is_finite() && px > 0.0 { px } else { 1.0 };
|
||||
let arrow = 9.0 * px;
|
||||
let gap = 3.0 * px;
|
||||
@@ -196,7 +237,8 @@ pub fn dim_layout(f: &DimFrame, text: Option<Vec2>, px: f64, half: Vec2) -> DimL
|
||||
// The dimension line sits `h` across from the middle of the measured points.
|
||||
let q0 = p0 + n * (h - (p0 - m).dot(n));
|
||||
let q1 = p1 + n * (h - (p1 - m).dot(n));
|
||||
for (p, q) in [(p0, q0), (p1, q1)] {
|
||||
for ((p, q), r) in [(p0, q0), (p1, q1)].into_iter().zip(reach) {
|
||||
let p = r.map_or(p, |r| nearest_on(r, q));
|
||||
let v = q - p;
|
||||
if let Some(u) = v.normalized() {
|
||||
out.lines.push(vec![p + u * gap.min(v.len()), q + u * over]);
|
||||
@@ -375,4 +417,35 @@ mod tests {
|
||||
assert_eq!(l.arrows.len(), 2);
|
||||
assert!(l.text.x > 0.0 && l.text.y > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_lines_reach_the_nearest_part_of_the_lines_measured() {
|
||||
// #1361: two parallel lines dimensioned from the low end of one; the dimension placed
|
||||
// across their middles needs no extension lines, one placed above them reaches their
|
||||
// top ends, not the low end.
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0);
|
||||
let m = sk.add_line(Vec2::new(30.0, -80.0), Vec2::new(30.0, 90.0), None, None, None).unwrap_or(0);
|
||||
let p = sk.curves[m].kind.point_ids()[0];
|
||||
let k = ConstraintKind::PointLineDistance { p, l, value: 30.0 };
|
||||
let f = dim_frame(&sk, &k).unwrap_or(DimFrame::Radial { center: Vec2::ZERO, radius: 1.0, diameter: false });
|
||||
let reach = dim_reach(&sk, &k);
|
||||
assert!(reach[0].is_some() && reach[1].is_some());
|
||||
let extension_ys = |at: Vec2| -> Vec<(f64, f64)> {
|
||||
let lay = dim_layout_reaching(&f, Some(encode_text(&f, at)), 0.1, Vec2::new(2.0, 1.0), reach);
|
||||
lay.lines.iter().filter(|line| line.len() == 2 && (line[0].x - line[1].x).abs() < 1e-9).map(|line| (line[0].y, line[1].y)).collect()
|
||||
};
|
||||
assert!(extension_ys(Vec2::new(15.0, 50.0)).is_empty(), "{:?}", extension_ys(Vec2::new(15.0, 50.0)));
|
||||
let above = extension_ys(Vec2::new(15.0, 120.0));
|
||||
assert_eq!(above.len(), 2);
|
||||
assert!((above[0].0 - 100.3).abs() < 1e-9 && (above[1].0 - 90.3).abs() < 1e-9, "{above:?}");
|
||||
// Without the lines' reach the extension lines start at the measured point's height.
|
||||
let lay = dim_layout(&f, Some(encode_text(&f, Vec2::new(15.0, 120.0))), 0.1, Vec2::new(2.0, 1.0));
|
||||
assert!(lay.lines.iter().any(|line| (line[0].y + 79.7).abs() < 1e-9));
|
||||
// A point that is not the end of a parallel line keeps its own extension line.
|
||||
let mut sk = Sketch::new();
|
||||
let l = sk.add_line(Vec2::new(0.0, -100.0), Vec2::new(0.0, 100.0), None, None, None).unwrap_or(0);
|
||||
let q = sk.add_point(Vec2::new(30.0, -80.0), None).unwrap_or(0);
|
||||
assert!(dim_reach(&sk, &ConstraintKind::PointLineDistance { p: q, l, value: 30.0 })[1].is_none());
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ pub use curves::{
|
||||
spline_end_tangent, spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode,
|
||||
};
|
||||
pub use decompose::Budget;
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text};
|
||||
pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, dim_layout_reaching, dim_reach, encode_text};
|
||||
pub use fonts::{DEFAULT_FONT_FAMILY, FontData, families_in, find_style_in, font_data, font_data_styled, installed_families, set_font_file_check};
|
||||
pub use freeform_offset::{FreeformOffset, OFFSET_TOLERANCE};
|
||||
pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES, SKETCH_AXIS_HALF, sketch_axis_geom};
|
||||
|
||||
@@ -108,6 +108,31 @@ impl LinkSource {
|
||||
LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::<String>()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies this source reads geometry from (an intersection reads two).
|
||||
pub fn bodies(&self) -> Vec<&str> {
|
||||
match self {
|
||||
LinkSource::Edge { body, .. }
|
||||
| LinkSource::Face { body, .. }
|
||||
| LinkSource::Body { body }
|
||||
| LinkSource::Vertex { body, .. }
|
||||
| LinkSource::OnSurface { body, .. }
|
||||
| LinkSource::Iso { body, .. }
|
||||
| LinkSource::Spun { body, .. } => vec![body.as_str()],
|
||||
LinkSource::Intersection { a, b } => {
|
||||
let mut bodies = a.bodies();
|
||||
bodies.extend(b.bodies());
|
||||
bodies
|
||||
}
|
||||
LinkSource::SketchCurve { .. }
|
||||
| LinkSource::SketchPoint { .. }
|
||||
| LinkSource::Origin
|
||||
| LinkSource::Axis { .. }
|
||||
| LinkSource::SketchAxis { .. }
|
||||
| LinkSource::Plane { .. }
|
||||
| LinkSource::Text { .. } => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Half length of the line a sketch axis link produces.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Fillet and chamfer drag arrows on outside and inside edges (#1384): the arrow points where
|
||||
//! the value grows (into the body on an outside edge, into the open corner on an inside one),
|
||||
//! and dragging out along it increases the value.
|
||||
|
||||
use crate::canvas::{anchor, blend_grow_dir};
|
||||
use crate::dialogs::Kind;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// A 40 x 30 x 20 block with a 20 x 30 x 10 step on its left half: the edge along y at x 20,
|
||||
/// z 20 is inside (concave); the block's right top edge (x 40, z 20) and the step's top right
|
||||
/// edge (x 20, z 30) are outside (convex).
|
||||
fn l_step() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
h.app.run("solid.box", json!({"length": 20, "width": 30, "height": 10, "corner": [0, 0, 20], "operation": "join"})).unwrap();
|
||||
h.frames(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn edge(h: &Harness, p: Vec3) -> Sel {
|
||||
let st = h.app.session.world_state();
|
||||
let m = st.body("Body1").unwrap().mesh();
|
||||
let i = m.edges.iter().position(|e| e.windows(2).any(|w| p.dist_to_segment(w[0], w[1]) < 1e-9)).expect("an edge there");
|
||||
Sel::Edge { body: "Body1".into(), index: i, point: p }
|
||||
}
|
||||
|
||||
fn grow(h: &Harness, s: &Sel) -> Vec3 {
|
||||
let Sel::Edge { body, index, point } = s else { panic!("an edge") };
|
||||
blend_grow_dir(&h.app.session.world_state().body(body).unwrap().mesh(), *index, *point).unwrap()
|
||||
}
|
||||
|
||||
fn close(a: Vec3, b: Vec3) -> bool {
|
||||
a.dist(b) < 1e-6
|
||||
}
|
||||
|
||||
const INSIDE: Vec3 = Vec3::new(20.0, 15.0, 20.0);
|
||||
const OUTSIDE: Vec3 = Vec3::new(40.0, 15.0, 20.0);
|
||||
const STEP_TOP: Vec3 = Vec3::new(20.0, 15.0, 30.0);
|
||||
|
||||
/// The direction function on its own: the corner's open side on the inside edge, into the
|
||||
/// material on the outside ones, each edge of a mixed chain on its own.
|
||||
#[test]
|
||||
fn grow_direction_follows_each_edges_convexity() {
|
||||
let h = l_step();
|
||||
let s = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let open = Vec3::new(s, 0.0, s);
|
||||
assert!(close(grow(&h, &edge(&h, INSIDE)), open), "inside: {:?}", grow(&h, &edge(&h, INSIDE)));
|
||||
assert!(close(grow(&h, &edge(&h, OUTSIDE)), Vec3::new(-s, 0.0, -s)), "outside: {:?}", grow(&h, &edge(&h, OUTSIDE)));
|
||||
// Same face normals as the inside edge, but outside: the opposite way.
|
||||
assert!(close(grow(&h, &edge(&h, STEP_TOP)), -open), "step top: {:?}", grow(&h, &edge(&h, STEP_TOP)));
|
||||
}
|
||||
|
||||
/// A plain box's outside edge keeps its arrow into the body.
|
||||
#[test]
|
||||
fn box_edge_arrow_still_points_into_the_body() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||||
let e = edge(&h, Vec3::new(20.0, 0.0, 20.0));
|
||||
for id in ["solid.fillet", "solid.chamfer"] {
|
||||
h.app.session.selection = vec![e.clone()];
|
||||
h.app.start(id);
|
||||
let dir = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap().1.unwrap();
|
||||
assert!(dir.y > 0.5 && dir.z < -0.5, "{id}: {dir:?}");
|
||||
h.app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The value of the open fillet or chamfer window.
|
||||
fn value(h: &Harness) -> f64 {
|
||||
let Some(Kind::Fillet { radius, .. }) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("a fillet window") };
|
||||
h.app.session.doc.eval(radius, solidcraft_engine::doc::expr::Kind::Length).unwrap()
|
||||
}
|
||||
|
||||
/// On an inside edge (and on an outside one, a mixed chain's first edge either way), the arrow
|
||||
/// points along the edge's own grow direction and dragging outward along it increases the value.
|
||||
#[test]
|
||||
fn dragging_out_along_the_arrow_grows_fillet_and_chamfer_on_both_kinds_of_edge() {
|
||||
for first in [INSIDE, OUTSIDE] {
|
||||
for id in ["solid.fillet", "solid.chamfer"] {
|
||||
let mut h = l_step();
|
||||
h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap();
|
||||
h.step(&json!({"call": "ui.view", "params": {"view": "fit"}})).unwrap();
|
||||
let chain = if first == INSIDE { vec![edge(&h, INSIDE), edge(&h, OUTSIDE)] } else { vec![edge(&h, OUTSIDE), edge(&h, INSIDE)] };
|
||||
h.app.session.selection = chain;
|
||||
h.app.start(id);
|
||||
h.frames(3);
|
||||
let shown = h.app.dialog.as_ref().unwrap().inputs[0].items[0].clone();
|
||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.len(), 2);
|
||||
let want = grow(&h, &shown);
|
||||
assert!(close(want, grow(&h, &edge(&h, first))) || matches!(&shown, Sel::Edge { point, .. } if *point != first));
|
||||
let (at, dir) = anchor(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
assert!(close(dir.unwrap(), want), "{id} at {first:?}: {dir:?} vs {want:?}");
|
||||
let before = value(&h);
|
||||
let to = at + want * 6.0;
|
||||
h.step(&json!({"drag": [{"handle": "arrow"}, {"world": [to.x, to.y, to.z]}], "ctrl": true})).unwrap();
|
||||
let after = value(&h);
|
||||
assert!(after > before && (after - 6.0).abs() < 0.3, "{id} at {first:?}: {before} -> {after}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1248,7 +1248,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.state()
|
||||
.bodies
|
||||
.iter()
|
||||
.filter(|b| doc.model_body_component(b) == comp)
|
||||
.filter(|b| doc.model_body_component(b) == comp && !app.session.body_removed_later(&b.name))
|
||||
.map(|b| Entry {
|
||||
key: b.name.clone(),
|
||||
row_key: format!("b:{}", b.name),
|
||||
@@ -1818,7 +1818,7 @@ fn triad(app: &mut SolveApp, ui: &mut egui::Ui, m: &mut OccMove) {
|
||||
/// viewport like the sketch being edited draws its own (`crate::dim_view`), but not editable.
|
||||
pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
|
||||
use solidcraft_engine::geom::Vec2;
|
||||
use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout};
|
||||
use solidcraft_engine::sketch::{DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach};
|
||||
let Some(rect) = app.viewport.rect else { return };
|
||||
if app.ui.shown_dims.is_empty() {
|
||||
return;
|
||||
@@ -1856,7 +1856,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) {
|
||||
);
|
||||
let size = galley.size() + vec2(6.0, 2.0);
|
||||
let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px);
|
||||
let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half);
|
||||
let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, middle, px)), px, half, dim_reach(sk, &c.kind));
|
||||
for line in &lay.lines {
|
||||
painter.add(egui::Shape::line(line.iter().filter_map(|q| to(*q)).collect(), Stroke::new(1.0, col)));
|
||||
}
|
||||
@@ -1960,6 +1960,12 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solidcraft_engin
|
||||
Some(solidcraft_engine::doc::apply_point(&solidcraft_engine::doc::mat_mul(&parent, &own), solidcraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// The names the Bodies folder lists at the top level, for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn body_rows(app: &SolveApp) -> Vec<String> {
|
||||
entries(app, 0, "bodies").into_iter().map(|e| e.key).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "section_browser_tests.rs"]
|
||||
mod section_browser_tests;
|
||||
@@ -116,7 +116,13 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
(at, normal.or(dir.map(|d| -d)))
|
||||
}
|
||||
Kind::Section { .. } | Kind::OffsetPlane { .. } | Kind::OffsetFaces { .. } => (at, normal),
|
||||
// Fillet radii, chamfer distances and shell thickness grow into the body.
|
||||
// Fillet radii and chamfer distances grow the way `blend_grow_dir` gives (into the body
|
||||
// on an outside edge, into the open corner on an inside one); shell thickness grows into
|
||||
// the body.
|
||||
Kind::Fillet { .. } if matches!(first, Sel::Edge { .. }) => {
|
||||
let Sel::Edge { body, index, .. } = first else { return None };
|
||||
(at, st.body(body).and_then(|b| blend_grow_dir(&b.mesh(), *index, at)))
|
||||
}
|
||||
Kind::Fillet { .. } | Kind::Shell { .. } => (at, normal.map(|n| -n)),
|
||||
Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)),
|
||||
Kind::Plastic(_) => (at, normal),
|
||||
@@ -178,23 +184,46 @@ fn reference_height(app: &SolveApp, d: &Dialog, label: &str, plane: &solidcraft_
|
||||
Some(solidcraft_engine::doc::object_height(&app.session.world_state(), plane, centre, p))
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge (a fillet's radius arrow points the
|
||||
/// other way, into the body).
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
/// The two faces at an edge, near `p`: for each, the normal and centroid of its triangle nearest
|
||||
/// `p`.
|
||||
fn edge_sides(m: &Mesh, index: usize, p: Vec3) -> Option<Vec<(Vec3, Vec3)>> {
|
||||
let faces = m.edge_faces.get(index)?;
|
||||
let mut sum = Vec3::ZERO;
|
||||
for f in faces {
|
||||
let n = m
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, tf)| *tf == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p)))
|
||||
.and_then(|[a, b, c]| (b - a).cross(c - a).normalized())?;
|
||||
sum += n;
|
||||
}
|
||||
sum.normalized()
|
||||
faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
m.triangles
|
||||
.iter()
|
||||
.zip(&m.tri_face)
|
||||
.filter(|(_, tf)| *tf == f)
|
||||
.filter_map(|(t, _)| m.tri(t))
|
||||
.min_by(|a, b| ((a[0] + a[1] + a[2]) / 3.0).dist(p).total_cmp(&((b[0] + b[1] + b[2]) / 3.0).dist(p)))
|
||||
.and_then(|[a, b, c]| Some(((b - a).cross(c - a).normalized()?, (a + b + c) / 3.0)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The outward direction between the two faces at an edge: the mean of their normals.
|
||||
fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
edge_sides(m, index, p)?.iter().fold(Vec3::ZERO, |sum, (n, _)| sum + *n).normalized()
|
||||
}
|
||||
|
||||
/// Whether the edge is an inside (concave) one: each face near it lies in front of the other's
|
||||
/// plane, where on an outside edge it lies behind. Only the offset across the edge counts, since
|
||||
/// each normal is square to the edge.
|
||||
fn edge_concave(m: &Mesh, index: usize, p: Vec3) -> Option<bool> {
|
||||
let sides = edge_sides(m, index, p)?;
|
||||
let [(n1, c1), (n2, c2)] = sides.as_slice() else { return None };
|
||||
let s = (*c1 - p).dot(*n2) + (*c2 - p).dot(*n1);
|
||||
Some(s > 0.0)
|
||||
}
|
||||
|
||||
/// The direction a fillet's radius or a chamfer's distance grows from an edge, along the bisector
|
||||
/// of its faces: into the body on an outside edge, where the blend takes material away, and out
|
||||
/// into the open corner on an inside edge, where it adds material (#1384). Dragging along it
|
||||
/// increases the value either way.
|
||||
pub(crate) fn blend_grow_dir(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
let n = edge_bisector(m, index, p)?;
|
||||
Some(if edge_concave(m, index, p).unwrap_or(false) { n } else { -n })
|
||||
}
|
||||
|
||||
/// Whether a dynamically registered canvas handle owns this point before navigation.
|
||||
@@ -609,8 +638,9 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain
|
||||
let (spline, start) = law.as_ref()?;
|
||||
let other = if start.dist(*a) <= start.dist(*b) { *b } else { *a };
|
||||
let mid = *start + (other - *start) * 0.5;
|
||||
// Into the body, between the two faces (the edge is straight between planar faces).
|
||||
let n = -edge_bisector(mesh, *index, mid)?;
|
||||
// Between the two faces, the way this edge's radius grows: into the body on an outside
|
||||
// edge, into the corner on an inside one (the edge is straight between planar faces).
|
||||
let n = blend_grow_dir(mesh, *index, mid)?;
|
||||
let curve = (0..=48)
|
||||
.filter_map(|k| {
|
||||
let u = f64::from(k) / 48.0;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
//! - `ui.confirm {accept?}`: the Delete confirmation (without `accept`: what it lists)
|
||||
//! - `ui.shortcut {command, key?, replace?}`: a command's key, or bind one ("" clears)
|
||||
//! - `ui.help {item?: about|shortcuts|report}`: the Help menu; returns the version and diagnostics
|
||||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa,
|
||||
//! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_around, msaa,
|
||||
//! length_decimals, angle_decimals); returns them all
|
||||
//! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question
|
||||
//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor
|
||||
|
||||
@@ -108,6 +108,7 @@ pub enum Kind {
|
||||
/// Sweep a profile along a path of sketch curves.
|
||||
Sweep {
|
||||
operation: usize,
|
||||
opts: crate::dialogs_sweep::SweepOpts,
|
||||
},
|
||||
/// A construction plane offset from a plane.
|
||||
OffsetPlane {
|
||||
@@ -544,8 +545,8 @@ impl Dialog {
|
||||
),
|
||||
"solid.loft" => Dialog::new(Kind::Loft { operation: usize::from(has_bodies) }, vec![SelInput::new("Profiles", PROFILES, true)]),
|
||||
"solid.sweep" => Dialog::new(
|
||||
Kind::Sweep { operation: usize::from(has_bodies) },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true)],
|
||||
Kind::Sweep { operation: usize::from(has_bodies), opts: crate::dialogs_sweep::SweepOpts::default() },
|
||||
vec![SelInput::new("Profile", PROFILES, true), SelInput::new("Path", CURVES, true), crate::dialogs_sweep::direction_input()],
|
||||
),
|
||||
"solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)),
|
||||
"solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]),
|
||||
@@ -2537,7 +2538,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "lf_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
enter |= crate::dialogs_sweep::rows(ui, opts, "sw");
|
||||
row_label(ui, "Operation");
|
||||
combo(ui, "sw_op", &OP_LABELS, operation);
|
||||
ui.end_row();
|
||||
@@ -3393,8 +3395,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
|
||||
}
|
||||
// Each planar body face extrudes on its own.
|
||||
for p in face_points(0) {
|
||||
out.push(("solid.extrude".to_string(), with(json!({ "face": p }))));
|
||||
for pick in sels(d, 0) {
|
||||
if let Sel::Face { body, point, .. } = pick {
|
||||
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
@@ -3548,16 +3552,15 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let sections: Vec<Value> = sections.into_iter().map(|(sk, v)| json!({"sketch": sk, "profiles": v})).collect();
|
||||
("solid.loft", json!({"sections": sections, "operation": OPS.get(*operation).copied().unwrap_or("new")}))
|
||||
}
|
||||
Kind::Sweep { operation } => {
|
||||
Kind::Sweep { operation, opts } => {
|
||||
need(0, "a profile")?;
|
||||
need(1, "the path")?;
|
||||
let (sketch, idx) = profiles();
|
||||
let path: Vec<String> = sels(d, 1).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect();
|
||||
let path_sketch = curves_sketch(app, &path, sketch.as_u64()).ok_or("the path curves must be in one sketch")?;
|
||||
(
|
||||
"solid.sweep",
|
||||
json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")}),
|
||||
)
|
||||
let mut p = json!({"sketch": sketch, "profiles": idx, "path_sketch": path_sketch, "path": path, "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
crate::dialogs_sweep::params(app, opts, sels(d, 2).first(), &mut p)?;
|
||||
("solid.sweep", p)
|
||||
}
|
||||
Kind::OffsetPlane { offset } => {
|
||||
need(0, "a plane")?;
|
||||
@@ -4614,9 +4617,13 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, .. } => {
|
||||
FeatureKind::Sweep { sketch, profiles, path, operation, targets, orientation, twist, scale, .. } => {
|
||||
let mut d = start("solid.sweep")?;
|
||||
d.kind = Kind::Sweep { operation: op_index(operation) };
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, &feature_frame(s, f.component));
|
||||
d.kind = Kind::Sweep { operation: op_index(operation), opts };
|
||||
if let Some(inp) = d.inputs.get_mut(2) {
|
||||
inp.items = direction.into_iter().collect();
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.get_mut(0) {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
|
||||
@@ -450,6 +450,7 @@ fn shifted(s: &SnapPt, x: f64, y: f64) -> Value {
|
||||
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [SelInput], active: &mut usize) -> bool {
|
||||
let t = Tokens::get();
|
||||
let mut enter = false;
|
||||
let problem = joint_problem(app, k, inputs);
|
||||
match k {
|
||||
Asm::NewComponent { name, activate } => {
|
||||
row_label(ui, "Name");
|
||||
@@ -465,6 +466,20 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
||||
if f.as_built && *active == 0 && inputs.first().is_some_and(|i| i.items.len() >= 2) {
|
||||
*active = 1;
|
||||
}
|
||||
// Picks that make no joint say why, under them, and offer the way out (#1239).
|
||||
if let Some((why, bodies)) = problem {
|
||||
row_label(ui, "");
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(why).color(t.warning)).wrap());
|
||||
if !bodies.is_empty()
|
||||
&& ui.button(crate::i18n::tr("cmd.component_from_bodies")).on_hover_text(crate::i18n::tr("joint.from_bodies_hint")).clicked()
|
||||
{
|
||||
// A refusal shows on the status line; the picks stay either way.
|
||||
let _ = app.run("component.from_bodies", json!({ "bodies": bodies }));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, "Motion");
|
||||
ui.label("");
|
||||
ui.end_row();
|
||||
@@ -729,6 +744,42 @@ fn sel_items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// The body a pick is on.
|
||||
fn body_of(sel: &Sel) -> Option<&str> {
|
||||
match sel {
|
||||
Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(body),
|
||||
Sel::Body { name } => Some(name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the Joint dialog's picks, all in, make no joint: without it the preview and OK just go
|
||||
/// quiet, and no option seems to do anything (#1239). With it, the bodies Create Components from
|
||||
/// Bodies would part (none when that would not help). The usual case is every pick on one
|
||||
/// component: a STEP file with several parts often brings them as bodies of one component.
|
||||
fn joint_problem(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Option<(String, Vec<String>)> {
|
||||
let Asm::Joint(f) = k else { return None };
|
||||
let picks: Vec<&Sel> =
|
||||
if f.as_built { sel_items(inputs, 0).iter().collect() } else { (0..2).filter_map(|i| sel_items(inputs, i).first()).collect() };
|
||||
if f.editing.is_some() || picks.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut bodies: Vec<String> = Vec::new();
|
||||
for b in picks.iter().filter_map(|p| body_of(p)) {
|
||||
if !bodies.iter().any(|x| x == b) {
|
||||
bodies.push(b.to_string());
|
||||
}
|
||||
}
|
||||
let occs: Vec<Option<u64>> = bodies.iter().map(|b| occurrence_of_body(&app.session, b)).collect();
|
||||
if occs.first().is_some_and(|first| first.is_some() && occs.iter().all(|o| o == first)) {
|
||||
return Some(match bodies.as_slice() {
|
||||
[body] => (crate::i18n::format("joint.one_body", &[("body", body.as_str())]), Vec::new()),
|
||||
_ => (crate::i18n::format("joint.one_component", &[("bodies", &bodies.join(", "))]), bodies),
|
||||
});
|
||||
}
|
||||
commands(app, k, inputs).err().map(|e| (e, Vec::new()))
|
||||
}
|
||||
|
||||
/// The occurrences of picked bodies (deduplicated, in pick order).
|
||||
fn occurrences(s: &Session, items: &[Sel]) -> Vec<u64> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -207,6 +207,66 @@ fn ok_click_survives_a_busy_preview() {
|
||||
assert!(app.dialog.is_none());
|
||||
}
|
||||
|
||||
/// #1239: two parts as bodies of one component (a multi-body STEP import). Picks on both used to
|
||||
/// make no joint with nothing said, so no option seemed to do anything. The dialog now says why
|
||||
/// under the picks and offers Create Components from Bodies, after which the same picks join.
|
||||
#[test]
|
||||
fn picks_on_one_component_say_why_and_offer_components() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10})).unwrap();
|
||||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "corner": [30, 0, 0]})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
let bodies = vec!["Body1".to_string(), "Body2".to_string()];
|
||||
// As-built: two bodies of one component are one component.
|
||||
app.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }];
|
||||
app.start("joint.as_built");
|
||||
let d = app.dialog.take().unwrap();
|
||||
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
|
||||
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(bodies.clone()));
|
||||
// Two faces of one body: pick another part (nothing to part).
|
||||
app.session.selection.clear();
|
||||
app.start("joint.create");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
|
||||
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 0.0)).unwrap());
|
||||
let Kind::Assembly(k) = &d.kind else { panic!("{:?}", d.kind) };
|
||||
assert_eq!(joint_problem(&app, k, &d.inputs).map(|p| p.1), Some(vec![]));
|
||||
// A face of each body.
|
||||
app.start("joint.create");
|
||||
let mut d = app.dialog.take().unwrap();
|
||||
d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap());
|
||||
d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 0.0)).unwrap());
|
||||
assert!(apply_commands(&app, &d).is_err(), "one component: no joint");
|
||||
app.dialog = Some(d);
|
||||
let ctx = egui::Context::default();
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
|
||||
let frame = |app: &mut SolveApp, events: Vec<egui::Event>| {
|
||||
let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx()));
|
||||
// Nothing is painted: drop the texture changes (an unapplied delta panics on drop).
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
};
|
||||
// The auto-sized dialog takes a few frames to settle its width.
|
||||
for _ in 0..3 {
|
||||
frame(&mut app, vec![]);
|
||||
}
|
||||
let out = frame(&mut app, vec![]);
|
||||
let why = crate::i18n::format("joint.one_component", &[("bodies", "Body1, Body2")]);
|
||||
assert!(text_at(&out, &why).is_some(), "the dialog says why");
|
||||
let at = text_at(&out, crate::i18n::tr("cmd.component_from_bodies")).expect("and offers the way out");
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(&mut app, vec![button(true)]);
|
||||
frame(&mut app, vec![button(false)]);
|
||||
assert!(bodies.iter().all(|b| app.session.doc.find_component(b).is_some()), "each body has a component");
|
||||
let d = app.dialog.as_ref().expect("the dialog stays open");
|
||||
let c = apply_commands(&app, d).unwrap();
|
||||
assert_eq!((c[0].1["a"]["occurrence"].clone(), c[0].1["b"]["occurrence"].clone()), (json!(occ(&app, "Body1")), json!(occ(&app, "Body2"))));
|
||||
let out = frame(&mut app, vec![]);
|
||||
assert!(text_at(&out, &why).is_none(), "nothing left to say");
|
||||
}
|
||||
|
||||
/// Two top faces mate face to face as in Fusion: B ends up upside down on A, not inside it;
|
||||
/// Flip turns it back over (into A).
|
||||
#[test]
|
||||
|
||||
@@ -25,7 +25,7 @@ pub enum Surf {
|
||||
symmetric: bool,
|
||||
axis: Option<Value>,
|
||||
},
|
||||
Sweep,
|
||||
Sweep(crate::dialogs_sweep::SweepOpts),
|
||||
Loft,
|
||||
Patch,
|
||||
Unstitch,
|
||||
@@ -68,7 +68,7 @@ impl Surf {
|
||||
match self {
|
||||
Surf::Extrude { .. } => "SURFACE EXTRUDE",
|
||||
Surf::Revolve { .. } => "SURFACE REVOLVE",
|
||||
Surf::Sweep => "SURFACE SWEEP",
|
||||
Surf::Sweep(_) => "SURFACE SWEEP",
|
||||
Surf::Loft => "SURFACE LOFT",
|
||||
Surf::Patch => "PATCH",
|
||||
Surf::Unstitch => "UNSTITCH",
|
||||
@@ -110,7 +110,11 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
|
||||
match k {
|
||||
Surf::Extrude { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true)],
|
||||
Surf::Revolve { .. } => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Axis", AXES, false)],
|
||||
Surf::Sweep => vec![SelInput::new("Profiles or curves", PROFILES | CURVES, true), SelInput::new("Path", CURVES, true)],
|
||||
Surf::Sweep(_) => vec![
|
||||
SelInput::new("Profiles or curves", PROFILES | CURVES, true),
|
||||
SelInput::new("Path", CURVES, true),
|
||||
crate::dialogs_sweep::direction_input(),
|
||||
],
|
||||
Surf::Loft => vec![SelInput::new("Sections (one sketch each)", PROFILES | CURVES, true)],
|
||||
Surf::Patch => vec![SelInput::new("Boundary", PROFILES | EDGES, true)],
|
||||
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
|
||||
@@ -137,7 +141,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let k = match id {
|
||||
"surface.extrude" => Surf::Extrude { distance: "10 mm".into(), direction: 0 },
|
||||
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
|
||||
"surface.sweep" => Surf::Sweep,
|
||||
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
|
||||
"surface.loft" => Surf::Loft,
|
||||
"surface.patch" => Surf::Patch,
|
||||
"surface.unstitch" => Surf::Unstitch,
|
||||
@@ -243,7 +247,8 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
|
||||
ui.checkbox(in_place, "");
|
||||
ui.end_row();
|
||||
}
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Sweep | Surf::Loft => {}
|
||||
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
|
||||
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
|
||||
}
|
||||
enter
|
||||
}
|
||||
@@ -345,7 +350,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
}
|
||||
("surface.revolve", p)
|
||||
}
|
||||
Surf::Sweep => {
|
||||
Surf::Sweep(opts) => {
|
||||
need(0, "profiles or curves to sweep")?;
|
||||
need(1, "the path")?;
|
||||
let mut p = sheet_picks(app, items(inputs, 0))?;
|
||||
@@ -354,6 +359,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
|
||||
p["path_sketch"] =
|
||||
json!(crate::dialogs::curves_sketch(app, &path, p["sketch"].as_u64()).ok_or("the path curves must be in one other sketch")?);
|
||||
p["path"] = json!(path);
|
||||
crate::dialogs_sweep::params(app, opts, items(inputs, 2).first(), &mut p)?;
|
||||
("surface.sweep", p)
|
||||
}
|
||||
Surf::Loft => {
|
||||
@@ -578,10 +584,17 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
}
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, .. } => (
|
||||
Surf::Sweep,
|
||||
vec![sheet_sels(app, *sketch, profiles.as_ref(), curves), path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect()],
|
||||
),
|
||||
FeatureKind::SurfaceSweep { sketch, profiles, curves, path, orientation, twist, scale, .. } => {
|
||||
let (opts, direction) = crate::dialogs_sweep::SweepOpts::of(orientation, twist, scale, frame);
|
||||
(
|
||||
Surf::Sweep(opts),
|
||||
vec![
|
||||
sheet_sels(app, *sketch, profiles.as_ref(), curves),
|
||||
path.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(),
|
||||
direction.into_iter().collect(),
|
||||
],
|
||||
)
|
||||
}
|
||||
FeatureKind::SurfaceLoft { sections } => {
|
||||
(Surf::Loft, vec![sections.iter().flat_map(|sec| sheet_sels(app, sec.sketch, sec.profiles.as_ref(), &sec.curves)).collect()])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The options Sweep and Surface Sweep share (#771): how the profile turns along the path, a
|
||||
//! twist over the path's length and the profile's scale at the end.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{Mat, SweepOrientation};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{combo, field, row_label};
|
||||
use crate::selection::{AXES, EDGES, SelInput};
|
||||
|
||||
/// The orientation choices, in combo order: perpendicular, parallel, a direction.
|
||||
const ORIENTATIONS: [&str; 3] = ["Turn with Path", "Keep as Drawn", "Follow Direction"];
|
||||
/// The orientation choice that takes a picked direction.
|
||||
const DIRECTION: usize = 2;
|
||||
/// A twist field that means no twist.
|
||||
const NO_TWIST: &str = "0 deg";
|
||||
/// An end scale field that means no scaling.
|
||||
const NO_SCALE: &str = "1";
|
||||
|
||||
/// A sweep dialog's options.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepOpts {
|
||||
/// An index into [`ORIENTATIONS`].
|
||||
pub orientation: usize,
|
||||
pub twist: String,
|
||||
pub scale: String,
|
||||
/// A stored direction (world), used while no direction is picked.
|
||||
pub direction: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl Default for SweepOpts {
|
||||
fn default() -> Self {
|
||||
SweepOpts { orientation: 0, twist: NO_TWIST.into(), scale: NO_SCALE.into(), direction: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl SweepOpts {
|
||||
/// A feature's stored options as dialog values; `frame` maps the feature's component frame to
|
||||
/// the world (a stored direction is in the component's frame). A direction along an origin
|
||||
/// axis comes back as a pick of that axis.
|
||||
pub fn of(orientation: &SweepOrientation, twist: &Option<String>, scale: &Option<String>, frame: &Mat) -> (SweepOpts, Option<Sel>) {
|
||||
let mut o = SweepOpts {
|
||||
twist: twist.clone().unwrap_or_else(|| NO_TWIST.into()),
|
||||
scale: scale.clone().unwrap_or_else(|| NO_SCALE.into()),
|
||||
..SweepOpts::default()
|
||||
};
|
||||
let mut pick = None;
|
||||
match orientation {
|
||||
SweepOrientation::Perpendicular => {}
|
||||
SweepOrientation::Parallel => o.orientation = 1,
|
||||
SweepOrientation::Direction(d) => {
|
||||
o.orientation = DIRECTION;
|
||||
let world = solidcraft_engine::doc::apply_vector(frame, *d);
|
||||
match world.normalized().and_then(world_axis) {
|
||||
Some(name) => pick = Some(Sel::Axis { name: name.into() }),
|
||||
None => o.direction = Some(world),
|
||||
}
|
||||
}
|
||||
}
|
||||
(o, pick)
|
||||
}
|
||||
}
|
||||
|
||||
/// The origin axis a direction runs along (its way), if any.
|
||||
fn world_axis(d: Vec3) -> Option<&'static str> {
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// The input that picks a Follow Direction sweep's direction: an axis, a sketch line or a
|
||||
/// straight edge.
|
||||
pub fn direction_input() -> SelInput {
|
||||
SelInput::new("Direction", AXES | EDGES, false)
|
||||
}
|
||||
|
||||
/// The option rows; true when Enter was pressed in a field.
|
||||
pub fn rows(ui: &mut egui::Ui, o: &mut SweepOpts, id: &str) -> bool {
|
||||
let mut enter = false;
|
||||
row_label(ui, "Orientation");
|
||||
combo(ui, &format!("{id}_orient"), &ORIENTATIONS, &mut o.orientation);
|
||||
ui.end_row();
|
||||
row_label(ui, "Twist");
|
||||
enter |= field(ui, &mut o.twist);
|
||||
ui.end_row();
|
||||
row_label(ui, "End Scale");
|
||||
enter |= field(ui, &mut o.scale);
|
||||
ui.end_row();
|
||||
enter
|
||||
}
|
||||
|
||||
/// The options as command parameters, added to `p`: the orientation, and the twist and end
|
||||
/// scale when they change anything. `picked` is the direction input's selection.
|
||||
pub fn params(app: &SolveApp, o: &SweepOpts, picked: Option<&Sel>, p: &mut Value) -> Result<(), String> {
|
||||
match o.orientation {
|
||||
0 => {}
|
||||
1 => p["orientation"] = json!("parallel"),
|
||||
_ => {
|
||||
let d = match (picked, o.direction) {
|
||||
(Some(x), _) => crate::dialogs::axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
|
||||
(None, Some(d)) => d,
|
||||
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) for the profile to follow".into()),
|
||||
};
|
||||
p["orientation"] = json!([d.x, d.y, d.z]);
|
||||
}
|
||||
}
|
||||
let twist = o.twist.trim();
|
||||
if !twist.is_empty() && twist != NO_TWIST {
|
||||
p["twist"] = json!(twist);
|
||||
}
|
||||
let scale = o.scale.trim();
|
||||
if !scale.is_empty() && scale != NO_SCALE {
|
||||
p["scale"] = json!(scale);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::geom::Vec2;
|
||||
use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout};
|
||||
use solidcraft_engine::sketch::{ConstraintKind, DimFrame, chain_centres, default_text, dim_frame, dim_layout_reaching, dim_reach};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
@@ -516,7 +516,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
let half = Vec2::new(f64::from(size.x) * 0.5 * px, f64::from(size.y) * 0.5 * px);
|
||||
// Unplaced linear dimensions sit outside the shape they measure.
|
||||
let away = first_point(sk, &c.kind).and_then(|p| centres.get(p).copied()).unwrap_or(anchor);
|
||||
let lay = dim_layout(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half);
|
||||
let lay = dim_layout_reaching(&frame, c.text.or_else(|| default_text(&frame, away, px)), px, half, dim_reach(sk, &c.kind));
|
||||
let stroke = Stroke::new(1.0, col);
|
||||
let mut segs = Vec::new();
|
||||
for line in &lay.lines {
|
||||
|
||||
@@ -1314,10 +1314,36 @@ impl Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// A device of its own for an offscreen render or check (`None` without a GPU adapter).
|
||||
/// The backends the offscreen tests start, chosen once per test process as the app chooses them
|
||||
/// (`apps/solidcraft/src/startup.rs`, `choose_backends`): what `WGPU_BACKEND` names; on Windows one
|
||||
/// backend, the first of D3D12, Vulkan and OpenGL with a hardware adapter (else D3D12); elsewhere
|
||||
/// wgpu's default. wgpu's default on Windows starts Vulkan, D3D12 and OpenGL together, and tests
|
||||
/// starting them side by side hung or crashed the test process inside the Vulkan driver.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn offscreen_backends() -> wgpu::Backends {
|
||||
static CHOSEN: std::sync::OnceLock<wgpu::Backends> = std::sync::OnceLock::new();
|
||||
*CHOSEN.get_or_init(|| {
|
||||
let default = wgpu::InstanceDescriptor::new_without_display_handle_from_env().backends;
|
||||
if !cfg!(target_os = "windows") || std::env::var("WGPU_BACKEND").is_ok_and(|v| !v.trim().is_empty()) {
|
||||
return default;
|
||||
}
|
||||
let hardware = |backends: wgpu::Backends| {
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
|
||||
desc.backends = backends;
|
||||
let adapters = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(backends));
|
||||
adapters.iter().any(|a| a.get_info().device_type != wgpu::DeviceType::Cpu)
|
||||
};
|
||||
[wgpu::Backends::DX12, wgpu::Backends::VULKAN, wgpu::Backends::GL].into_iter().find(|b| hardware(*b)).unwrap_or(wgpu::Backends::DX12)
|
||||
})
|
||||
}
|
||||
|
||||
/// A device of its own for an offscreen render or check (`None` without a GPU adapter), on
|
||||
/// [`offscreen_backends`].
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env());
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
|
||||
desc.backends = offscreen_backends();
|
||||
let instance = wgpu::Instance::new(desc);
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
@@ -1572,6 +1598,27 @@ mod tests {
|
||||
assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Vulkan, "AMD FirePro W5100"));
|
||||
}
|
||||
|
||||
/// The offscreen tests start the backends the app would: on Windows without `WGPU_BACKEND`
|
||||
/// exactly one, from the app's order (D3D12, Vulkan, OpenGL); otherwise wgpu's default.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn offscreen_tests_start_the_backend_the_app_would() {
|
||||
use egui_wgpu::wgpu::Backends;
|
||||
let chosen = super::offscreen_backends();
|
||||
let env = std::env::var("WGPU_BACKEND").is_ok_and(|v| !v.trim().is_empty());
|
||||
if cfg!(target_os = "windows") && !env {
|
||||
assert!([Backends::DX12, Backends::VULKAN, Backends::GL].contains(&chosen), "one backend, not {chosen:?}");
|
||||
} else {
|
||||
assert_eq!(chosen, egui_wgpu::wgpu::InstanceDescriptor::new_without_display_handle_from_env().backends);
|
||||
}
|
||||
assert_eq!(super::offscreen_backends(), chosen, "chosen once per test process");
|
||||
// And the offscreen device is made on them (skipped without a GPU adapter).
|
||||
if let Some((adapter, _, _)) = super::offscreen_device() {
|
||||
let used = Backends::from(adapter.get_info().backend);
|
||||
assert!(chosen.contains(used), "the offscreen device runs on {used:?}, not on {chosen:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The viewport shader parses and validates (a bad shader would only fail at run time).
|
||||
#[test]
|
||||
fn shader_is_valid() {
|
||||
|
||||
@@ -32,6 +32,7 @@ pub mod dialogs_plastic;
|
||||
pub mod dialogs_save_mesh;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dialogs_surface;
|
||||
pub mod dialogs_sweep;
|
||||
pub mod dim_view;
|
||||
#[cfg(test)]
|
||||
mod dimension_create_ui_tests;
|
||||
@@ -88,6 +89,8 @@ mod file_load;
|
||||
mod file_picker;
|
||||
pub use file_picker::FilePickerFuture;
|
||||
#[cfg(test)]
|
||||
mod blend_arrow_tests;
|
||||
#[cfg(test)]
|
||||
mod file_load_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod fillet_spline_handle_tests;
|
||||
@@ -141,6 +144,8 @@ pub mod smart_extrude;
|
||||
#[cfg(test)]
|
||||
mod smart_extrude_ui_tests;
|
||||
pub mod stand_up;
|
||||
#[cfg(test)]
|
||||
mod sweep_dialog_tests;
|
||||
pub mod theme;
|
||||
#[cfg(test)]
|
||||
mod thin_extrude_tests;
|
||||
|
||||
@@ -226,12 +226,30 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
||||
ui.label(RichText::new(h).color(t.text_dim).small());
|
||||
}
|
||||
ui.end_row();
|
||||
for r in rows {
|
||||
// Favourite star.
|
||||
let star = if r.favorite { RichText::new("★").color(t.accent) } else { RichText::new("☆").color(t.text_dim) };
|
||||
if ui.add(egui::Button::new(star).frame(false)).on_hover_text(crate::i18n::tr_label("Favourite")).clicked() {
|
||||
d.error = app.run("parameters.favorite", json!({"name": r.name, "favorite": !r.favorite})).err();
|
||||
}
|
||||
for (i, r) in rows.iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
// Favourite star.
|
||||
let star = if r.favorite { RichText::new("★").color(t.accent) } else { RichText::new("☆").color(t.text_dim) };
|
||||
if ui.add(egui::Button::new(star).frame(false)).on_hover_text(crate::i18n::tr_label("Favourite")).clicked() {
|
||||
d.error = app.run("parameters.favorite", json!({"name": r.name, "favorite": !r.favorite})).err();
|
||||
}
|
||||
// Up and down past the neighbouring user parameter (#1187), in the user table only:
|
||||
// the favourites table lists favourites first whatever their order.
|
||||
if salt == "user" {
|
||||
for (glyph, tip, neighbour, side) in
|
||||
[("▲", "Move up", i.checked_sub(1).and_then(|j| rows.get(j)), "before"), ("▼", "Move down", rows.get(i + 1), "after")]
|
||||
{
|
||||
let button = egui::Button::new(RichText::new(glyph).small().color(t.text_dim)).frame_when_inactive(false);
|
||||
let resp = ui.add_enabled(neighbour.is_some(), button).on_hover_text(crate::i18n::tr_label(tip));
|
||||
crate::scenario::publish_handle(&format!("param:{}:{}", side, r.name), resp.rect.center());
|
||||
if resp.clicked()
|
||||
&& let Some(n) = neighbour
|
||||
{
|
||||
d.error = app.run("parameters.move", json!({"name": r.name, side: n.name})).err();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Name: double-click to rename.
|
||||
match &mut d.renaming {
|
||||
Some((old, new)) if *old == r.name => {
|
||||
|
||||
+135
-27
@@ -94,7 +94,7 @@ use NavButton::{Left, Middle, Right};
|
||||
use NavMode::{Orbit, Pan, Zoom};
|
||||
|
||||
/// The mouse mappings. The first is the default.
|
||||
pub const NAV_PRESETS: [NavPreset; 7] = [
|
||||
pub const NAV_PRESETS: [NavPreset; 8] = [
|
||||
NavPreset {
|
||||
id: "fusion",
|
||||
label: "Fusion",
|
||||
@@ -140,6 +140,13 @@ pub const NAV_PRESETS: [NavPreset; 7] = [
|
||||
bind(Right, NavKeys::NONE, Orbit),
|
||||
],
|
||||
},
|
||||
NavPreset {
|
||||
id: "blender",
|
||||
label: "Blender",
|
||||
help: "Middle drag orbits, Shift+middle pans, Ctrl+middle zooms; right drag leaves the camera alone",
|
||||
reverse_zoom: false,
|
||||
bindings: &[bind(Middle, NavKeys::SHIFT, Pan), bind(Middle, NavKeys::CTRL, Zoom), bind(Middle, NavKeys::NONE, Orbit)],
|
||||
},
|
||||
NavPreset {
|
||||
id: "tinkercad",
|
||||
label: "Tinkercad",
|
||||
@@ -229,6 +236,19 @@ impl NavigatorStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// What an orbit turns around (Preferences: "Orbit around").
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OrbitAround {
|
||||
/// The world origin: the view turns about it and keeps it still on screen.
|
||||
Origin,
|
||||
/// The view's centre: what Pan moves and Fit frames (the default, as it always was).
|
||||
#[default]
|
||||
ViewCentre,
|
||||
/// The model point under the cursor when the drag began.
|
||||
Cursor,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Prefs {
|
||||
@@ -242,7 +262,11 @@ pub struct Prefs {
|
||||
pub radial_context_menu: bool,
|
||||
/// The wheel zooms out when turned forward.
|
||||
pub zoom_reverse: bool,
|
||||
/// Orbit around the point under the cursor (else around the view's centre).
|
||||
/// What an orbit turns around.
|
||||
pub orbit_around: OrbitAround,
|
||||
/// The cursor preference as releases before the origin option saved it (`true` = the point
|
||||
/// under the cursor). [`migrate`] folds it into `orbit_around` when saved preferences are
|
||||
/// read, so an existing choice survives the upgrade; this version only writes `false`.
|
||||
pub orbit_cursor: bool,
|
||||
/// Whether a connected 3D mouse moves the view.
|
||||
pub space_mouse_enabled: bool,
|
||||
@@ -339,6 +363,7 @@ impl Default for Prefs {
|
||||
navigator_style: NavigatorStyle::default(),
|
||||
radial_context_menu: false,
|
||||
zoom_reverse: false,
|
||||
orbit_around: OrbitAround::default(),
|
||||
orbit_cursor: false,
|
||||
space_mouse_enabled: true,
|
||||
space_mouse_speed: 1.0,
|
||||
@@ -475,6 +500,7 @@ fn defaults(app: &mut SolveApp, page: usize) {
|
||||
p.zoom_to_cursor = d.zoom_to_cursor;
|
||||
p.smooth_wheel_zoom = d.smooth_wheel_zoom;
|
||||
p.trackpad_pan = d.trackpad_pan;
|
||||
p.orbit_around = d.orbit_around;
|
||||
p.orbit_cursor = d.orbit_cursor;
|
||||
p.space_mouse_enabled = d.space_mouse_enabled;
|
||||
p.space_mouse_speed = d.space_mouse_speed;
|
||||
@@ -578,25 +604,36 @@ pub fn zoom_sign(app: &SolveApp) -> f64 {
|
||||
if app.preferences.zoom_reverse { -1.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Orbit by pixel deltas: around the view's centre, or, with "orbit around the cursor", around
|
||||
/// the model point that was under the cursor when the drag began (kept still on screen). A drag
|
||||
/// that begins over empty space turns around the centre of the visible geometry instead, so the
|
||||
/// model never swings around a far-off point. The pivot is chosen once, when the drag begins.
|
||||
/// Orbit by pixel deltas about the configured centre: the view's centre (default), the world
|
||||
/// origin, or the model point that was under the cursor when the drag began. The origin and the
|
||||
/// cursor pivot are kept still on screen; a cursor drag that begins over empty space turns around
|
||||
/// the centre of the visible geometry instead, so the model never swings around a far-off point.
|
||||
/// The cursor pivot is chosen once, when the drag begins.
|
||||
pub fn orbit(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, press: Option<Pos2>) {
|
||||
if !app.preferences.orbit_cursor {
|
||||
let pivot = match app.preferences.orbit_around {
|
||||
OrbitAround::ViewCentre => None,
|
||||
OrbitAround::Origin => Some(Vec3::ZERO),
|
||||
OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let at = press.unwrap_or(rect.center());
|
||||
let p = pivot_under(app, &proj, at);
|
||||
app.viewport.orbit_pivot = Some(p);
|
||||
p
|
||||
}
|
||||
}),
|
||||
};
|
||||
let Some(pivot) = pivot else {
|
||||
app.cam.orbit(dx, dy);
|
||||
return;
|
||||
}
|
||||
let pivot = match app.viewport.orbit_pivot {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let at = press.unwrap_or(rect.center());
|
||||
let p = pivot_under(app, &proj, at);
|
||||
app.viewport.orbit_pivot = Some(p);
|
||||
p
|
||||
}
|
||||
};
|
||||
orbit_about(app, rect, dx, dy, pivot);
|
||||
}
|
||||
|
||||
/// Orbit by pixel deltas, keeping `pivot` still on screen: the camera turns about it and a pan
|
||||
/// correction puts it back where it was.
|
||||
fn orbit_about(app: &mut SolveApp, rect: egui::Rect, dx: f64, dy: f64, pivot: Vec3) {
|
||||
let before = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
app.cam.orbit(dx, dy);
|
||||
let after = crate::viewport::projection(app, rect).to_screen(pivot);
|
||||
@@ -1043,9 +1080,11 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Orbit around"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, false, crate::i18n::tr_label("View centre"));
|
||||
ui.radio_value(&mut app.preferences.orbit_cursor, true, crate::i18n::tr_label("Point under the cursor"));
|
||||
// Wrapped: three choices, and German or Russian labels are long.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Origin, crate::i18n::tr_label("Origin"));
|
||||
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::ViewCentre, crate::i18n::tr_label("View centre"));
|
||||
ui.radio_value(&mut app.preferences.orbit_around, OrbitAround::Cursor, crate::i18n::tr_label("Point under the cursor"));
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(crate::i18n::tr_label("Middle double-click"));
|
||||
@@ -1305,12 +1344,22 @@ pub fn control(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Fold a setting an older release kept in its own field into the one this version reads.
|
||||
/// `orbit_cursor` (`true` = the point under the cursor) became `orbit_around`.
|
||||
pub fn migrate(mut p: Prefs) -> Prefs {
|
||||
if p.orbit_cursor {
|
||||
p.orbit_around = OrbitAround::Cursor;
|
||||
p.orbit_cursor = false;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Saved preferences, keeping every one that still reads. A value this version doesn't
|
||||
/// understand (a setting a newer or older release saved differently, a hand edit) falls back to
|
||||
/// its default on its own instead of discarding all of them.
|
||||
pub fn lenient(saved: &serde_json::Value) -> Prefs {
|
||||
if let Ok(p) = serde_json::from_value::<Prefs>(saved.clone()) {
|
||||
return p;
|
||||
return migrate(p);
|
||||
}
|
||||
let Ok(mut cur) = serde_json::to_value(Prefs::default()) else { return Prefs::default() };
|
||||
if let (Some(o), Some(src)) = (cur.as_object_mut(), saved.as_object()) {
|
||||
@@ -1324,7 +1373,7 @@ pub fn lenient(saved: &serde_json::Value) -> Prefs {
|
||||
}
|
||||
}
|
||||
}
|
||||
serde_json::from_value(cur).unwrap_or_default()
|
||||
serde_json::from_value(cur).map(migrate).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Set preferences from JSON (control channel); returns them all.
|
||||
@@ -1344,7 +1393,7 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result<serde_json::Valu
|
||||
{
|
||||
return Err("`color_vision` must be off, protan, deutan or tritan".into());
|
||||
}
|
||||
let new: Prefs = serde_json::from_value(cur).map_err(|e| e.to_string())?;
|
||||
let new: Prefs = migrate(serde_json::from_value(cur).map_err(|e| e.to_string())?);
|
||||
if !UNITS.contains(&new.default_units.as_str()) {
|
||||
return Err(format!("units must be one of {}", UNITS.join(", ")));
|
||||
}
|
||||
@@ -1659,6 +1708,29 @@ mod tests {
|
||||
assert!(!app.preferences.trackpad_pan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blender_preset_moves_the_camera_with_the_middle_button_only() {
|
||||
// #1090: orbit, pan and zoom all on the middle button; no left or right drag binding, so
|
||||
// those stay selection and the context menu (Ctrl+left on empty space orbits in every
|
||||
// preset, outside the bindings).
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
set(&mut app, &json!({"nav": "blender"})).unwrap();
|
||||
let none = Modifiers::default();
|
||||
let shift = Modifiers { shift: true, ..Default::default() };
|
||||
let ctrl = Modifiers { ctrl: true, ..Default::default() };
|
||||
assert_eq!(nav_mode(&app, NavButton::Middle, none), Some(NavMode::Orbit));
|
||||
assert_eq!(nav_mode(&app, NavButton::Middle, shift), Some(NavMode::Pan));
|
||||
assert_eq!(nav_mode(&app, NavButton::Middle, ctrl), Some(NavMode::Zoom));
|
||||
for mods in [none, shift, ctrl] {
|
||||
assert_eq!(nav_mode(&app, NavButton::Left, mods), None);
|
||||
assert_eq!(nav_mode(&app, NavButton::Right, mods), None);
|
||||
}
|
||||
assert_eq!(zoom_sign(&app), 1.0, "the wheel turns the usual way");
|
||||
let mut other = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
other.load_prefs(&app.prefs());
|
||||
assert_eq!(other.preferences.nav, "blender");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_map_the_mouse_and_prefs_round_trip() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
@@ -1684,7 +1756,7 @@ mod tests {
|
||||
assert_eq!(nav_mode(&app, NavButton::Left, none), None, "a plain left drag still selects");
|
||||
assert_eq!(nav_mode(&app, NavButton::Left, shift), None);
|
||||
assert_eq!(nav_mode(&app, NavButton::Right, none), Some(NavMode::Orbit));
|
||||
assert!(set(&mut app, &json!({"nav": "blender"})).is_err());
|
||||
assert!(set(&mut app, &json!({"nav": "no-such-preset"})).is_err());
|
||||
assert!(set(&mut app, &json!({"default_units": "furlong"})).is_err());
|
||||
assert!(set(&mut app, &json!({"nope": 1})).is_err());
|
||||
// Kept between runs.
|
||||
@@ -1802,11 +1874,47 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Orbiting about the world origin keeps the origin where it was drawn, even when Pan has
|
||||
/// moved it off to the side.
|
||||
#[test]
|
||||
fn orbiting_around_the_origin_keeps_it_still() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
app.fit_view();
|
||||
assert_eq!(app.preferences.orbit_around, OrbitAround::ViewCentre, "the view centre stays the default");
|
||||
app.preferences.orbit_around = OrbitAround::Origin;
|
||||
app.cam.pan(60.0, -25.0, 600.0);
|
||||
let was = (app.cam.yaw, app.cam.pitch);
|
||||
let before = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
|
||||
orbit(&mut app, rect, 40.0, 25.0, None);
|
||||
let after = crate::viewport::projection(&app, rect).to_screen(Vec3::ZERO).unwrap();
|
||||
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
|
||||
assert_ne!((app.cam.yaw, app.cam.pitch), was, "the view must turn");
|
||||
}
|
||||
|
||||
/// Preferences saved before the origin option had `orbit_cursor`: that choice survives, and
|
||||
/// the control channel still accepts the old key.
|
||||
#[test]
|
||||
fn saved_cursor_preference_migrates_to_the_origin_option() {
|
||||
assert_eq!(lenient(&json!({"orbit_cursor": true})).orbit_around, OrbitAround::Cursor);
|
||||
assert_eq!(lenient(&json!({"orbit_cursor": false})).orbit_around, OrbitAround::ViewCentre);
|
||||
assert_eq!(lenient(&json!({})).orbit_around, OrbitAround::ViewCentre);
|
||||
assert_eq!(lenient(&json!({"orbit_around": "origin"})).orbit_around, OrbitAround::Origin);
|
||||
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
set(&mut app, &json!({"orbit_around": "origin"})).unwrap();
|
||||
assert_eq!(app.preferences.orbit_around, OrbitAround::Origin);
|
||||
set(&mut app, &json!({"orbit_cursor": true})).unwrap();
|
||||
assert_eq!(app.preferences.orbit_around, OrbitAround::Cursor, "the old key still moves the choice");
|
||||
assert!(!app.preferences.orbit_cursor, "and is folded away");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbiting_around_the_cursor_keeps_that_point_still() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
|
||||
app.preferences.orbit_cursor = true;
|
||||
app.preferences.orbit_around = OrbitAround::Cursor;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
app.fit_view();
|
||||
let at = egui::pos2(500.0, 250.0);
|
||||
@@ -1822,7 +1930,7 @@ mod tests {
|
||||
fn orbiting_from_empty_space_turns_around_the_visible_geometry() {
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.run("solid.box", json!({"length": 40, "width": 40, "height": 40})).unwrap();
|
||||
app.preferences.orbit_cursor = true;
|
||||
app.preferences.orbit_around = OrbitAround::Cursor;
|
||||
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));
|
||||
app.fit_view();
|
||||
let corner = egui::pos2(10.0, 10.0);
|
||||
@@ -1835,7 +1943,7 @@ mod tests {
|
||||
assert_eq!(app.viewport.orbit_pivot, Some(centre));
|
||||
|
||||
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
|
||||
app.preferences.orbit_cursor = true;
|
||||
app.preferences.orbit_around = OrbitAround::Cursor;
|
||||
app.cam.target = solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0);
|
||||
orbit(&mut app, rect, 40.0, 25.0, Some(corner));
|
||||
assert_eq!(app.viewport.orbit_pivot, Some(solidcraft_engine::geom::Vec3::new(5.0, -3.0, 2.0)));
|
||||
|
||||
@@ -9,6 +9,24 @@ use solidcraft_engine::{Sel, Session};
|
||||
use crate::dialogs::{Dialog, Kind, apply_commands};
|
||||
use crate::{Services, SolveApp};
|
||||
|
||||
#[test]
|
||||
fn a_face_extrude_keeps_the_picked_body_when_faces_coincide() {
|
||||
let mut app = app_with(json!([
|
||||
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Other"}},
|
||||
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}
|
||||
]));
|
||||
let before = app.session.model.state();
|
||||
let pick = Sel::Face { body: "Wall".into(), index: 2, point: Vec3::new(40.0, 15.0, 10.0) };
|
||||
let d = start(&mut app, vec![pick], "solid.extrude");
|
||||
let (command, params) = apply_commands(&app, &d).unwrap().remove(0);
|
||||
assert_eq!(params["face_body"], json!("Wall"));
|
||||
app.session.execute(&command, ¶ms).unwrap();
|
||||
let after = app.session.model.state();
|
||||
assert_eq!(after.bodies.len(), 2);
|
||||
assert!(after.body("Other").unwrap().body.same(&before.body("Other").unwrap().body));
|
||||
assert!(after.body("Wall").unwrap().body.contains(Vec3::new(49.0, 15.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_plane_click_retains_source_and_previews_then_edits_in_place() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -41,6 +41,8 @@ fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() {
|
||||
let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option<f64>, Option<f64>, Value)> = vec![
|
||||
("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, None, json!({"center":[0,0],"radius":1})),
|
||||
("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, None, json!({"p0":[-1,0],"p1":[1,0]})),
|
||||
// #1358: the whole line, both sides of the middle.
|
||||
("sketch.line.midpoint", vec![[0., 0.]], [1., 0.5], None, None, json!({"mid":[0,0],"end":[1,0.5]})),
|
||||
("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})),
|
||||
("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})),
|
||||
(
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The Sweep and Surface Sweep dialogs' options (#771): orientation, twist and end scale reach
|
||||
//! the commands, and editing a sweep keeps them.
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs_surface::Surf;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{FeatureKind, SweepOrientation};
|
||||
use solidcraft_engine::geom::Vec3;
|
||||
|
||||
/// A square `Q` on XY and a path `P` 40 up the Z axis; the square's sketch id.
|
||||
fn setup(h: &mut Harness) -> u64 {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Q"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[-5,-5],"p1":[5,5]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.run("sketch.create", json!({"plane":"XZ","name":"P"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[0,-40]],"ids":["up"]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.doc.find_feature("Q").unwrap().id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_sweep_takes_its_options_from_the_dialog() {
|
||||
let mut h = Harness::new();
|
||||
let sketch = setup(&mut h);
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Twist").is_some() && h.widget("End Scale").is_some() && h.widget("Orientation").is_some());
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
|
||||
d.inputs[2].items = vec![Sel::Axis { name: "X".into() }];
|
||||
let Kind::Sweep { opts, .. } = &mut d.kind else { panic!("{:?}", d.kind) };
|
||||
opts.orientation = 2;
|
||||
opts.twist = "90 deg".into();
|
||||
opts.scale = "0.5".into();
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert_eq!(*orientation, SweepOrientation::Direction(Vec3::X));
|
||||
assert_eq!(twist.as_deref(), Some("90 deg"));
|
||||
assert_eq!(scale.as_deref(), Some("0.5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_sweep_from_the_dialog_stores_no_options() {
|
||||
let mut h = Harness::new();
|
||||
let sketch = setup(&mut h);
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"solid.sweep"}})).unwrap();
|
||||
h.frames(3);
|
||||
let d = h.app.dialog.as_mut().unwrap();
|
||||
d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
|
||||
d.inputs[1].items = vec![Sel::SketchCurve { id: "up".into() }];
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::Sweep { orientation, twist, scale, .. } = &f.kind else { panic!("{:?}", f.kind) };
|
||||
assert!(orientation.is_perpendicular() && twist.is_none() && scale.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_sweeps_keeps_their_options() {
|
||||
let mut h = Harness::new();
|
||||
setup(&mut h);
|
||||
h.app
|
||||
.run(
|
||||
"solid.sweep",
|
||||
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":[1,1,0],"twist":"30 deg","scale":"2","operation":"new","name":"Solid"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.app
|
||||
.run(
|
||||
"surface.sweep",
|
||||
json!({"sketch":"Q","path_sketch":"P","path":["up"],"orientation":"parallel","twist":"45 deg","scale":"0.5","name":"Sheet"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.frames(4);
|
||||
for name in ["Solid", "Sheet"] {
|
||||
let before = h.app.session.doc.find_feature(name).unwrap().clone();
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":name}})).unwrap();
|
||||
h.frames(8);
|
||||
match &h.app.dialog.as_ref().unwrap().kind {
|
||||
Kind::Sweep { opts, .. } | Kind::Surface(Surf::Sweep(opts)) => assert_eq!(opts.twist, if name == "Solid" { "30 deg" } else { "45 deg" }),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
// The options come back as they were (the profile pick is stored by its curves).
|
||||
let options = |k: &FeatureKind| match k {
|
||||
FeatureKind::Sweep { orientation, twist, scale, .. } | FeatureKind::SurfaceSweep { orientation, twist, scale, .. } => {
|
||||
(*orientation, twist.clone(), scale.clone())
|
||||
}
|
||||
k => panic!("{k:?}"),
|
||||
};
|
||||
let after = h.app.session.doc.find_feature(name).unwrap();
|
||||
assert_eq!(options(&after.kind), options(&before.kind), "{name}");
|
||||
assert_eq!(after.param_names, before.param_names, "{name}");
|
||||
}
|
||||
}
|
||||
@@ -586,6 +586,14 @@ pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option<f64>, rho: Opti
|
||||
("sketch.circle.two_point", [a]) => {
|
||||
sk.add_circle((*a + hover) * 0.5, a.dist(hover) * 0.5, None, None).ok()?;
|
||||
}
|
||||
// The first click is the middle: the line runs from the cursor's mirror image through
|
||||
// it to the cursor (#1358), not from the middle to the cursor.
|
||||
("sketch.line.midpoint", [m]) => {
|
||||
if m.dist(hover) <= 1e-9 {
|
||||
return None;
|
||||
}
|
||||
sk.add_line(*m * 2.0 - hover, hover, None, None, None).ok()?;
|
||||
}
|
||||
("sketch.circle.three_point" | "sketch.arc.three_point", [a, b]) => {
|
||||
let ab = *b - *a;
|
||||
let ah = hover - *a;
|
||||
|
||||
@@ -19,6 +19,10 @@ use crate::{SolveApp, icons};
|
||||
#[path = "viewport_appearance_tests.rs"]
|
||||
mod appearance_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_removed_later_tests.rs"]
|
||||
mod removed_later_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_plane_target_tests.rs"]
|
||||
mod plane_target_tests;
|
||||
@@ -2300,16 +2304,22 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
if let Some((mode, delta)) = motion {
|
||||
if delta != egui::Vec2::ZERO {
|
||||
let mut params = json!({"motion":if mode == NavMode::Pan {"pan"} else {"orbit"},"delta":[delta.x,delta.y]});
|
||||
if mode == NavMode::Orbit && app.preferences.orbit_cursor {
|
||||
let pivot = match app.viewport.orbit_pivot {
|
||||
Some(pivot) => pivot,
|
||||
None => {
|
||||
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
|
||||
app.viewport.orbit_pivot = Some(pivot);
|
||||
pivot
|
||||
}
|
||||
if mode == NavMode::Orbit {
|
||||
let pivot = match app.preferences.orbit_around {
|
||||
crate::prefs::OrbitAround::ViewCentre => None,
|
||||
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
|
||||
crate::prefs::OrbitAround::Cursor => Some(match app.viewport.orbit_pivot {
|
||||
Some(pivot) => pivot,
|
||||
None => {
|
||||
let pivot = crate::prefs::pivot_under(app, &projection(app, rect), hover.unwrap_or(rect.center()) - delta);
|
||||
app.viewport.orbit_pivot = Some(pivot);
|
||||
pivot
|
||||
}
|
||||
}),
|
||||
};
|
||||
params["pivot"] = json!(pivot);
|
||||
if let Some(pivot) = pivot {
|
||||
params["pivot"] = json!(pivot);
|
||||
}
|
||||
}
|
||||
camera_motion(app, rect, params);
|
||||
}
|
||||
@@ -3341,7 +3351,17 @@ fn orbit_drag(app: &mut SolveApp, rect: Rect, dx: f64, dy: f64, press: Option<Po
|
||||
target.pitch = previous.target.pitch;
|
||||
}
|
||||
}
|
||||
let pivot = app.viewport.orbit_pivot.filter(|_| around_cursor).and_then(|p| {
|
||||
// The cursor and origin pivots stay still while a snap eases in; the axis gizmo has no
|
||||
// pivot (it orbits about the view centre, see `axis_view`).
|
||||
let pivot = if around_cursor {
|
||||
match app.preferences.orbit_around {
|
||||
crate::prefs::OrbitAround::Origin => Some(Vec3::ZERO),
|
||||
_ => app.viewport.orbit_pivot,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.and_then(|p| {
|
||||
app.cam = displayed;
|
||||
projection(app, rect).to_screen(p).map(|at| (p, at))
|
||||
});
|
||||
@@ -4425,7 +4445,7 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = hover_app(false);
|
||||
app.preferences.nav = "touchpad".into();
|
||||
app.preferences.orbit_cursor = true;
|
||||
app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
|
||||
let p = pos2(350.0, 300.0);
|
||||
let alt = egui::Modifiers { alt: true, ..Default::default() };
|
||||
hover_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]);
|
||||
@@ -5495,10 +5515,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The origin pivot holds through a Shift drag that snaps to an axis: the spring eases the
|
||||
/// view round without letting the origin slide on screen.
|
||||
#[test]
|
||||
fn snapping_keeps_the_origin_still() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Origin;
|
||||
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
|
||||
h.app.fit_view();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
let at = rect.center() + vec2(50.0, -20.0);
|
||||
orbit_drag(&mut h.app, rect, 0.0, 0.0, Some(at), true, true);
|
||||
assert_eq!(
|
||||
h.app.viewport.orbit_snap.as_ref().and_then(|s| s.pivot).map(|(p, _)| p),
|
||||
Some(Vec3::ZERO),
|
||||
"the origin is the pivot while snapping"
|
||||
);
|
||||
let before = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
|
||||
for _ in 0..30 {
|
||||
orbit_drag(&mut h.app, rect, 4.0, 2.0, Some(at), true, true);
|
||||
step_orbit_snap(&mut h.app, rect, 1.0 / 60.0);
|
||||
let after = projection(&h.app, rect).to_screen(Vec3::ZERO).unwrap();
|
||||
assert!(after.distance(before) < 2.0, "{before:?} -> {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_orbit_keeps_cursor_pivot_and_default_middle_pan() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.preferences.orbit_cursor = true;
|
||||
h.app.preferences.orbit_around = crate::prefs::OrbitAround::Cursor;
|
||||
h.app.run("solid.box", json!({"length":40,"width":40,"height":40})).unwrap();
|
||||
h.app.fit_view();
|
||||
let rect = h.app.viewport.rect.unwrap();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Editing a sketch rolls the timeline back to it; a body deleted after the sketch is not drawn
|
||||
//! then (#1167), and is back in neither the viewport nor the browser's Bodies folder.
|
||||
|
||||
use super::build_scene;
|
||||
use crate::{Services, SolveApp};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
/// `Kept` and `Gone` blocks, a sketch `Edited` made after both, and `Gone` deleted after it.
|
||||
fn app() -> SolveApp {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("Kept", 0.0), ("Gone", 20.0)] {
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "name": format!("{name}Sketch")})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, 0], "p1": [x + 10.0, 10]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.extrude", &json!({"sketch": format!("{name}Sketch"), "distance": 5, "body_name": name})).unwrap();
|
||||
}
|
||||
s.execute("sketch.create", &json!({"plane": "XY", "offset": "30 mm", "name": "Edited"})).unwrap();
|
||||
s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [5, 5]})).unwrap();
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
s.execute("solid.remove", &json!({"bodies": ["Gone"]})).unwrap();
|
||||
let mut app = SolveApp::new(s, Services::default());
|
||||
app.ui.ground_shadow = false;
|
||||
app.ui.show_origin = false;
|
||||
app
|
||||
}
|
||||
|
||||
fn triangles(app: &SolveApp) -> usize {
|
||||
let scene = build_scene(app).merged();
|
||||
scene.tris.len() + scene.pbr.len() + scene.trans.len() + scene.glass.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_deleted_later_is_not_drawn_while_a_sketch_is_edited() {
|
||||
let mut app = app();
|
||||
let drawn = triangles(&app);
|
||||
assert!(drawn > 0, "the kept block is drawn");
|
||||
let sketch = app.session.doc.find_feature("Edited").map(|f| f.id).unwrap();
|
||||
app.edit_sketch(sketch);
|
||||
assert_eq!(app.session.active_sketch, Some(sketch));
|
||||
assert!(app.session.model.state().body("Gone").is_some(), "the rolled-back history has it");
|
||||
assert_eq!(triangles(&app), drawn, "only the kept block is drawn");
|
||||
app.finish_sketch();
|
||||
assert_eq!(triangles(&app), drawn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bodies_folder_leaves_out_a_body_deleted_later() {
|
||||
let mut app = app();
|
||||
let sketch = app.session.doc.find_feature("Edited").map(|f| f.id).unwrap();
|
||||
let names = |app: &SolveApp| crate::browser::body_rows(app).into_iter().collect::<Vec<_>>();
|
||||
assert_eq!(names(&app), ["Kept"]);
|
||||
app.edit_sketch(sketch);
|
||||
assert_eq!(names(&app), ["Kept"], "the body is not listed with an eye that cannot show it");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! The Parameters dialog's ▲ / ▼ buttons reorder user parameters (#1187).
|
||||
use serde_json::json;
|
||||
use solidcraft_ui_egui::scenario::Harness;
|
||||
|
||||
fn order(h: &mut Harness) -> Vec<String> {
|
||||
let v = h.app.session.execute("parameters.list", &json!({"filter": "user"})).unwrap();
|
||||
v["parameters"].as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_move_user_parameters_up_and_down() {
|
||||
let mut h = Harness::new();
|
||||
for (n, e) in [("a", "1 mm"), ("b", "2 mm"), ("c", "3 mm")] {
|
||||
h.app.session.execute("parameters.add", &json!({"name": n, "expression": e})).unwrap();
|
||||
}
|
||||
h.step(&json!({"widget":"Parameters"})).unwrap();
|
||||
assert!(h.app.params.is_some());
|
||||
h.frames(3);
|
||||
h.run(&json!([{"click":{"handle":"param:after:a"}}])).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(order(&mut h), ["b", "a", "c"]);
|
||||
h.run(&json!([{"click":{"handle":"param:before:c"}}])).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(order(&mut h), ["b", "c", "a"]);
|
||||
// The first row cannot move up, the last cannot move down.
|
||||
h.run(&json!([{"click":{"handle":"param:before:b"}}, {"click":{"handle":"param:after:a"}}])).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(order(&mut h), ["b", "c", "a"]);
|
||||
assert!(h.app.params.as_ref().unwrap().error.is_none());
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
{"note": "Alias preset (#304): Shift+Alt+left drag orbits, Shift+Alt+middle pans, Shift+Alt+right zooms (down: out, up: in). Other presets don't navigate with Shift+Alt+left."},
|
||||
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}},
|
||||
{"call": "ui.prefs", "params": {"nav": "alias"}},
|
||||
{"note": "The orbit centre is its own preference, and the origin and cursor pivots move the camera's target: pin the view centre (the default) here so `panned` measures the binding, not the pivot."},
|
||||
{"call": "ui.prefs", "params": {"orbit_around": "view_centre"}},
|
||||
{"expect": {"prefs": {"nav": "alias"}}},
|
||||
|
||||
{"note": "Orbit: like a right drag (from the drag threshold on), and Shift (part of the binding) doesn't snap."},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{"note":"#226: a cap created before a wall touches only the edge of the wall's outward extrusion. Picking the wall face in Extrude must extend Wall and preserve Cap as a separate body."},
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"}}},
|
||||
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}},
|
||||
{"call":"ui.camera","params":{"target":[20,15,12.5],"yaw":-0.785398,"pitch":0.61548,"distance":110}},
|
||||
{"start":"solid.extrude"},
|
||||
{"click":{"world":[40,15,10]}},
|
||||
{"text":"10"},
|
||||
{"key":"Enter"},
|
||||
{"expect":{"dialog":null,"bodies":2,"errors":0,"body_volume":{"body":"Wall","volume":30000},"bbox":{"body":"Wall","min":[0,0,0],"max":[50,30,20]}}},
|
||||
{"expect":{"body_volume":{"body":"Cap","volume":6000},"bbox":{"body":"Cap","min":[0,0,20],"max":[40,30,25]}}},
|
||||
{"shot":"face_join_keeps_cap"}
|
||||
]
|
||||
@@ -28,6 +28,13 @@ made. Contact sets stop motion where bodies would pass through each other,
|
||||
**Motion Study** keys joint values along a timeline, and **Interference** shows overlapping
|
||||
volumes in red.
|
||||
|
||||
A joint moves one component against another, so its two picks must be on different components.
|
||||
A STEP file with several parts often comes in as bodies of one component, and picks on two of
|
||||
those bodies make no joint. The Joint and As-built Joint dialogs say so under the picks and offer
|
||||
**Create Components from Bodies**: each picked body gets a component of its own and stays where it
|
||||
is, the picks are kept, and the joint previews. `joint.create` refuses such origins with the same
|
||||
advice.
|
||||
|
||||
Joints may close a loop, as in a four-bar linkage or a slider-crank. When one joint of the loop
|
||||
is driven, the other joints in the loop follow so that every joint still meets, and the
|
||||
mechanism stays in the assembly mode it started in (an open four-bar does not flip to the
|
||||
|
||||
@@ -185,13 +185,51 @@ consume an undo step.
|
||||
{"command":"sheet.export_dxf","params":{"path":"bracket-flat.dxf"}}
|
||||
```
|
||||
|
||||
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres. The `OUTLINE`, `BEND_UP` and
|
||||
`BEND_DOWN` layers separate cuts from bend centres. A round hole is a `CIRCLE`, a circular arc an
|
||||
`ARC`, a whole ellipse one `ELLIPSE` and an arc of an ellipse an elliptical `ELLIPSE`; the pieces
|
||||
of a spline, the outlines of text and the other conics are `SPLINE`s of their own degree, so no
|
||||
curve is written as chords. For a machine that reads only lines and arcs, pass
|
||||
`"curves": "arcs"` to `sheet.export_dxf`: the free-form curves are then tangent arcs and lines
|
||||
within 0.001 mm.
|
||||
The DXF is an AutoCAD 2000 (`AC1015`) drawing in millimetres, made for a laser-cutting shop's
|
||||
nesting or CAM software: each kind of line is on a layer of its own, with a colour (AutoCAD's
|
||||
index) and, for the bends, a linetype that the file defines.
|
||||
|
||||
| Layer | Colour | Line | Holds |
|
||||
|---|---|---|---|
|
||||
| `OUTLINE` | 7 white or black | continuous | the outer contour of each piece of the sheet |
|
||||
| `CUT` | 4 cyan | continuous | what is cut out inside it: holes, slots, and the loose pieces a cut leaves (the middle of a ring) |
|
||||
| `TEXT` | 2 yellow | continuous | the letters that text cut through the sheet, with their counters; only with `"text": "cut"`, the default |
|
||||
| `ETCH` | 6 magenta | continuous | letters as single contours, to be marked and not cut: `"text": "etch"`, and always text machined to a depth |
|
||||
| `BEND_UP`, `BEND_DOWN` | 1 red, 5 blue | `DASHED` | the bend centre lines, by direction |
|
||||
| `BEND_NOTES` | 8 grey | continuous | the note beside each bend line, only with `"bend_notes": true` |
|
||||
| `POCKET_…` | 3 green | continuous | pockets, a layer for each depth (see below) |
|
||||
| `PERFORATION` | 3 green | continuous | the holes of a perforation field |
|
||||
|
||||
`OUTLINE`, `CUT`, `BEND_UP` and `BEND_DOWN` are always in the layer table, the others when the
|
||||
sheet has something for them. The inner contours (`CUT`, `TEXT`) are written before the outline,
|
||||
so a program that follows the file's order cuts inside-out.
|
||||
|
||||
A round hole is a `CIRCLE`, a circular arc an `ARC`, a whole ellipse one `ELLIPSE` and an arc of
|
||||
an ellipse an elliptical `ELLIPSE`; the pieces of a spline, the outlines of text and the other
|
||||
conics are `SPLINE`s of their own degree, so no curve is written as chords. For a machine that
|
||||
reads only lines and arcs, pass `"curves": "arcs"` to `sheet.export_dxf`: the free-form curves
|
||||
are then tangent arcs and lines within 0.001 mm.
|
||||
|
||||
Letters are told from other cut-outs by where they came from: a cut whose sketch holds text
|
||||
(Sketch Text) marks the cut-outs it makes as letters, counters included, while the design is
|
||||
built; nothing more is saved in the design file. A letter that another cut-out overlaps, or
|
||||
that the sheet's edge clips, has become one shape with it and stays on `CUT`.
|
||||
|
||||
```json
|
||||
{"command":"sheet.export_dxf","params":{"path":"tray-flat.dxf","text":"etch","bend_notes":true}}
|
||||
```
|
||||
|
||||
With `"bend_notes": true` each bend line gets a note on `BEND_NOTES`, `B1 UP 90° R1` (the bend's
|
||||
number, its direction, its angle and its inner radius; `%%d` is AutoCAD's degree sign). It is 3 mm
|
||||
high (`"note_height"` changes that), centred half a height beside the middle of the line,
|
||||
parallel to it and readable from below or from the right. A bend that cut-outs split has a note
|
||||
for each piece. Notes are off by default, because a program that cuts or marks every layer would
|
||||
mark them.
|
||||
|
||||
A design with several sheet bodies exports one file for each with `"per_body": true`: the files
|
||||
are `<path>-<body>.dxf` (characters a file name cannot hold become `_`; two bodies that would
|
||||
share a name get `_2`, `_3`…), and the reply lists them in `files`. Without a `path` it lists
|
||||
their DXF texts instead.
|
||||
|
||||
Base-flange profiles keep their interior holes. Through cuts that cross a sheet edge become
|
||||
notches in the flat outline; overlapping and repeated cuts remove their combined area once.
|
||||
@@ -206,6 +244,10 @@ exactly (`cutout_shapes`): a round hole as `{"circle": {"center", "radius"}}`, a
|
||||
|
||||
Cuts can leave separate material pieces, including the centre of a ring cut. These pieces
|
||||
remain one logical sheet group through Unfold / Refold and export as separate flat outlines.
|
||||
A cut that parts a piece from the base (a flange cut off along its bend, the part of a ring's
|
||||
inside on a flange's edge) keeps it too, folded or flat, and its warning says how many pieces
|
||||
are no longer joined to the base, their area and where they lie in the flat pattern. Islands
|
||||
inside other material (a ring's centre, a letter's counter) are not counted.
|
||||
Large profiles that exceed the geometry processing budget report an error instead of
|
||||
exporting a partial outline.
|
||||
|
||||
@@ -225,7 +267,8 @@ become a through hole wherever they remove the whole thickness. Flat-pattern JSO
|
||||
`pockets` with their side, depth and bottom/top thickness coordinates in millimetres.
|
||||
DXF pocket boundaries use `POCKET_BOTTOM_Z…_TO_…_MM`, `POCKET_TOP_Z…_TO_…_MM`, or
|
||||
`POCKET_INTERNAL_Z…_TO_…_MM` layers. These are machining operations, so do not treat every
|
||||
pocket layer as a through-cut contour when importing the drawing into a CAM tool.
|
||||
pocket layer as a through-cut contour when importing the drawing into a CAM tool. (Text
|
||||
machined to a depth is a marking, on `ETCH`.)
|
||||
|
||||
Extrude Intersect on a straight base or an unfolded sheet retains the material inside both
|
||||
the current tool footprint and its depth interval, including interior holes, detached islands
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
Esc or a click elsewhere closes the list. Clicking again on the same spot, with nothing changed
|
||||
in between, selects the next thing in that list. The
|
||||
wheel zooms at the cursor, the middle button pans and right-drag orbits freely (the mouse
|
||||
preset in Preferences switches to SolidWorks, Inventor, Alias, Tinkercad, OpenSCAD or Touchpad style). On a
|
||||
preset in Preferences switches to SolidWorks, Inventor, Alias, Blender, Tinkercad, OpenSCAD or Touchpad style). On a
|
||||
trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans;
|
||||
off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways.
|
||||
Shift+middle orbits freely
|
||||
|
||||
@@ -132,7 +132,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o
|
||||
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
|
||||
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
|
||||
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_cursor`, `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `blender`, `tinkercad`, `openscad`, `touchpad`), `zoom_reverse`, `zoom_to_cursor`, `smooth_wheel_zoom` (default true), `trackpad_pan` (default true: two-finger scroll pans, except Touchpad always zooms; pinch zooms), `orbit_around` (`view_centre` (default: what Pan moves and Fit frames), `origin` (the world origin stays still on screen), `cursor` (the model point under the pointer when the drag began); the older `orbit_cursor` boolean still sets `cursor` when true), `look_at_anim`, `look_at_speed`, `middle_double_click` (`home` (default), `fit`, `off`), `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `language` (`en`, `ja`, `de`, `fr`, `ko`, `ru`, `uk`, `zh`; see [localization](localization.md)), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`, `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778), `check_updates` (default true; [updates](book/src/guide/updates.md)); `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` |
|
||||
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
|
||||
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
|
||||
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
|
||||
|
||||
+7
-5
@@ -1,6 +1,6 @@
|
||||
# Features
|
||||
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **365 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **367 commands.** Planned (registered, not built yet): 4. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
|
||||
|
||||
## Sketch (99)
|
||||
|
||||
@@ -315,7 +315,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Measure (I) | `inspect.measure` | SOLID › INSPECT |
|
||||
| Section Analysis | `inspect.section` | SOLID › INSPECT |
|
||||
|
||||
## Files (7)
|
||||
## Files (8)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -325,7 +325,8 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Save (Ctrl+S) | `file.save` | – |
|
||||
| Save As | `file.save_as` | – |
|
||||
| Export | `file.export` | – |
|
||||
| Save As Mesh | `file.save_mesh` | – |
|
||||
| Save As Mesh | `file.save_mesh` | MESH › MODIFY |
|
||||
| Installed Slicers | `file.slicers` | – |
|
||||
|
||||
## Documents (4)
|
||||
|
||||
@@ -462,7 +463,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
| Convert Mesh | `mesh.convert` | MESH › MODIFY |
|
||||
| Convert Mesh | `mesh.convert` | – |
|
||||
|
||||
## `model.*` (3)
|
||||
|
||||
@@ -491,7 +492,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Move Occurrence | `occurrence.move` | – |
|
||||
| Ground | `occurrence.ground` | – |
|
||||
|
||||
## `parameters.*` (12)
|
||||
## `parameters.*` (13)
|
||||
|
||||
| Command | Id | Toolbar |
|
||||
|---|---|---|
|
||||
@@ -501,6 +502,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
|
||||
| Rename Parameter | `parameters.rename` | – |
|
||||
| Delete Parameter | `parameters.delete` | – |
|
||||
| Favourite Parameter | `parameters.favorite` | – |
|
||||
| Move Parameter | `parameters.move` | – |
|
||||
| Comment Parameter | `parameters.comment` | – |
|
||||
| Parameter Users | `parameters.users` | – |
|
||||
| Parameter Graph | `parameters.graph` | – |
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
|
||||
| `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. |
|
||||
| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. |
|
||||
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
|
||||
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
|
||||
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
|
||||
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
|
||||
|
||||
@@ -738,7 +738,7 @@ def main():
|
||||
mcp.send("notifications/initialized", notify=True)
|
||||
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed, a.minimum_caption)
|
||||
# The pi story's camera moves orbit about the point under the pointer (Preferences > Navigation).
|
||||
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_cursor": a.story.startswith("pi"), "button": "apply"})
|
||||
d.ui("ui.prefs", {"language": "en", "theme": "dark", "ui_scale": 1, "nav": "fusion", "orbit_around": "cursor" if a.story.startswith("pi") else "view_centre", "button": "apply"})
|
||||
d.ui("ui.resize", {"width": 1920, "height": 1080})
|
||||
d.ui("ui.home", {"open": False})
|
||||
d.ui("ui.documents", {"action": "new"})
|
||||
|
||||
@@ -129,7 +129,7 @@ The same pattern moved the solar wings of the SELENE rover (`Rover-v7.solidcraft
|
||||
| A linked joint stops following | Its value is clamped to its own limits. Hinge 2 in the example has `[[-60, 0]]`, so a drive of Hinge below 0 leaves it at 0. Give a mirrored joint the mirrored limits. |
|
||||
| A drive stops short, `stopped_by_contact` | Two bodies of a contact set touch. `fraction` is how far the move got. |
|
||||
| `occurrence.ground` says there is no occurrence `Frame` | It takes occurrence names only: `Frame:1`. |
|
||||
| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. |
|
||||
| `a joint needs two different occurrences` | Both origins found the same part. Give `occurrence` on both. If the parts are bodies of one component (an imported STEP file often brings them so), run `component.from_bodies {"bodies": [...]}` first. |
|
||||
| "unknown command" for `file.open` | Open a design with the `open` tool or `doc.open {path}`. |
|
||||
| Rigid hoses, cables or springs drift as the mechanism moves | Bodies don't bend. Hide them with `browser.visibility`, or model them per link. |
|
||||
| `solidcraft-cli snapshot SCRIPT --in design` runs on an empty design | `snapshot` ignores `--in`. Run `solidcraft-cli run SCRIPT --in design --save out.solidcraft` first. |
|
||||
|
||||
@@ -11620,6 +11620,63 @@
|
||||
"Body1/v[F6:end & F6:side:l7 & F6:side:l8]": "vertex.plane_three_points vertex.point",
|
||||
"design": "load"
|
||||
},
|
||||
"typical/multi-body-face-extrude": {
|
||||
"Cap/F1:+x": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Cap/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+x|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+y": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Cap/F1:+y|F1:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+y|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+z": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Cap/F1:+z|F1:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:+z|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:-x": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Cap/F1:-x|F1:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:-x|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:-y": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Cap/F1:-y|F1:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Cap/F1:-z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.offset_plane !face.press_pull face.shell face.sketch",
|
||||
"Cap/body": "body.copy body.mirror body.move body.pattern",
|
||||
"Cap/v[F1:+x & F1:+y & F1:+z]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+x & F1:+y & F1:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+x & F1:+z & F1:-y]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+x & F1:-y & F1:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+y & F1:+z & F1:-x]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+y & F1:-x & F1:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:+z & F1:-x & F1:-y]": "vertex.plane_three_points vertex.point",
|
||||
"Cap/v[F1:-x & F1:-y & F1:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/F2:+x": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Wall/F2:+x|F2:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+x|F2:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+x|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+x|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+y": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Wall/F2:+y|F2:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+y|F2:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+y|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.midplane face.offset_plane !face.press_pull face.shell face.sketch",
|
||||
"Wall/F2:+z|F2:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:+z|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:-x": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Wall/F2:-x|F2:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:-x|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:-y": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Wall/F2:-y|F2:-z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project",
|
||||
"Wall/F2:-z": "face.angle_plane face.draft_neutral face.extrude face.hole face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Wall/body": "body.copy body.mirror body.move body.pattern",
|
||||
"Wall/v[F2:+x & F2:+y & F2:+z]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+x & F2:+y & F2:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+x & F2:+z & F2:-y]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+x & F2:-y & F2:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+y & F2:+z & F2:-x]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+y & F2:-x & F2:-z]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:+z & F2:-x & F2:-y]": "vertex.plane_three_points vertex.point",
|
||||
"Wall/v[F2:-x & F2:-y & F2:-z]": "vertex.plane_three_points vertex.point",
|
||||
"design": "load"
|
||||
},
|
||||
"typical/multi-body-join-cut": {
|
||||
"Block/F1:+x": "face.angle_plane !face.draft_neutral face.extrude face.extrude+edit face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch",
|
||||
"Block/F1:+x|F1:+y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.normal_plane+edit edge.project",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"description": "Issue #226: the upper cap precedes the wall in body order and touches only the edge of a 10 mm outward extrusion from the wall's x=40 face.",
|
||||
"commands": [
|
||||
{"command":"solid.box","params":{"corner":[0,0,20],"length":40,"width":30,"height":5,"body_name":"Cap"}},
|
||||
{"command":"solid.box","params":{"length":40,"width":30,"height":20,"body_name":"Wall"}}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user