Patch: G1/G2 to the faces beside the loop, general and N-sided loops, points (#759) (#1521)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 23:07:24 -04:00
1 parent 77bdcc47e9
commit fac3f96bdd
24 files changed
+2688 -77

No files matched your search

+2
View File
@@ -53,6 +53,8 @@ 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 of any shape and number of sides as one face, G0/G1/G2 to the faces beside each edge, through points, #759), 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), 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 |
| 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), gears, rack and pinion and screws (motion links whose ratio follows picked radii, parameters or a pitch, also inside closed loops), 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, Gear, Rack and Pinion, Screw 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 |
+6
View File
@@ -8,14 +8,17 @@
"Area": "Fläche",
"BROWSER": "BROWSER",
"Bends": "Biegungen",
"Boundary": "Begrenzung",
"Centre to centre": "Mitte zu Mitte",
"Circumference": "Umfang",
"Construction": "Konstruktionslinie",
"Continuity": "Stetigkeit",
"Copy Error Message": "Fehlermeldung kopieren",
"Copy Warning Message": "Warnmeldung kopieren",
"Corner Relief": "Eckenentlastung",
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
"Current perspective view": "Aktuelle perspektivische Ansicht",
"Curvature (G2)": "Krümmung (G2)",
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
"Drive Joint": "Gelenk antreiben",
"Edges": "Kanten",
@@ -42,7 +45,9 @@
"Notch": "Ausklinkung",
"Outside": "Außen",
"Overall length": "Gesamtlänge",
"Pass through": "Durch Punkte",
"Picked": "Ausgewählte",
"Position (G0)": "Position (G0)",
"Profiles/Curves": "Profile/Kurven",
"Projection": "Projektion",
"RULED SURFACE": "REGELFLÄCHE",
@@ -62,6 +67,7 @@
"Sketch geometry and profiles": "Skizzengeometrie und Profile",
"Sketch points": "Skizzenpunkte",
"Slot width": "Langlochbreite",
"Tangent (G1)": "Tangente (G1)",
"Tear": "Reißen",
"Thickness Outside": "Dicke außen",
"Thin": "Dünnwandig",
+6
View File
@@ -34,6 +34,7 @@
"Bend Radius": "Bend Radius",
"Bends": "Bends",
"Body": "Body",
"Boundary": "Boundary",
"Bounding box": "Bounding box",
"CSV or JSON: updates existing parameters, adds new ones": "CSV or JSON: updates existing parameters, adds new ones",
"Capture All Positions": "Capture All Positions",
@@ -56,6 +57,7 @@
"Configuration rules": "Configuration rules",
"Construction": "Construction",
"Contact": "Contact",
"Continuity": "Continuity",
"Copy Error Message": "Copy Error Message",
"Copy Warning Message": "Copy Warning Message",
"Copy all licences": "Copy all licences",
@@ -70,6 +72,7 @@
"Cube": "Cube",
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
"Current perspective view": "Current perspective view",
"Curvature (G2)": "Curvature (G2)",
"Customize Toolbar": "Customize Toolbar",
"Delete all": "Delete all",
"Delete rule": "Delete rule",
@@ -159,6 +162,7 @@
"Outside": "Outside",
"Overall length": "Overall length",
"Parameter values": "Parameter values",
"Pass through": "Pass through",
"Path": "Path",
"Per Edge": "Per Edge",
"Pick the tool bodies": "Pick the tool bodies",
@@ -167,6 +171,7 @@
"Playback": "Playback",
"Position": "Position",
"Position (0 to 1)": "Position (0 to 1)",
"Position (G0)": "Position (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Position along the edge, 0 at the start end, 1 at the other",
"Preview Row": "Preview Row",
"Preview leaves the current design unchanged.": "Preview leaves the current design unchanged.",
@@ -239,6 +244,7 @@
"Style": "Style",
"Suppressed": "Suppressed",
"Swap Ends": "Swap Ends",
"Tangent (G1)": "Tangent (G1)",
"Tangent Chain": "Tangent Chain",
"Tear": "Tear",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "The dashed sketch is a saved plane guide. Select a new face to repair the attachment.",
+6
View File
@@ -32,6 +32,7 @@
"Bend Radius": "Rayon de pliage",
"Bends": "Plis",
"Body": "Corps",
"Boundary": "Contour",
"Bounding box": "Boîte englobante",
"CSV or JSON: updates existing parameters, adds new ones": "CSV ou JSON : met à jour les paramètres existants, ajoute les nouveaux",
"Capture All Positions": "Capturer toutes les positions",
@@ -52,6 +53,7 @@
"Configuration rules": "Règles de configuration",
"Construction": "Construction",
"Contact": "Contact",
"Continuity": "Continuité",
"Copy Error Message": "Copier le message d'erreur",
"Copy Warning Message": "Copier le message d'avertissement",
"Copy all licences": "Copier toutes les licences",
@@ -64,6 +66,7 @@
"Countersink angle": "Angle de la fraisure",
"Create Issue — review draft": "Créer un ticket — relire le brouillon",
"Cube": "Cube",
"Curvature (G2)": "Courbure (G2)",
"Customize Toolbar": "Personnaliser la barre d'outils",
"Delete all": "Tout supprimer",
"Delete rule": "Supprimer la règle",
@@ -143,6 +146,7 @@
"Other side": "Autre côté",
"Outside": "Extérieur",
"Parameter values": "Valeurs des paramètres",
"Pass through": "Passer par",
"Path": "Trajectoire",
"Per Edge": "Par arête",
"Pick the tool bodies": "Choisir les corps outils",
@@ -151,6 +155,7 @@
"Playback": "Lecture",
"Position": "Position",
"Position (0 to 1)": "Position (0 à 1)",
"Position (G0)": "Position (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Position le long de l'arête : 0 à l'extrémité de départ, 1 à l'autre",
"Preview Row": "Aperçu de la ligne",
"Preview leaves the current design unchanged.": "L'aperçu ne modifie pas la conception actuelle.",
@@ -221,6 +226,7 @@
"Style": "Style",
"Suppressed": "Désactivé",
"Swap Ends": "Permuter les extrémités",
"Tangent (G1)": "Tangence (G1)",
"Tangent Chain": "Chaîne tangente",
"Tear": "Déchirure",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "L'esquisse en pointillés est un guide de plan enregistré. Sélectionnez une nouvelle face pour réparer le rattachement.",
+6
View File
@@ -34,6 +34,7 @@
"Bend Radius": "曲げ半径",
"Bends": "曲げ",
"Body": "ボディ",
"Boundary": "境界",
"Bounding box": "境界ボックス",
"CSV or JSON: updates existing parameters, adds new ones": "CSVまたはJSON:既存のパラメータを更新し、新しいパラメータを追加します",
"Capture All Positions": "すべての位置をキャプチャ",
@@ -56,6 +57,7 @@
"Configuration rules": "構成ルール",
"Construction": "コンストラクション",
"Contact": "接触",
"Continuity": "連続性",
"Copy Error Message": "エラーメッセージをコピー",
"Copy Warning Message": "警告メッセージをコピー",
"Copy all licences": "すべてのライセンスをコピー",
@@ -70,6 +72,7 @@
"Cube": "キューブ",
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
"Current perspective view": "現在の透視投影",
"Curvature (G2)": "曲率 (G2)",
"Customize Toolbar": "ツールバーのカスタマイズ",
"Delete all": "すべて削除",
"Delete rule": "ルールを削除",
@@ -159,6 +162,7 @@
"Outside": "外側",
"Overall length": "全長",
"Parameter values": "パラメータ値",
"Pass through": "通過点",
"Path": "パス",
"Per Edge": "エッジごと",
"Pick the tool bodies": "ツールボディを選択",
@@ -167,6 +171,7 @@
"Playback": "再生",
"Position": "位置",
"Position (0 to 1)": "位置 (0〜1)",
"Position (G0)": "位置 (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "エッジ上の位置(始点が0、終点が1)",
"Preview Row": "行をプレビュー",
"Preview leaves the current design unchanged.": "プレビューは現在のデザインを変更しません。",
@@ -239,6 +244,7 @@
"Style": "スタイル",
"Suppressed": "抑制",
"Swap Ends": "端点を入れ替え",
"Tangent (G1)": "接線 (G1)",
"Tangent Chain": "正接チェーン",
"Tear": "引き裂き",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "破線のスケッチは保存された平面ガイドです。新しい面を選択してアタッチメントを修復してください。",
+6
View File
@@ -8,14 +8,17 @@
"Area": "면적",
"BROWSER": "브라우저",
"Bends": "벤딩",
"Boundary": "경계",
"Centre to centre": "중심 간",
"Circumference": "원주",
"Construction": "구성선",
"Continuity": "연속성",
"Copy Error Message": "오류 메시지 복사",
"Copy Warning Message": "경고 메시지 복사",
"Corner Relief": "모서리 릴리프",
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
"Current perspective view": "현재 원근 투영 뷰",
"Curvature (G2)": "곡률 (G2)",
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
"Drive Joint": "조인트 구동",
"Edges": "모서리",
@@ -42,7 +45,9 @@
"Notch": "노치",
"Outside": "바깥쪽",
"Overall length": "전체 길이",
"Pass through": "통과점",
"Picked": "선택한 항목",
"Position (G0)": "위치 (G0)",
"Profiles/Curves": "프로파일/곡선",
"Projection": "투영",
"RULED SURFACE": "룰드 서피스",
@@ -62,6 +67,7 @@
"Sketch geometry and profiles": "스케치 기하 및 프로파일",
"Sketch points": "스케치 점",
"Slot width": "슬롯 폭",
"Tangent (G1)": "접선 (G1)",
"Tear": "찢김",
"Thickness Outside": "바깥쪽 두께",
"Thin": "얇은 벽",
+6
View File
@@ -28,6 +28,7 @@
"Bend Radius": "Радиус гиба",
"Bends": "Гибы",
"Body": "Тело",
"Boundary": "Граница",
"Bounding box": "Габаритная рамка",
"CSV or JSON: updates existing parameters, adds new ones": "CSV или JSON: обновляет существующие параметры, добавляет новые",
"Capture All Positions": "Зафиксировать все положения",
@@ -48,6 +49,7 @@
"Configuration rules": "Правила конфигурации",
"Construction": "Вспомогательная",
"Contact": "Контакт",
"Continuity": "Непрерывность",
"Copy Error Message": "Скопировать сообщение об ошибке",
"Copy Warning Message": "Скопировать предупреждение",
"Copy all licences": "Копировать все лицензии",
@@ -60,6 +62,7 @@
"Countersink angle": "Угол зенковки",
"Create Issue — review draft": "Создать Issue — проверить черновик",
"Cube": "Куб",
"Curvature (G2)": "Кривизна (G2)",
"Customize Toolbar": "Настроить панель инструментов",
"Delete all": "Удалить все",
"Delete rule": "Удалить правило",
@@ -131,6 +134,7 @@
"Open-source parametric 3D CAD in Rust. Licensed MIT OR Apache-2.0.": "Открытая параметрическая 3D CAD на Rust. Лицензия MIT OR Apache-2.0.",
"Other side": "Другая сторона",
"Parameter values": "Значения параметров",
"Pass through": "Через точки",
"Path": "Траектория",
"Per Edge": "По каждому ребру",
"Pick the tool bodies": "Выберите тела-инструменты",
@@ -138,6 +142,7 @@
"Playback": "Воспроизведение",
"Position": "Положение",
"Position (0 to 1)": "Положение (от 0 до 1)",
"Position (G0)": "Положение (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Положение вдоль ребра: 0 — начало, 1 — конец",
"Preview Row": "Строка предпросмотра",
"Preview leaves the current design unchanged.": "Предпросмотр не меняет текущий дизайн.",
@@ -203,6 +208,7 @@
"Style": "Стиль",
"Suppressed": "Подавлено",
"Swap Ends": "Поменять концы",
"Tangent (G1)": "Касание (G1)",
"Tangent Chain": "Касательная цепочка",
"Tear": "Разрыв",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Пунктирный эскиз — сохранённая подсказка плоскости. Выберите новую грань, чтобы восстановить привязку.",
+6
View File
@@ -32,6 +32,7 @@
"Bend Radius": "Радіус згину",
"Bends": "Згини",
"Body": "Тіло",
"Boundary": "Межа",
"Bounding box": "Обмежувальна рамка",
"CSV or JSON: updates existing parameters, adds new ones": "CSV або JSON: оновлює наявні параметри, додає нові",
"Capture All Positions": "Зафіксувати всі положення",
@@ -52,6 +53,7 @@
"Configuration rules": "Правила конфігурації",
"Construction": "Конструкційна",
"Contact": "Контакт",
"Continuity": "Неперервність",
"Copy all licences": "Копіювати всі ліцензії",
"Copy diagnostic information": "Копіювати діагностичну інформацію",
"Copy report": "Копіювати звіт",
@@ -62,6 +64,7 @@
"Countersink angle": "Кут зенкування",
"Create Issue — review draft": "Створити повідомлення про проблему — перегляд чернетки",
"Cube": "Куб",
"Curvature (G2)": "Кривина (G2)",
"Customize Toolbar": "Налаштувати панель інструментів",
"Delete all": "Видалити все",
"Delete rule": "Видалити правило",
@@ -135,6 +138,7 @@
"Other side": "Інший бік",
"Outside": "Назовні",
"Parameter values": "Значення параметрів",
"Pass through": "Через точки",
"Path": "Траєкторія",
"Per Edge": "На кожне ребро",
"Pick the tool bodies": "Виберіть тіла інструмента",
@@ -142,6 +146,7 @@
"Playback": "Відтворення",
"Position": "Положення",
"Position (0 to 1)": "Положення (від 0 до 1)",
"Position (G0)": "Положення (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "Положення вздовж ребра: 0 на початку, 1 на іншому кінці",
"Preview Row": "Рядок перегляду",
"Preview leaves the current design unchanged.": "Попередній перегляд не змінює поточний проєкт.",
@@ -209,6 +214,7 @@
"Style": "Стиль",
"Suppressed": "Придушено",
"Swap Ends": "Поміняти кінці місцями",
"Tangent (G1)": "Дотик (G1)",
"Tangent Chain": "Дотичний ланцюг",
"Tear": "Розрив",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "Штриховий ескіз — це збережений орієнтир площини. Виберіть нову грань, щоб відновити прив'язку.",
+6
View File
@@ -34,6 +34,7 @@
"Bend Radius": "折弯半径",
"Bends": "折弯",
"Body": "实体",
"Boundary": "边界",
"Bounding box": "边界框",
"CSV or JSON: updates existing parameters, adds new ones": "CSV 或 JSON:更新现有参数、添加新参数",
"Capture All Positions": "捕获所有位置",
@@ -56,6 +57,7 @@
"Configuration rules": "配置规则",
"Construction": "构造",
"Contact": "接触",
"Continuity": "连续性",
"Copy Error Message": "复制错误消息",
"Copy Warning Message": "复制警告消息",
"Copy all licences": "复制所有许可证",
@@ -70,6 +72,7 @@
"Cube": "立方体",
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
"Current perspective view": "当前透视视图",
"Curvature (G2)": "曲率 (G2)",
"Customize Toolbar": "自定义工具栏",
"Delete all": "全部删除",
"Delete rule": "删除规则",
@@ -159,6 +162,7 @@
"Outside": "外侧",
"Overall length": "总长",
"Parameter values": "参数值",
"Pass through": "通过点",
"Path": "路径",
"Per Edge": "每条边",
"Pick the tool bodies": "选择工具实体",
@@ -167,6 +171,7 @@
"Playback": "播放",
"Position": "位置",
"Position (0 to 1)": "位置(0 到 1)",
"Position (G0)": "位置 (G0)",
"Position along the edge, 0 at the start end, 1 at the other": "沿边的位置,起点端为 0,另一端为 1",
"Preview Row": "预览行",
"Preview leaves the current design unchanged.": "预览不会更改当前设计。",
@@ -239,6 +244,7 @@
"Style": "样式",
"Suppressed": "已抑制",
"Swap Ends": "交换端点",
"Tangent (G1)": "相切 (G1)",
"Tangent Chain": "相切链",
"Tear": "撕裂",
"The dashed sketch is a saved plane guide. Select a new face to repair the attachment.": "虚线草图是已保存的平面引导。选择一个新面以修复附着。",
+47
View File
@@ -1276,6 +1276,14 @@ pub enum FeatureKind {
/// exact plane whatever this says).
#[serde(default, skip_serializing_if = "is_zero")]
crown: f64,
/// How a patch on edges meets the faces beside its loop (G0 everywhere by default, and
/// then not saved).
#[serde(default, skip_serializing_if = "PatchContinuity::is_g0")]
continuity: PatchContinuity,
/// Points a patch on edges passes through.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[walk(pos)]
points: Vec<Vec3>,
},
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
Stitch {
@@ -1666,6 +1674,45 @@ pub struct BlendSide {
pub edges: Vec<Vec3>,
}
/// How a patch on a loop of edges meets the faces beside the loop: every edge with `all`, but the
/// edges picked in `edges` with their own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct PatchContinuity {
#[serde(default = "patch_g0", skip_serializing_if = "is_g0")]
pub all: Continuity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edges: Vec<EdgeContinuity>,
}
/// One edge of a patch's loop (a point on it) and how the patch meets the face beside it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
pub struct EdgeContinuity {
#[walk(pos)]
pub edge: Vec3,
pub g: Continuity,
}
impl Default for PatchContinuity {
fn default() -> PatchContinuity {
PatchContinuity { all: Continuity::G0, edges: Vec::new() }
}
}
impl PatchContinuity {
/// G0 everywhere: the patch only meets its edges.
pub fn is_g0(&self) -> bool {
self.all == Continuity::G0 && self.edges.iter().all(|e| e.g == Continuity::G0)
}
}
fn patch_g0() -> Continuity {
Continuity::G0
}
fn is_g0(g: &Continuity) -> bool {
*g == Continuity::G0
}
/// The continuity a blend repeats by default: the tangent plane on both sides.
fn blend_orders() -> [Continuity; 2] {
[Continuity::G1, Continuity::G1]
+8 -2
View File
@@ -8,7 +8,7 @@ use solidcraft_geom::Seg2;
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
match &f.kind {
FeatureKind::Patch { sketch, profiles, body, edges, crown } => {
FeatureKind::Patch { sketch, profiles, body, edges, crown, continuity, points } => {
let mut made = Vec::new();
if let Some(sk) = sketch {
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
@@ -20,7 +20,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
if !edges.is_empty() {
let i = body_at(st, body, edges)?;
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
made.push(kernel::patch_edges_crowned(&b.body, edges, *crown)?);
let spec = kernel::PatchSpec {
continuity: continuity.all,
edges: continuity.edges.iter().map(|e| (e.edge, e.g)).collect(),
points: points.clone(),
crown: *crown,
};
made.push(kernel::patch_edges_with(&b.body, edges, &spec)?);
}
if made.is_empty() {
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
@@ -0,0 +1,6 @@
//! A `Patch` on edges can carry `continuity` (`all`: `g0`, `g1` or `g2`, and `edges`:
//! `[{edge: [x, y, z], g}]` for edges met otherwise; G0 everywhere when absent, and then not
//! saved) and `points` it passes through (none when absent). Older designs need no
//! transformation: a patch without them fills as it always did; a loop that is not a height field
//! over its plane, which 0.13.0 refused, now fills with one face. 0.13.x asks for an update
//! instead of dropping them.
+138 -4
View File
@@ -2,7 +2,9 @@
//! solids), and the planned surface toolset (`planned!`: registered, not built yet).
use serde_json::Value;
use solidcraft_doc::{BlendSide, ExtendMode, ExtendTo, FaceAt, FeatureKind, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind};
use solidcraft_doc::{
BlendSide, EdgeContinuity, ExtendMode, ExtendTo, FaceAt, FeatureKind, PatchContinuity, PlaneRef, ProfileSel, RuledCorners, RuledMode, expr::Kind,
};
use solidcraft_kernel::Continuity;
use super::CommandSpec;
@@ -14,7 +16,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("surface.patch", "Patch", patch)
.at("SURFACE", "CREATE")
.icon("patch")
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges; a loop that is planar is the exact plane whatever the crown); name?, body_name?"),
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges: one face on them, planar when the loop is, else a smooth B-spline surface) and body?; continuity?: g0|g1|g2 (edges only, default g0: how the patch meets the faces beside the loop — g1 their tangent planes, g2 also their curvature across the edges) or [{edge: [x,y,z], g: g0|g1|g2}] (those edges so, the rest g0) or {all, edges}; points?: [[x,y,z]] (edges only, up to 64: points the patch passes through); crown?: number −0.9…3 (default 0: only a fitted patch, whose middle becomes fuller by this many times the loop's size — the bump vanishes on the loop, so the face still meets the picked edges and the continuity across them; a loop that is planar and g0 is the exact plane whatever the crown; with points, crown needs a g1 or g2 edge); name?, body_name?"),
CommandSpec::new("surface.stitch", "Stitch", stitch)
.at("SURFACE", "MODIFY")
.icon("stitch")
@@ -84,24 +86,91 @@ pub static COMMANDS: &[CommandSpec] = &[
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "surface.patch";
let crown = crown_param(p, cmd)?;
let continuity = patch_continuity(p, cmd)?;
let points = patch_points(p, cmd)?;
let edges: Vec<solidcraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
let mut v = if !edges.is_empty() {
if edges.len() > 10_000 {
return Err(bad(cmd, "too many edges"));
}
let body = str_(p, "body").map(str::to_string);
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown })?
add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges, crown, continuity: continuity.clone(), points })?
} else {
if !continuity.is_g0() || !points.is_empty() {
return Err(bad(cmd, "`continuity` and `points` are for a patch on a loop of edges"));
}
let sketch = feature_sketch(s, p, cmd)?;
let profiles = profiles(p, cmd)?;
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new(), crown })?
let kind = FeatureKind::Patch {
sketch: Some(sketch),
profiles,
body: None,
edges: Vec::new(),
crown,
continuity: PatchContinuity::default(),
points,
};
add_feature(s, p, kind)?
};
if let Some(o) = v.as_object_mut() {
o.insert("crown".into(), serde_json::json!(crown));
o.insert("continuity".into(), serde_json::to_value(&continuity).unwrap_or(Value::Null));
}
Ok(v)
}
/// A continuity word for a patch: g0, g1 or g2 (a patch does not hold g3).
fn patch_order(v: Option<&Value>, cmd: &str) -> Result<Continuity> {
match v.and_then(Value::as_str).and_then(Continuity::parse) {
Some(g) if g != Continuity::G3 => Ok(g),
_ => Err(bad(cmd, "a patch's continuity is g0, g1 or g2")),
}
}
/// The patch's `continuity`: one word for every edge, a list of `{edge, g}` for some edges (the
/// rest g0), or `{all, edges}`.
fn patch_continuity(p: &Value, cmd: &str) -> Result<PatchContinuity> {
let edges = |list: &Value| -> Result<Vec<EdgeContinuity>> {
let list = list.as_array().ok_or_else(|| bad(cmd, "`continuity` edges must be a list of {edge: [x,y,z], g}"))?;
if list.len() > 10_000 {
return Err(bad(cmd, "`continuity` lists at most 10000 edges"));
}
list.iter()
.map(|e| {
let edge = e.get("edge").and_then(vec3).ok_or_else(|| bad(cmd, "a `continuity` entry's `edge` must be a point [x,y,z]"))?;
Ok(EdgeContinuity { edge, g: patch_order(e.get("g"), cmd)? })
})
.collect()
};
match p.get("continuity") {
None => Ok(PatchContinuity::default()),
Some(v) if v.is_string() => Ok(PatchContinuity { all: patch_order(Some(v), cmd)?, edges: Vec::new() }),
Some(v) if v.is_array() => Ok(PatchContinuity { all: Continuity::G0, edges: edges(v)? }),
Some(v) if v.is_object() => {
let all = match v.get("all") {
None => Continuity::G0,
some => patch_order(some, cmd)?,
};
let edges = match v.get("edges") {
None => Vec::new(),
Some(list) => edges(list)?,
};
Ok(PatchContinuity { all, edges })
}
Some(_) => Err(bad(cmd, "`continuity` must be g0, g1 or g2, or a list of {edge, g}")),
}
}
/// The points a patch passes through (at most 64, each a finite point).
fn patch_points(p: &Value, cmd: &str) -> Result<Vec<solidcraft_geom::Vec3>> {
let Some(v) = p.get("points") else { return Ok(Vec::new()) };
let list = v
.as_array()
.filter(|a| a.len() <= solidcraft_geom::fill_general::MAX_FILL_POINTS)
.ok_or_else(|| bad(cmd, "`points` must list at most 64 points [x,y,z]"))?;
list.iter().map(|q| vec3(q).filter(|q| q.is_finite()).ok_or_else(|| bad(cmd, "a patch point must be a finite [x,y,z]"))).collect()
}
/// The patch's `crown`: how full a fitted patch's middle is made, -0.9…3 (default 0). A hostile
/// value (NaN, infinity, out of range, a string) is refused.
fn crown_param(p: &Value, cmd: &str) -> Result<f64> {
@@ -1329,4 +1398,69 @@ mod tests {
.is_err()
);
}
/// A tube whose top face is taken off (`surface.unstitch`), and points round its rim.
fn open_tube() -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 30, "body_name": "Tube"}));
run(&mut s, "surface.unstitch", json!({"body": "Tube", "faces": [[0, 0, 30]]}));
let rim: Vec<Value> = (0..8).map(|k| f64::from(k) * 0.785).map(|a| json!([10.0 * a.cos(), 10.0 * a.sin(), 30.0])).collect();
(s, json!(rim))
}
/// `surface.patch continuity` and `points`: the tube's open end capped G1, per-edge
/// continuity, a point the cap passes through, all saved with the design and read back;
/// hostile values refused.
#[test]
fn patch_continuity_and_points_cap_a_tube() {
// A coarse mesh is enough to tell the patches apart (measuring a fitted face's area
// exactly tessellates it finely).
let mesh = |s: &Session, name: &str| s.world_state().body(name).unwrap().body.tessellate(0.05).unwrap();
let (mut s, rim) = open_tube();
let reply = run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": "g1", "body_name": "Cap"}));
assert_eq!(reply["continuity"], json!({"all": "g1"}), "{reply}");
// A dome over the rim, not the flat disc a G0 patch of a planar rim is.
let disc = std::f64::consts::PI * 100.0;
let dome = mesh(&s, "Cap").measure().area;
assert!(dome > disc * 1.2, "a dome, not the disc: {dome}");
// Through a point 8 above the rim, G1 to the wall: the cap reaches it.
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": {"all": "g1"}, "points": [[0, 0, 38]], "body_name": "Tall"}));
let top = mesh(&s, "Tall").bounds().max.z;
assert!(top >= 38.0 - 1e-2, "the cap passes through the point: top {top}");
// One edge G2, the rest G0 (the list form).
let first = rim[0].clone();
let reply =
run(&mut s, "surface.patch", json!({"edges": rim, "body": "Tube", "continuity": [{"edge": first, "g": "g2"}], "body_name": "Mixed"}));
assert_eq!(reply["continuity"]["edges"][0]["g"], json!("g2"), "{reply}");
// Saved and read back: the same patches.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let reopened = Session::new(back);
for name in ["Cap", "Tall", "Mixed"] {
let (a, b) = (mesh(&s, name).positions, mesh(&reopened, name).positions);
assert_eq!(a, b, "{name}: the same surface read back");
}
assert!(s.doc.to_json().contains("\"continuity\""), "the continuity is saved");
// Hostile and misplaced values.
for (key, v) in [
("continuity", json!("g3")),
("continuity", json!("smooth")),
("continuity", json!(5)),
("continuity", json!([{"edge": "here", "g": "g1"}])),
("continuity", json!([{"edge": [f64::INFINITY, 0, 0], "g": "g1"}])),
("continuity", json!({"all": "g9"})),
("continuity", json!([{"edge": [0, 0, 0], "g": "g1"}])),
("points", json!([[0, 0, f64::NAN]])),
("points", json!("up")),
("points", json!(vec![[0, 0, 35]; 65])),
("points", json!([[100, 0, 30]])),
] {
let mut p = json!({"edges": rim, "body": "Tube"});
p[key] = v.clone();
assert!(s.execute("surface.patch", &p).is_err(), "accepted {key}: {v}");
}
run(&mut s, "sketch.create", json!({"plane": "XY"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [30, 0], "p1": [40, 10]}));
let sketch_patch = json!({"sketch": 1, "continuity": "g1"});
assert!(s.execute("surface.patch", &sketch_patch).is_err(), "continuity on a sketch patch");
}
}
+2
View File
@@ -779,6 +779,8 @@ fn hostile_params_never_panic() {
"orientation",
"twist",
"scale",
"continuity",
"crown",
];
// Commands are independent; worker threads take them one at a time (their costs vary a lot).
let ids: Vec<&str> = command_specs()
+111 -21
View File
@@ -33,7 +33,7 @@ const GAUSS4: [(f64, f64); 4] = [
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
];
/// The smoothness term's weight per fitted point (see [`fill_loop`]).
const SMOOTHNESS: f64 = 1e-7;
pub(crate) const SMOOTHNESS: f64 = 1e-7;
/// A loop step shorter than this fraction of its length in space, seen along the normal, runs
/// along the normal (steeper than about 87° from the plane).
const MIN_STEP_SLOPE: f64 = 0.05;
@@ -289,13 +289,32 @@ fn fit_once(fit: &[Vec3], all: &[Vec3], tol: f64, crown: f64) -> SplineResult<Fi
/// control points took the bump, and the farthest the bump moved the surface at a sampled loop
/// point (0).
fn crown_fill(surface: NurbsSurface, uv: &[[f64; 2]], crown: f64, normal: Vec3) -> SplineResult<(NurbsSurface, usize, f64)> {
if crown == 0.0 {
return Ok((surface, 0, 0.0));
}
if !normal.is_finite() {
return invalid("the loop's extent is not finite");
}
crown_fill_along(surface, uv, crown, &|_| Some(normal), MAX_FILL_SPANS)
}
/// [`crown_fill`] moving each control point along `along(ξ, η)` (at its Greville point, the mean
/// of its knots each way) rather than one normal: what a fill that is not a height field takes,
/// with its own normal there. Its net may be refined up to `max_spans` each way.
pub(crate) fn crown_fill_along(
surface: NurbsSurface,
uv: &[[f64; 2]],
crown: f64,
along: &dyn Fn([f64; 2]) -> Option<Vec3>,
max_spans: usize,
) -> SplineResult<(NurbsSurface, usize, f64)> {
if crown == 0.0 {
return Ok((surface, 0, 0.0));
}
let gap = loop_gap(uv);
let mut base = surface;
loop {
if let Some((crowned, points)) = crown_net(&base, uv, gap, crown, normal)? {
if let Some((crowned, points)) = crown_net(&base, uv, gap, crown, along)? {
let mut drift = 0.0f64;
for q in uv {
if let (Ok(a), Ok(b)) = (base.eval(q[0], q[1]), crowned.eval(q[0], q[1])) {
@@ -309,11 +328,11 @@ fn crown_fill(surface: NurbsSurface, uv: &[[f64; 2]], crown: f64, normal: Vec3)
}
// No room inside the loop yet: halve the cells (the surface itself does not change).
let spans = [0, 1].map(|d| base.count()[d].saturating_sub(3));
if spans[0] >= MAX_FILL_SPANS && spans[1] >= MAX_FILL_SPANS {
if spans[0] >= max_spans && spans[1] >= max_spans {
return Err(SplineError::Unsupported("crowning a patch whose loop leaves no room inside it".into()));
}
for (d, &s) in spans.iter().enumerate() {
if s < MAX_FILL_SPANS {
if s < max_spans {
base = subdivide(&base, d)?;
}
}
@@ -324,13 +343,19 @@ fn crown_fill(surface: NurbsSurface, uv: &[[f64; 2]], crown: f64, normal: Vec3)
/// loop takes `crown · size · B(ξ, η)`, `B` the bubble's blossom at the point's knots (so the
/// surface between the perturbed points is exactly the bubble, not a fit of it) and `size` the
/// box's diagonal. `None` when the net has no such point.
fn crown_net(base: &NurbsSurface, uv: &[[f64; 2]], gap: [f64; 2], crown: f64, normal: Vec3) -> SplineResult<Option<(NurbsSurface, usize)>> {
fn crown_net(
base: &NurbsSurface,
uv: &[[f64; 2]],
gap: [f64; 2],
crown: f64,
along: &dyn Fn([f64; 2]) -> Option<Vec3>,
) -> SplineResult<Option<(NurbsSurface, usize)>> {
let [cu, cv] = base.count();
let degree = base.degree();
let knots = base.knots();
let domain = base.domain();
let size = (domain[0][1] - domain[0][0]).hypot(domain[1][1] - domain[1][0]);
if !(size > 0.0 && size.is_finite() && normal.is_finite()) {
if !(size > 0.0 && size.is_finite()) {
return invalid("the loop's extent is not finite");
}
// The bubble `4·s·(1−s)` per direction, as a quadratic in the plane coordinate.
@@ -351,6 +376,8 @@ fn crown_net(base: &NurbsSurface, uv: &[[f64; 2]], gap: [f64; 2], crown: f64, no
continue;
}
let bump = crown * size * blossom(qu, u_knots) * blossom(qv, v_knots);
let mean = |t: [f64; 3]| (t[0] + t[1] + t[2]) / 3.0;
let Some(normal) = along([mean(u_knots), mean(v_knots)]).filter(|n| n.is_finite()) else { continue };
let Some(slot) = ctrl.get_mut(j * cu + i) else { continue };
let h = *slot;
let Some(p) = h.project() else { continue };
@@ -431,7 +458,7 @@ fn loop_gap(uv: &[[f64; 2]]) -> [f64; 2] {
}
/// Whether `p` is inside the closed loop `uv` (ray casting).
fn inside_loop(p: [f64; 2], uv: &[[f64; 2]]) -> bool {
pub(crate) fn inside_loop(p: [f64; 2], uv: &[[f64; 2]]) -> bool {
let m = uv.len();
let mut inside = false;
for k in 0..m {
@@ -512,7 +539,7 @@ fn check_projectable(points: &[Vec3], uv: &[[f64; 2]]) -> SplineResult<()> {
}
/// Whether the closed segments `ab` and `cd` share a point.
fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
pub(crate) fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
let orient = |p: [f64; 2], q: [f64; 2], r: [f64; 2]| (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]);
let within =
|p: [f64; 2], q: [f64; 2], r: [f64; 2]| r[0] >= p[0].min(q[0]) && r[0] <= p[0].max(q[0]) && r[1] >= p[1].min(q[1]) && r[1] <= p[1].max(q[1]);
@@ -525,9 +552,9 @@ fn segments_meet(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2]) -> bool {
/// A bicubic height field's grid: uniform clamped knots over `[lo, hi]` in each direction,
/// `count` control points along each.
struct Grid {
knots: [Vec<f64>; 2],
count: [usize; 2],
pub(crate) struct Grid {
pub(crate) knots: [Vec<f64>; 2],
pub(crate) count: [usize; 2],
}
impl LoopFill {
@@ -540,7 +567,7 @@ impl LoopFill {
}
impl Grid {
fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
pub(crate) fn new(spans: [usize; 2], lo: [f64; 2], hi: [f64; 2]) -> Grid {
let knots = [0, 1].map(|d| {
let (a, b, s) = (lo[d], hi[d], spans[d]);
let mut k = vec![a; 4];
@@ -552,7 +579,7 @@ impl Grid {
}
/// The length of one span in direction `d`.
fn cell(&self, d: usize) -> f64 {
pub(crate) fn cell(&self, d: usize) -> f64 {
let (Some(k), Some(&c)) = (self.knots.get(d), self.count.get(d)) else { return 0.0 };
(k.last().copied().unwrap_or(0.0) - k.first().copied().unwrap_or(0.0)) / (c - 3) as f64
}
@@ -564,7 +591,7 @@ impl Grid {
/// The four non-zero basis functions' `r`-th derivatives and the first index, at `x` in
/// direction `d`.
fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
pub(crate) fn basis_derivative(&self, d: usize, x: f64, r: usize) -> Option<(usize, [f64; 4])> {
let kn = self.knots.get(d)?;
let x = x.clamp(*kn.first()?, *kn.last()?);
let span = find_span(kn, 3, *self.count.get(d)?, x)?;
@@ -622,6 +649,22 @@ impl Grid {
// against the data term (squared heights) alike at any size. Each term is a product of
// one-direction Gram matrices of the basis functions' derivatives.
let lambda = SMOOTHNESS * fitted as f64 / n as f64 * self.cell(0) * self.cell(1);
self.add_energy(&mut band, lambda);
band.solve(rhs)
}
/// Add `lambda` times the thin-plate energy `∫∫ h_uu² + 2·h_uv² + h_vv²` over the grid's box
/// to the normal equations `band` (unknowns i fastest): each term is a product of
/// one-direction Gram matrices of the basis functions' derivatives.
pub(crate) fn add_energy(&self, band: &mut Band, lambda: f64) {
let cu = self.count[0];
self.energy(lambda, &mut |(i, j), (k, l), e| band.add(j * cu + i, l * cu + k, e));
}
/// The entries of `lambda` times the thin-plate energy's matrix (see [`Grid::add_energy`]),
/// control point `(i, j)` against `(k, l)`, for every pair whose supports overlap.
pub(crate) fn energy(&self, lambda: f64, add: &mut dyn FnMut((usize, usize), (usize, usize), f64)) {
let [cu, cv] = self.count;
let gram = |d: usize, r: usize| -> Vec<f64> {
let c = self.count.get(d).copied().unwrap_or(0);
let mut g = vec![0.0; c * c];
@@ -652,16 +695,14 @@ impl Grid {
};
for j in 0..cv {
for i in 0..cu {
let r = j * cu + i;
for l in j.saturating_sub(3)..(j + 4).min(cv) {
for k in i.saturating_sub(3)..(i + 4).min(cu) {
let e: f64 = ENERGY.iter().map(|&(ru, rv, w)| w * g(0, ru, i, k) * g(1, rv, j, l)).sum();
band.add(r, l * cu + k, lambda * e);
add((i, j), (k, l), lambda * e);
}
}
}
}
band.solve(rhs)
}
/// The surface of the net `heights` over the plane `o`, `x`, `y`, `n`: control point `(i, j)`
@@ -680,14 +721,15 @@ impl Grid {
}
/// A symmetric banded matrix: the upper band, row `r` holding columns `r ..= r + bw`.
struct Band {
#[derive(Clone)]
pub(crate) struct Band {
n: usize,
bw: usize,
a: Vec<f64>,
}
impl Band {
fn new(n: usize, bw: usize) -> Band {
pub(crate) fn new(n: usize, bw: usize) -> Band {
Band { n, bw, a: vec![0.0; n * (bw + 1)] }
}
fn index(&self, r: usize, s: usize) -> Option<usize> {
@@ -704,7 +746,7 @@ impl Band {
}
/// Add `v` at `(r, s)` with `r ≤ s` (the lower half is the same by symmetry: callers adding
/// a full outer product reach each stored entry once through `r ≤ s`).
fn add(&mut self, r: usize, s: usize, v: f64) {
pub(crate) fn add(&mut self, r: usize, s: usize, v: f64) {
if r <= s
&& let Some(x) = self.index(r, s).and_then(|i| self.a.get_mut(i))
{
@@ -713,7 +755,13 @@ impl Band {
}
/// Solve `A·x = b` by banded Cholesky factorisation `A = Uᵀ·U`; an error when `A` is not
/// positive definite.
fn solve(mut self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
fn solve(self, b: Vec<f64>) -> SplineResult<Vec<f64>> {
self.factor()?.substitute(b)
}
/// The Cholesky factor `U` of `A = Uᵀ·U`, in place; an error when `A` is not positive
/// definite.
pub(crate) fn factor(mut self) -> SplineResult<Band> {
let (n, bw) = (self.n, self.bw);
for r in 0..n {
let mut d = self.get(r, r);
@@ -734,6 +782,48 @@ impl Band {
self.set(r, s, v / d);
}
}
Ok(self)
}
/// [`Band::factor`] by rows: once row `r` of `U` is known, the rows below it within the band
/// are updated from it at once (right-looking), which walks memory row by row instead of down
/// columns. The same factor, to rounding; what the general fill, whose bands are wide, uses.
pub(crate) fn factor_rows(mut self) -> SplineResult<Band> {
let (n, bw) = (self.n, self.bw);
let w = bw + 1;
let mut row = vec![0.0; w];
for r in 0..n {
let Some(here) = self.a.get_mut(r * w..r * w + w) else { return invalid("the fit's equations are out of shape") };
let d = here.first().copied().unwrap_or(0.0);
if !(d > 0.0) {
return invalid("the fit's equations are singular");
}
let d = d.sqrt();
for (k, x) in here.iter_mut().enumerate() {
*x = if k == 0 { d } else { *x / d };
}
row.copy_from_slice(here);
let end = (r + bw + 1).min(n);
for s in r + 1..end {
let us = row.get(s - r).copied().unwrap_or(0.0);
if us == 0.0 {
continue;
}
let len = end - s;
let (Some(dst), Some(src)) = (self.a.get_mut(s * w..s * w + len), row.get(s - r..s - r + len)) else {
return invalid("the fit's equations are out of shape");
};
for (x, y) in dst.iter_mut().zip(src) {
*x -= us * y;
}
}
}
Ok(self)
}
/// Solve `Uᵀ·U·x = b` with a factor from [`Band::factor`].
pub(crate) fn substitute(&self, mut b: Vec<f64>) -> SplineResult<Vec<f64>> {
let (n, bw) = (self.n, self.bw);
// Uᵀ·y = b, then U·x = y.
for r in 0..n {
let mut v = b.get(r).copied().unwrap_or(0.0);
File diff suppressed because it is too large. Load diff
+191
View File
@@ -0,0 +1,191 @@
use super::*;
use std::f64::consts::{PI, TAU};
const TOL: f64 = 1e-5;
const ANGLE: f64 = 0.05 * PI / 180.0;
/// The surface at fractions `(x, y)` of its domain.
fn at(s: &NurbsSurface, x: f64, y: f64) -> Vec3 {
let [du, dv] = s.domain();
s.eval((du[0] + (du[1] - du[0]) * x).min(du[1]), (dv[0] + (dv[1] - dv[0]) * y).min(dv[1])).unwrap()
}
fn spec(points: &[Vec3]) -> FillSpec<'_> {
FillSpec { tol: TOL, angle_tol: ANGLE, crown: 0.0, points }
}
/// The radial and tangential unit vectors of a cylinder about z at angle `a`.
fn radial(a: f64) -> Vec3 {
Vec3::new(a.cos(), a.sin(), 0.0)
}
fn around(a: f64) -> Vec3 {
Vec3::new(-a.sin(), a.cos(), 0.0)
}
/// The open end of a tube about z (radius `r`, its wall below z = 0), one side: G1 (or G2) to
/// the wall, which leaves straight up.
fn tube_end(r: f64, g2: bool) -> impl FnMut(usize, &[f64]) -> Result<Vec<Station>, String> {
move |_, ts| {
Ok(ts
.iter()
.map(|t| {
let a = t * TAU;
let across = Across { leave: Vec3::Z, normal: radial(a), bend: g2.then_some(Vec3::ZERO) };
Station { p: radial(a) * r, up: around(a).cross(Vec3::Z), across: Some(across), corner: false, clash: false }
})
.collect())
}
}
/// A window in the wall of a cylinder about z (radius `r`): `a` either side of the x axis, `h`
/// high, its four sides G1 to the wall around it (the same cylinder).
fn window(r: f64, a: f64, h: f64) -> impl FnMut(usize, &[f64]) -> Result<Vec<Station>, String> {
move |k, ts| {
Ok(ts
.iter()
.map(|&t| {
let (angle, z, tangent, leave) = match k {
0 => (-a + 2.0 * a * t, 0.0, around(-a + 2.0 * a * t), Vec3::Z),
1 => (a, h * t, Vec3::Z, around(a) * -1.0),
2 => (a - 2.0 * a * t, h, around(a - 2.0 * a * t) * -1.0, Vec3::Z * -1.0),
_ => (-a, h - h * t, Vec3::Z * -1.0, around(-a)),
};
let p = radial(angle) * r + Vec3::Z * z;
let up = tangent.cross(leave);
Station { p, up, across: Some(Across { leave, normal: radial(angle), bend: None }), corner: t == 0.0, clash: false }
})
.collect())
}
}
#[test]
fn a_tube_end_capped_g1_meets_the_wall_and_rises_over_it() {
for g2 in [false, true] {
let f = fill_general(1, &mut tube_end(10.0, g2), &spec(&[])).unwrap();
eprintln!(
"tube end (g2 {g2}): {:?} spans, {} stations, off the rim {:e}, normals within {:.4}°, curvature mismatch {:e}",
f.spans,
f.stations,
f.deviation,
f.normal_deviation.to_degrees(),
f.curvature_mismatch
);
assert!(f.deviation <= TOL && f.normal_deviation <= ANGLE);
// The middle rises over the rim (the cap leaves the wall upwards).
let mid = at(&f.surface, 0.5, 0.5);
eprintln!("the cap's middle: {mid:?}");
assert!(mid.z > 2.0 && mid.x.hypot(mid.y) < 1.0, "{mid:?}");
}
}
#[test]
fn a_window_in_a_cylinder_wrapping_past_half_way_fills_close_to_the_cylinder() {
// 240° around: no plane sees it as a height field.
let (r, a, h) = (10.0, 2.0 * PI / 3.0, 10.0);
let f = fill_general(4, &mut window(r, a, h), &spec(&[])).unwrap();
eprintln!("window: {:?} spans, off {:e}, normals within {:.4}°", f.spans, f.deviation, f.normal_deviation.to_degrees());
assert!(f.deviation <= TOL && f.normal_deviation <= ANGLE);
// Inside, the fill stays near the cylinder it was cut from (it is the least-bending surface
// with the cylinder's boundary data, not the cylinder itself).
let mut worst = 0.0f64;
for k in 1..20 {
for j in 1..20 {
let (ang, z) = (-a + 2.0 * a * k as f64 / 20.0, h * j as f64 / 20.0);
// The fill's point nearest this cylinder point, by a coarse search.
let target = radial(ang) * r + Vec3::Z * z;
let mut best = f64::INFINITY;
for u in 0..=120 {
for v in 0..=120 {
best = best.min(at(&f.surface, u as f64 / 120.0, v as f64 / 120.0).dist(target));
}
}
worst = worst.max(best);
}
}
eprintln!("window: the cylinder is within {worst} of the fill");
assert!(worst < 0.25, "{worst}");
}
#[test]
fn a_planar_polygon_is_its_plane_and_a_point_lifts_it() {
// An L-shaped hexagon (one reflex corner) in z = 0, its neighbours standing up around it.
let c = [[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [10.0, 10.0], [10.0, 20.0], [0.0, 20.0]];
let mut stations = |k: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
let (Some(a), Some(b)) = (c.get(k), c.get((k + 1) % 6)) else { return Err("no side".into()) };
let tangent = Vec3::new(b[0] - a[0], b[1] - a[1], 0.0).normalized().ok_or("no tangent")?;
let inward = Vec3::Z.cross(tangent);
Ok(ts
.iter()
.map(|t| {
let p = Vec3::new(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, 0.0);
Station { p, up: Vec3::Z, across: Some(Across { leave: inward, normal: Vec3::Z, bend: None }), corner: *t == 0.0, clash: false }
})
.collect())
};
let f = fill_general(6, &mut stations, &spec(&[])).unwrap();
let mut off = 0.0f64;
for i in 0..=20 {
for j in 0..=20 {
off = off.max(at(&f.surface, i as f64 / 20.0, j as f64 / 20.0).z.abs());
}
}
eprintln!("L: {:?} spans, the plane within {off:e}", f.spans);
assert!(off < 1e-8, "{off}");
// G0 with a point 3 above the inside of the L: the surface passes through it.
let mut g0 = |k: usize, ts: &[f64]| stations(k, ts).map(|v| v.into_iter().map(|s| Station { across: None, ..s }).collect());
let point = [Vec3::new(5.0, 5.0, 3.0)];
let f = fill_general(6, &mut g0, &spec(&point)).unwrap();
eprintln!("L through a point: {:?} spans, off the loop {:e}, off the point {:e}", f.spans, f.deviation, f.point_deviation);
assert!(f.deviation <= TOL && f.point_deviation <= TOL);
}
#[test]
fn hostile_loops_and_specs_are_refused() {
let ok = |s: &FillSpec| fill_general(1, &mut tube_end(10.0, false), s);
for (tol, angle, crown) in
[(0.0, ANGLE, 0.0), (f64::NAN, ANGLE, 0.0), (TOL, -1.0, 0.0), (TOL, ANGLE, f64::NAN), (TOL, ANGLE, -0.95), (TOL, ANGLE, 1e300)]
{
assert!(ok(&FillSpec { tol, angle_tol: angle, crown, points: &[] }).is_err(), "{tol} {angle} {crown}");
}
assert!(ok(&spec(&[Vec3::new(f64::NAN, 0.0, 0.0)])).is_err());
assert!(ok(&spec(&vec![Vec3::ZERO; MAX_FILL_POINTS + 1])).is_err());
// A point not over the inside of the loop.
assert!(ok(&spec(&[Vec3::new(30.0, 0.0, 0.0)])).is_err());
assert!(fill_general(0, &mut tube_end(10.0, false), &spec(&[])).is_err());
assert!(fill_general(10_000, &mut tube_end(10.0, false), &spec(&[])).is_err());
assert!(fill_general(1, &mut |_, _| Err("no".into()), &spec(&[])).is_err());
assert!(
fill_general(
1,
&mut |_, ts| Ok(vec![Station { p: Vec3::ZERO, up: Vec3::Z, across: None, corner: false, clash: false }; ts.len()]),
&spec(&[])
)
.is_err()
);
// A loop that runs out and straight back.
let mut spike = |_: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
Ok(ts
.iter()
.map(|t| Station { p: Vec3::X * (1.0 - (2.0 * t - 1.0f64).abs()) * 10.0, up: Vec3::Z, across: None, corner: false, clash: false })
.collect())
};
assert!(fill_general(1, &mut spike, &spec(&[])).is_err());
// A figure eight: its development crosses itself.
let mut eight = |_: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
Ok(ts
.iter()
.map(|t| {
let a = t * TAU;
Station { p: Vec3::new(10.0 * a.sin(), 5.0 * (2.0 * a).sin(), 0.0), up: Vec3::Z, across: None, corner: false, clash: false }
})
.collect())
};
assert!(fill_general(1, &mut eight, &spec(&[])).is_err());
// Huge and tiny loops do not panic.
for scale in [1e300, 1e-300] {
let mut s = |_: usize, ts: &[f64]| -> Result<Vec<Station>, String> {
Ok(ts.iter().map(|t| Station { p: radial(t * TAU) * scale, up: Vec3::Z, across: None, corner: false, clash: false }).collect())
};
let _ = fill_general(1, &mut s, &spec(&[]));
}
}
+1
View File
@@ -14,6 +14,7 @@ pub mod bspline;
pub mod compare;
pub mod env;
pub mod fill;
pub mod fill_general;
mod mesh;
mod plane;
mod profile;
+96 -26
View File
@@ -191,44 +191,115 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
let leave =
leave.normalized().filter(|l| l.is_finite()).ok_or_else(|| KernelError::Invalid("the leave direction must be a finite vector".into()))?;
let (pts, length) = chain_samples(edges, count)?;
let surf = face.surface();
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
let support = Support::of(face, length)?;
let mut walk = Walk::new(length);
let along = |n: Vec3| -> Result<Vec3> {
(leave - n * leave.dot(n))
.normalized()
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))
};
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
// next to its exact ring), so the projection is only asked to be close, not exact.
let tol = (length * 1e-4).max(1e-6);
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
// locally but round-off stays far below the geometry.
let mut step = (length * 1e-3).clamp(1e-6, 0.5);
let mut out = Vec::with_capacity(pts.len());
// The station before this one, as the hint for the projection: it keeps the search on the
// same side of a periodic or split surface.
let mut hint: Option<(f64, f64)> = None;
for p in &pts {
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(*p), hint, 100);
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(*p) <= tol;
out.push(support.station(*p, &along, &mut walk, true)?.0);
}
Ok(out)
}
/// The frames of `face` at stations on its own boundary, each leaving the face straight across
/// that boundary: `stations` are points on the boundary with the boundary's direction there *as
/// the face's own boundary runs* (its wires as [`mt::Face::boundaries`] gives them), and the leave
/// direction is that direction × the face's outward normal — in the face's tangent plane, at
/// right angles to the boundary, away from the face. `length` is the size of the boundary the
/// stations are on (it sets how closely a station must lie on the surface and the step the shape
/// is read with). Each frame comes with the face's unit normal there (as the face is oriented).
/// With `shape` false only the leave direction and the normal are read (`bend` and
/// `bend_change` are zero), which needs no room on the surface beside the station.
pub fn frames_across(face: &mt::Face, stations: &[(Vec3, Vec3)], length: f64, shape: bool) -> Result<Vec<(Frame, Vec3)>> {
if stations.len() > 1 << 16 || !(length.is_finite() && length > 0.0) {
return Err(KernelError::Invalid("frames across a boundary need at most 65536 stations on a boundary of finite size".into()));
}
let support = Support::of(face, length)?;
let outward = if face.orientation() { 1.0 } else { -1.0 };
let mut walk = Walk::new(length);
let mut out = Vec::with_capacity(stations.len());
for (p, tangent) in stations {
if !p.is_finite() || !tangent.is_finite() {
return Err(KernelError::Invalid("a boundary station must be finite".into()));
}
let away = |n: Vec3| -> Result<Vec3> {
tangent.cross(n * outward).normalized().ok_or_else(|| KernelError::Invalid("the boundary runs along the face's normal".to_string()))
};
let (frame, normal) = support.station(*p, &away, &mut walk, shape)?;
out.push((frame, normal * outward));
}
Ok(out)
}
/// A support surface read at stations: the surface, its parameter box, and how close a station
/// must lie to it.
struct Support {
surf: mt::Surface,
range_u: (f64, f64),
range_v: (f64, f64),
tol: f64,
}
/// What reading one station hands on to the next: the station's parameters (the hint for the
/// next projection, which keeps the search on the same side of a periodic or split surface) and
/// the finite-difference step that worked.
struct Walk {
hint: Option<(f64, f64)>,
step: f64,
}
impl Walk {
fn new(length: f64) -> Walk {
// Finite-difference step: a thousandth of the chain, so curvature over the chain is read
// locally but round-off stays far below the geometry.
Walk { hint: None, step: (length * 1e-3).clamp(1e-6, 0.5) }
}
}
impl Support {
fn of(face: &mt::Face, length: f64) -> Result<Support> {
let surf = face.surface();
let (Some(range_u), Some(range_v)) = surf.try_range_tuple() else {
return Err(KernelError::Failed("bridge: the support surface is unbounded".into()));
};
// A face's own edges may sit a fit tolerance off its surface (a cylinder's wall as a NURBS
// next to its exact ring), so the projection is only asked to be close, not exact.
let tol = (length * 1e-4).max(1e-6);
Ok(Support { surf, range_u, range_v, tol })
}
/// The frame at `p`, leaving in `leave(n)` (`n` the surface's unit normal there), and that
/// normal. Without `read_shape`, the frame's shape is left at zero.
fn station(&self, p: Vec3, leave: &dyn Fn(Vec3) -> Result<Vec3>, walk: &mut Walk, read_shape: bool) -> Result<(Frame, Vec3)> {
let (surf, range_u, range_v, tol) = (&self.surf, self.range_u, self.range_v, self.tol);
let project = |hint: Option<(f64, f64)>| surf.search_nearest_parameter(p3(p), hint, 100);
let close = |uv: (f64, f64)| from_p3(surf.subs(uv.0, uv.1)).dist(p) <= tol;
// The station before this one is the hint where it lands close; a chain that wraps the
// surface (or a periodic one) falls back to a free search.
let (u, v) = match project(hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
let (u, v) = match project(walk.hint).filter(|uv| close(*uv)).or_else(|| project(None).filter(|uv| close(*uv))) {
Some(uv) => uv,
None => return Err(KernelError::Invalid("a picked edge is not on the support surface".into())),
};
hint = Some((u, v));
walk.hint = Some((u, v));
let at = from_p3(surf.subs(u, v));
if at.dist(*p) > tol {
if at.dist(p) > tol {
return Err(KernelError::Invalid("a picked edge is not on the support surface".into()));
}
let normal = surf.normal(u, v);
let n = Vec3::new(normal.x, normal.y, normal.z)
.normalized()
.ok_or_else(|| KernelError::Failed("bridge: the support surface has no normal".into()))?;
let w = (leave - n * leave.dot(n))
.normalized()
.ok_or_else(|| KernelError::Invalid("the leave direction is normal to the support surface: it has no tangent plane there".to_string()))?;
let w = leave(n)?;
if !read_shape {
return Ok((Frame { p, leave: w, bend: Vec3::ZERO, bend_change: Vec3::ZERO }, n));
}
// Parameter-space direction that leaves in `w` at unit 3D speed: S_u·du + S_v·dv = w.
let su = tangent(&surf, u, v, range_u, true);
let sv = tangent(&surf, u, v, range_v, false);
let su = tangent(surf, u, v, range_u, true);
let sv = tangent(surf, u, v, range_v, false);
let (a, b, c) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = a * c - b * b;
let scale = a.max(c).max(1e-30);
@@ -260,7 +331,7 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
None
}
};
let mut h = step;
let mut h = walk.step;
let mut chosen = pick(h);
for _ in 0..8 {
if chosen.is_some() {
@@ -299,10 +370,9 @@ pub fn frames(face: &mt::Face, edges: &[mt::Edge], leave: Vec3, count: usize) ->
// against the leave direction: the curvature vector is the same either way, the change of
// curvature turns round with the direction of travel.
let bend_change = if t.dot(w) < 0.0 { -bend_change } else { bend_change };
out.push(Frame { p: *p, leave: w, bend, bend_change });
step = h;
walk.step = h;
Ok((Frame { p, leave: w, bend, bend_change }, n))
}
Ok(out)
}
/// A first derivative of the surface in one parameter, by central differences (one-sided where
+274 -12
View File
@@ -1,16 +1,259 @@
//! Filling a closed loop of a body's edges that is not planar with one face: a B-spline height
//! field over the loop's best-fit plane (`solidcraft_geom::fill::fill_loop`), bounded by the
//! body's own edges, whole. Loops that do not bound a height field over a plane are refused.
//! Filling a closed loop of a body's edges that is not planar with one face, bounded by the
//! body's own edges, whole: a B-spline height field over the loop's best-fit plane
//! (`solidcraft_geom::fill::fill_loop`) when the patch only has to meet the edges (G0) and the
//! loop bounds one; otherwise the general fill (`solidcraft_geom::fill_general`), developed from
//! the loop itself, which also meets the faces beside the loop with G1 or G2 and passes through
//! given points.
use solidcraft_geom::Vec3;
use solidcraft_geom::bspline::SplineError;
use solidcraft_geom::fill_general::{Across, FillSpec, MAX_FILL_POINTS, Station};
use truck_modeling as mt;
use crate::body::{Body, from_p3};
use crate::bridge::{Continuity, frames_across};
use crate::plane_fit::EdgeSampler;
use crate::record::{Rebuild, rebuilt_body};
use crate::{KernelError, Result};
/// How far (degrees) a G1 or G2 patch's normal may turn from its neighbour's along the loop.
pub const PATCH_ANGLE_TOL_DEG: f64 = 0.05;
/// A spline error as the patch reports it.
fn spline_error(e: SplineError) -> KernelError {
match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
}
}
/// What a patch on a loop of edges must do besides filling it.
#[derive(Clone, Debug, PartialEq)]
pub struct PatchSpec {
/// How every edge's neighbour is met (G0, G1 or G2).
pub continuity: Continuity,
/// Edges met otherwise: a point on the edge and its continuity.
pub edges: Vec<(Vec3, Continuity)>,
/// Points the patch passes through.
pub points: Vec<Vec3>,
/// How full the patch's middle is, `crown` times the loop's size (−0.9…3): the bump of
/// [`patch_edges_crowned`], laid along the general fill's own normal there (to the side its
/// G1/G2 neighbours' normals face) on control points clear of the loop, so the loop and the
/// conditions across it do not move. A general fill needs a G1 or G2 edge to crown.
pub crown: f64,
}
impl Default for PatchSpec {
fn default() -> PatchSpec {
PatchSpec { continuity: Continuity::G0, edges: Vec::new(), points: Vec::new(), crown: 0.0 }
}
}
impl PatchSpec {
/// Whether this is the plain patch of [`patch_edges_crowned`]: G0 everywhere, no points.
pub fn is_plain(&self) -> bool {
self.continuity == Continuity::G0 && self.edges.iter().all(|(_, g)| *g == Continuity::G0) && self.points.is_empty()
}
}
/// What a general fill achieved (all zero for a planar or height-field patch, which meet their
/// edges within [`face_tolerance`] and ask nothing else).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PatchReport {
/// Whether the general fill made the face.
pub general: bool,
/// The farthest loop station from the surface.
pub gap: f64,
/// The largest angle (degrees) between the patch's normal and a G1/G2 neighbour's, away from
/// corners where the neighbours themselves disagree, and anywhere.
pub normal_deviation_deg: f64,
pub normal_deviation_all_deg: f64,
/// The largest difference (1/mm) between the patch's normal curvature across a G2 edge and
/// the neighbour's.
pub curvature_mismatch: f64,
/// The farthest point from the surface.
pub point_gap: f64,
}
/// [`crate::patch_edges`] meeting the faces beside the loop with the continuity `spec` asks for
/// and passing through its points; a plain spec is exactly [`patch_edges_crowned`]. Its history
/// is over `[body]`: the face is new, its edges the body's own.
pub fn patch_edges_with(body: &Body, at: &[Vec3], spec: &PatchSpec) -> Result<Body> {
patch_edges_report(body, at, spec).map(|(b, _)| b)
}
/// [`patch_edges_with`] and what the fill achieved.
pub fn patch_edges_report(body: &Body, at: &[Vec3], spec: &PatchSpec) -> Result<(Body, PatchReport)> {
if at.is_empty() || at.len() > 10_000 {
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
}
// A range test also throws out NaN.
if !(solidcraft_geom::fill::MIN_CROWN..=solidcraft_geom::fill::MAX_CROWN).contains(&spec.crown) {
return Err(KernelError::Invalid("a patch's crown must be between -0.9 and 3".into()));
}
let g3 = |g: Continuity| g == Continuity::G3;
if g3(spec.continuity) || spec.edges.iter().any(|(_, g)| g3(*g)) {
return Err(KernelError::Invalid("not supported yet: a patch meets its neighbours with g0, g1 or g2".into()));
}
if spec.edges.len() > 10_000 || spec.edges.iter().any(|(p, _)| !p.is_finite()) {
return Err(KernelError::Invalid("pick at most 10000 edges, each by a finite point, for their continuity".into()));
}
if spec.points.len() > MAX_FILL_POINTS || spec.points.iter().any(|p| !p.is_finite()) {
return Err(KernelError::Invalid(format!("a patch passes through at most {MAX_FILL_POINTS} finite points")));
}
if spec.is_plain() {
return patch_edges_crowned(body, at, spec.crown).map(|b| (b, PatchReport::default()));
}
body.require_brep("patch")?;
crate::guard("patch", || {
let chain = crate::surfaces::picked_loop(body, at)?;
let mut orders = vec![spec.continuity; chain.len()];
for (p, g) in &spec.edges {
let e = crate::surfaces::edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
let k = chain
.iter()
.position(|c| c.id() == e.id())
.ok_or_else(|| KernelError::Invalid("a continuity pick is not on an edge of the loop".into()))?;
if let Some(slot) = orders.get_mut(k) {
*slot = *g;
}
}
if spec.crown != 0.0 && orders.iter().all(|g| *g == Continuity::G0) {
return Err(KernelError::Invalid("not supported yet: a crown on a patch through points with no g1 or g2 edge".into()));
}
general_fill(body, &chain, &orders, &spec.points, spec.crown)
})
}
/// One edge of the loop as the general fill samples it.
struct Side {
sampler: EdgeSampler,
start: Vec3,
/// The face beside the edge, and whether its boundary runs the edge the way the loop does.
face: mt::Face,
along: bool,
order: Continuity,
/// The loop has a corner at the edge's start, and the neighbours' tangent planes disagree
/// there.
corner: bool,
clash: bool,
}
impl Side {
fn point(&mut self, t: f64) -> Option<Vec3> {
// The loop's corners are its vertices (an imported edge's curve may end a little off).
if t <= 0.0 { Some(self.start) } else { self.sampler.at(t) }
}
/// The loop's unit tangent at `t`, by a central difference (one-sided at the ends).
fn tangent(&mut self, t: f64) -> Option<Vec3> {
const STEP: f64 = 1e-4;
let (a, b) = ((t - STEP).max(0.0), (t + STEP).min(1.0));
let (p, q) = (self.sampler.at(a)?, self.sampler.at(b)?);
(q - p).normalized()
}
/// The face's frames at points of this edge with the loop's tangents there.
fn frames(&self, stations: &[(Vec3, Vec3)], size: f64, shape: bool) -> Result<Vec<(crate::Frame, Vec3)>> {
let along: Vec<(Vec3, Vec3)> = stations.iter().map(|(p, t)| (*p, if self.along { *t } else { *t * -1.0 })).collect();
frames_across(&self.face, &along, size, shape)
}
}
/// The general fill of the closed loop `chain` of `body`'s edges, edge `k` meeting the face beside
/// it with `orders[k]`, passing through `points`, its middle made fuller by `crown` (see
/// [`PatchSpec::crown`]). Call it under [`crate::guard`].
pub(crate) fn general_fill(body: &Body, chain: &[mt::Edge], orders: &[Continuity], points: &[Vec3], crown: f64) -> Result<(Body, PatchReport)> {
let tol = face_tolerance(body);
let size = body.size().max(solidcraft_geom::LINEAR_TOL);
let mut sides: Vec<Side> = Vec::with_capacity(chain.len());
for (e, order) in chain.iter().zip(orders) {
let faces: Vec<&mt::Face> = body.solid.face_iter().filter(|f| f.edge_iter().any(|x| x.id() == e.id())).collect();
let face = match faces.as_slice() {
[f] => (*f).clone(),
[f, ..] if *order == Continuity::G0 => (*f).clone(),
[] => return Err(KernelError::Failed("patch: an edge of the loop bounds no face".into())),
_ => return Err(KernelError::Invalid("an edge of the loop lies between two faces: g1 and g2 need the one face beside it".into())),
};
let along = face
.boundaries()
.iter()
.flat_map(|w| w.edge_iter())
.find(|x| x.id() == e.id())
.map(|x| x.orientation() == e.orientation())
.ok_or_else(|| KernelError::Failed("patch: the face beside an edge does not hold it".into()))?;
let sampler = EdgeSampler::new(e).ok_or_else(|| KernelError::Invalid("an edge has no parameter range".into()))?;
sides.push(Side { sampler, start: from_p3(e.front().point()), face, along, order: *order, corner: false, clash: false });
}
// The corners: where the loop's tangent turns, and where the faces beside it disagree.
let n = sides.len();
for k in 0..n {
let prev = (k + n - 1) % n;
let (Some(t_in), Some(t_out)) = (sides.get_mut(prev).and_then(|s| s.tangent(1.0)), sides.get_mut(k).and_then(|s| s.tangent(0.0))) else {
return Err(KernelError::Invalid("an edge of the loop has no tangent".into()));
};
let Some(vertex) = sides.get(k).map(|s| s.start) else { continue };
let corner = t_in.dot(t_out) < CORNER_DEG.to_radians().cos();
let normal = |s: Option<&Side>, t: Vec3| -> Result<Vec3> {
let s = s.ok_or_else(|| KernelError::Failed("patch: side".into()))?;
let f = s.frames(&[(vertex, t)], size, false)?;
f.first().map(|f| f.1).ok_or_else(|| KernelError::Failed("patch: frame".into()))
};
let g1 = |s: Option<&Side>| s.is_some_and(|s| s.order != Continuity::G0);
let clash = if g1(sides.get(prev)) || g1(sides.get(k)) {
let (a, b) = (normal(sides.get(prev), t_in)?, normal(sides.get(k), t_out)?);
a.dot(b).abs() < (2.0 * PATCH_ANGLE_TOL_DEG).to_radians().cos()
} else {
false
};
if let Some(s) = sides.get_mut(k) {
s.corner = corner;
s.clash = clash;
}
}
let mut stations = |k: usize, ts: &[f64]| -> std::result::Result<Vec<Station>, String> {
let side = sides.get_mut(k).ok_or("no such side")?;
let mut at = Vec::with_capacity(ts.len());
for &t in ts {
let (Some(p), Some(tangent)) = (side.point(t), side.tangent(t)) else { return Err("an edge cannot be sampled".into()) };
at.push((p, tangent));
}
let frames = side.frames(&at, size, side.order == Continuity::G2).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(ts.len());
for ((&t, (p, tangent)), (frame, normal)) in ts.iter().zip(&at).zip(&frames) {
let up = tangent.cross(frame.leave).normalized().ok_or("the loop runs across its neighbour")?;
let across = (side.order != Continuity::G0).then(|| Across {
leave: frame.leave,
normal: *normal,
bend: (side.order == Continuity::G2).then_some(frame.bend),
});
let first = t <= 0.0;
out.push(Station { p: *p, up, across, corner: first && side.corner, clash: first && side.clash });
}
Ok(out)
};
let spec = FillSpec { tol, angle_tol: PATCH_ANGLE_TOL_DEG.to_radians(), crown, points };
let fill = solidcraft_geom::fill_general::fill_general(chain.len(), &mut stations, &spec).map_err(spline_error)?;
// The wire runs counter-clockwise about the surface's normal.
let wire: mt::Wire =
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
let face = mt::Face::try_new(vec![wire], crate::spline_conv::surface(&fill.surface)?).map_err(|e| KernelError::Failed(format!("fill: {e}")))?;
let mut rec = Rebuild::default();
rec.made(&face, Vec::new());
let report = PatchReport {
general: true,
gap: fill.deviation,
normal_deviation_deg: fill.normal_deviation.to_degrees(),
normal_deviation_all_deg: fill.normal_deviation_all.to_degrees(),
curvature_mismatch: fill.curvature_mismatch,
point_gap: fill.point_deviation,
};
Ok((rebuilt_body(crate::surfaces::surface_body(vec![face]), &[&body.solid], &rec), report))
}
/// The loop turns by more than this (degrees) at a corner: its stations hold position only.
const CORNER_DEG: f64 = 1.0;
/// A surface body of one face filling the closed loop `chain` of `body`'s edges (each edge
/// oriented along the loop, each ending where the next starts), within [`face_tolerance`] of
/// every edge. Its history is over `[body]`: the face is new, its edges the body's own. Call it
@@ -34,10 +277,16 @@ pub(crate) fn fill_loop_crowned(body: &Body, chain: &[mt::Edge], crown: f64) ->
// The loop's corners are its vertices (an imported edge's curve may end a little off).
if t <= 0.0 { Some(*start) } else { sampler.at(t) }
};
let fill = solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown).map_err(|e| match e {
SplineError::Unsupported(m) => KernelError::Invalid(format!("not supported yet: {m}")),
other => KernelError::Invalid(format!("cannot fill the loop: {other}")),
})?;
let fill = match solidcraft_geom::fill::fill_loop(chain.len(), &mut point, tol, crown) {
Ok(fill) => fill,
// Not a height field over its plane (or too wavy for one): the general fill, developed
// from the loop itself, when no crown asks for the height field's bump.
Err(SplineError::Unsupported(_)) if crown == 0.0 => {
let orders = vec![Continuity::G0; chain.len()];
return general_fill(body, chain, &orders, &[], 0.0).map(|(b, _)| b);
}
Err(e) => return Err(spline_error(e)),
};
// The wire runs counter-clockwise about the surface's normal.
let wire: mt::Wire =
if fill.counter_clockwise { chain.to_vec().into() } else { chain.iter().rev().map(mt::Edge::inverse).collect::<Vec<_>>().into() };
@@ -489,9 +738,11 @@ mod tests {
}
#[test]
fn a_loop_that_does_not_bound_a_height_field_is_refused() {
fn a_loop_that_does_not_bound_a_height_field_fills_with_one_face() {
// Half a round post's side: two straight edges up and two half circles, whose plane
// stands on the post's axis; the half circles run along its normal at their ends.
// stands on the post's axis; the half circles run along its normal at their ends, so no
// height field over it bounds them (the height-field fill refuses it). The general fill
// (#759) fills it with one face on its four edges.
let post = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
let halves = crate::split_by_plane(&post, &Plane::from_normal(Vec3::ZERO, Vec3::X).unwrap()).unwrap();
let half = halves.into_iter().find(|b| measure(b).unwrap().bbox.min.x > -1e-6).unwrap();
@@ -499,9 +750,16 @@ mod tests {
(-4..=4).map(|k| Vec2::from_angle(k as f64 * 0.35).extend(10.0) * 1.0).map(|p| Vec3::new(p.x * 10.0, p.y * 10.0, 10.0)).collect();
let side = copy_faces(&half, &at).unwrap();
let rim = free_edge_points(&side);
let err = patch_edges(&side, &rim).unwrap_err().to_string();
eprintln!("half a post's side: {err}");
assert!(err.contains("not supported yet"), "{err}");
let fill = patch_edges(&side, &rim).unwrap();
assert_eq!(fill.face_count(), 1, "one face");
let face = fill.solid.face_iter().next().unwrap().clone();
let (off, tol) = (edges_off_surface(&face), super::face_tolerance(&side));
assert!(off <= tol, "an edge is {off} off the face (tolerance {tol})");
// Its area is at least its shadow on the plane x = 0 (the 20 × 20 square the loop bounds
// there) and, a least-bending fill, no more than the half side it spans.
let area = measure(&fill).unwrap().area;
eprintln!("half a post's side: one face of area {area}, its edges within {off:e}");
assert!(area > 400.0 && area < std::f64::consts::PI * 10.0 * 20.0, "{area}");
}
#[test]
@@ -611,3 +869,7 @@ mod tests {
}
}
}
#[cfg(test)]
#[path = "fill_general_tests.rs"]
mod general_tests;
+506
View File
@@ -0,0 +1,506 @@
//! The general fill on bodies (#759): G1 and G2 to the faces beside the loop, loops that are not
//! height fields, N-sided loops and points the patch passes through. Every number is checked
//! against the analytic surface the loop was cut from, or against a quadrature of the fitted
//! surface itself, never against the mesher alone.
use mt::{BoundedCurve, InnerSpace, ParametricCurve, ParametricSurface, ParametricSurface3D};
use solidcraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use truck_modeling as mt;
use super::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, face_tolerance, patch_edges_report};
use crate::body::{Body, from_p3, p3};
use crate::bridge::Continuity;
use crate::{BoolOp, boolean, box_solid, copy_faces, cylinder, extrude, measure, patch_edges, stitch, torus};
/// The surface's parameters nearest `p`: the nearest of a grid of samples (or `hint`), then
/// Gauss–Newton within the domain.
fn nearest(s: &mt::Surface, p: Vec3, hint: Option<(f64, f64)>) -> (f64, f64) {
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!("a bounded surface") };
let p = p3(p);
let (mut u, mut v) = match hint {
Some(h) => h,
None => {
let n = 100;
let mut best = (f64::MAX, u0, v0);
for i in 0..=n {
for j in 0..=n {
let (u, v) = (u0 + (u1 - u0) * i as f64 / n as f64, v0 + (v1 - v0) * j as f64 / n as f64);
let d = (s.subs(u, v) - p).magnitude();
if d < best.0 {
best = (d, u, v);
}
}
}
(best.1, best.2)
}
};
for _ in 0..60 {
let (q, a, b) = (s.subs(u, v), s.uder(u, v), s.vder(u, v));
let r = q - p;
let (aa, ab, bb) = (a.dot(a), a.dot(b), b.dot(b));
let det = aa * bb - ab * ab;
if det.abs() < 1e-300 {
break;
}
let (ga, gb) = (a.dot(r), b.dot(r));
u = (u - (bb * ga - ab * gb) / det).clamp(u0, u1);
v = (v - (aa * gb - ab * ga) / det).clamp(v0, v1);
}
(u, v)
}
fn v3(v: mt::Vector3) -> Vec3 {
Vec3::new(v.x, v.y, v.z)
}
/// Points along every edge of the loop: `per` on each.
fn rim_points(face: &mt::Face, per: usize) -> Vec<Vec3> {
let mut out = Vec::new();
for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) {
let c = e.oriented_curve();
let (t0, t1) = c.range_tuple();
for k in 0..per {
out.push(from_p3(c.subs(t0 + (t1 - t0) * (k as f64 + 0.5) / per as f64)));
}
}
out
}
/// Along the loop, densely: the farthest a rim point is from the patch, the largest angle
/// (degrees) between the patch's normal and the neighbour's exact one (`normal(p)`, one sign for
/// the whole loop: a fold would show as 180°), and the largest difference between the patch's
/// normal curvature across the loop and `curvature(p)`, the neighbour's.
fn along_the_loop(fill: &Body, normal: &dyn Fn(Vec3) -> Vec3, curvature: &dyn Fn(Vec3) -> f64) -> (f64, f64, f64) {
let face = fill.solid.face_iter().next().unwrap().clone();
let s = face.surface();
let pts = rim_points(&face, 200);
let mut found = Vec::with_capacity(pts.len());
let mut hint = None;
for p in &pts {
let uv = nearest(&s, *p, hint);
hint = Some(uv);
found.push(uv);
}
// One sign for the whole loop: the one most rim points agree with.
let patch_normal = |(u, v): (f64, f64)| v3(s.normal(u, v));
let agree: f64 = pts.iter().zip(&found).map(|(p, uv)| patch_normal(*uv).dot(normal(*p)).signum()).sum();
let sign = agree.signum();
let (mut gap, mut angle, mut kappa) = (0.0f64, 0.0f64, 0.0f64);
for (k, (p, &(u, v))) in pts.iter().zip(&found).enumerate() {
gap = gap.max(from_p3(s.subs(u, v)).dist(*p));
let n = patch_normal((u, v));
let want = normal(*p) * sign;
angle = angle.max(n.dot(want).clamp(-1.0, 1.0).acos().to_degrees());
// Across the loop: the tangent-plane direction square to the rim.
let next = pts.get(k + 1).or(pts.get(k.wrapping_sub(1))).copied().unwrap();
let t = (next - *p).normalized().unwrap();
let Some(d) = n.cross(t).normalized() else { continue };
let (su, sv) = (v3(s.uder(u, v)), v3(s.vder(u, v)));
// d = a·su + b·sv.
let (aa, ab, bb) = (su.dot(su), su.dot(sv), sv.dot(sv));
let det = aa * bb - ab * ab;
let (a, b) = ((bb * d.dot(su) - ab * d.dot(sv)) / det, (aa * d.dot(sv) - ab * d.dot(su)) / det);
// The second derivatives by central differences of the surface's own points.
let h = 1e-3;
let e = |du: f64, dv: f64| from_p3(s.subs(u + du * h, v + dv * h));
let suu = (e(1.0, 0.0) - e(0.0, 0.0) * 2.0 + e(-1.0, 0.0)) * (1.0 / (h * h));
let svv = (e(0.0, 1.0) - e(0.0, 0.0) * 2.0 + e(0.0, -1.0)) * (1.0 / (h * h));
let suv = (e(1.0, 1.0) - e(1.0, -1.0) - e(-1.0, 1.0) + e(-1.0, -1.0)) * (0.25 / (h * h));
let second = suu * (a * a) + suv * (2.0 * a * b) + svv * (b * b);
kappa = kappa.max((second.dot(want) - curvature(*p)).abs());
}
(gap, angle, kappa)
}
/// `∫∫ f(S, S_u × S_v) du dv` over the face's own region of its surface (its loop taken into
/// parameter space by the nearest parameters of points along it), by the midpoint rule on a
/// `n × n` grid over the region's box: the quadrature the mesher's area is checked against.
fn quadrature(face: &mt::Face, n: usize, f: &dyn Fn(Vec3, Vec3) -> f64) -> f64 {
let s = face.surface();
let mut poly: Vec<(f64, f64)> = Vec::new();
let mut hint = None;
for p in rim_points(face, 400) {
let uv = nearest(&s, p, hint);
hint = Some(uv);
poly.push(uv);
}
let (mut lo, mut hi) = ((f64::MAX, f64::MAX), (f64::MIN, f64::MIN));
for &(u, v) in &poly {
lo = (lo.0.min(u), lo.1.min(v));
hi = (hi.0.max(u), hi.1.max(v));
}
let inside = |u: f64, v: f64| {
let mut w = false;
for k in 0..poly.len() {
let (a, b) = (poly[k], poly[(k + 1) % poly.len()]);
if (a.1 > v) != (b.1 > v) && u < a.0 + (v - a.1) / (b.1 - a.1) * (b.0 - a.0) {
w = !w;
}
}
w
};
let (du, dv) = ((hi.0 - lo.0) / n as f64, (hi.1 - lo.1) / n as f64);
let mut sum = 0.0;
for i in 0..n {
for j in 0..n {
let (u, v) = (lo.0 + (i as f64 + 0.5) * du, lo.1 + (j as f64 + 0.5) * dv);
if inside(u, v) {
sum += f(from_p3(s.subs(u, v)), v3(s.uder(u, v)).cross(v3(s.vder(u, v)))) * du * dv;
}
}
}
sum
}
/// The fill is one face on the open body's own edges, within the fill's tolerance of them; its
/// mesh has no folded triangle (each faces the way its corners' normals do); and the mesher's
/// area agrees with a quadrature of the surface over its own region. Returns the area.
fn check_face(name: &str, open: &Body, fill: &Body) -> f64 {
assert!(fill.is_surface());
assert_eq!(fill.face_count(), 1, "{name}: one face");
let own: Vec<mt::EdgeID> = open.solid.edge_iter().map(|e| e.id()).collect();
assert!(fill.solid.edge_iter().all(|e| own.contains(&e.id())), "{name}: an edge that is not the body's");
let face = fill.solid.face_iter().next().unwrap().clone();
let s = face.surface();
let tol = face_tolerance(open);
let mut off = 0.0f64;
let mut hint = None;
for p in rim_points(&face, 64) {
let uv = nearest(&s, p, hint);
hint = Some(uv);
off = off.max(from_p3(s.subs(uv.0, uv.1)).dist(p));
}
assert!(off <= tol * 1.01, "{name}: an edge is {off} off the face (tolerance {tol})");
let m = fill.tessellate(0.01).unwrap();
assert!(!m.triangles.is_empty(), "{name}: the face meshes");
for t in &m.triangles {
let [a, b, c] = m.tri(t).unwrap();
let v = (b - a).cross(c - a);
let n: Vec3 = t.iter().map(|&i| m.normals[i as usize]).fold(Vec3::ZERO, |s, x| s + x);
if v.len() > 1e-10 {
assert!(v.dot(n) > 0.0, "{name}: a folded triangle");
}
}
let area = measure(fill).unwrap().area;
let quad = quadrature(&face, 500, &|_, n| n.len());
eprintln!("{name}: one face, edges within {off:e} (tolerance {tol:e}), mesh area {area:.4}, quadrature {quad:.4}");
assert!((area - quad).abs() < 3e-3 * quad, "{name}: mesh area {area} against the surface's {quad}");
area
}
fn spec(g: Continuity) -> PatchSpec {
PatchSpec { continuity: g, ..PatchSpec::default() }
}
fn report(name: &str, r: &PatchReport) {
eprintln!(
"{name}: fill reports gap {:e}, normals within {:.4}° ({:.4}° near clashing corners), curvature mismatch {:e}, points within {:e}",
r.gap, r.normal_deviation_deg, r.normal_deviation_all_deg, r.curvature_mismatch, r.point_gap
);
}
/// A cylinder about z of radius 10 and 30 high with its top face taken off; points around the rim.
fn open_tube() -> (Body, Vec<Vec3>) {
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 30.0).unwrap();
let open = copy_faces(&solid, &[&around(10.0, 15.0)[..], &[Vec3::ZERO]].concat()).unwrap();
assert!(open.is_surface());
let rim = (0..8).map(|k| Vec2::from_angle(k as f64 * 0.785).extend(0.0) * 10.0 + Vec3::Z * 30.0).collect();
(open, rim)
}
/// `body` without its planar faces but those at height `z0` or `z1` (the ends of a cylinder):
/// a cut solid's walls (and ends) as a surface, the faces the cut made taken away.
fn curved_and_ends(body: &Body, ends: &[f64]) -> Body {
let drop: Vec<usize> = body
.solid
.face_iter()
.enumerate()
.filter(|(_, f)| match f.surface() {
mt::Surface::Plane(p) => !ends.iter().any(|z| (p.origin().z - z).abs() < 1e-9 && p.normal().z.abs() > 1.0 - 1e-12),
_ => false,
})
.map(|(i, _)| i)
.collect();
let open = crate::remove_faces(body, &drop).unwrap();
assert!(open.is_surface());
open
}
/// A point on each edge that bounds only one face of `body` (its free edges): the middle of
/// each, where the edge picker samples it.
fn free_edge_points(body: &Body) -> Vec<Vec3> {
let mut uses: std::collections::HashMap<mt::EdgeID, (usize, mt::Edge)> = std::collections::HashMap::new();
for f in body.solid.face_iter() {
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
uses.entry(e.id()).or_insert((0, e.clone())).0 += 1;
}
}
let mut points = uses
.values()
.filter(|(n, _)| *n == 1)
.map(|(_, e)| {
let c = e.curve();
let (t0, t1) = c.range_tuple();
from_p3(c.subs(0.5 * (t0 + t1)))
})
.collect::<Vec<_>>();
// In a fixed order, whatever the map's.
points.sort_by(|a, b| (a.z, a.y, a.x).partial_cmp(&(b.z, b.y, b.x)).unwrap());
points
}
/// Points round the circle of radius `r` about z at height `z`, every 30°.
fn around(r: f64, z: f64) -> Vec<Vec3> {
(0..12).map(|k| Vec2::from_angle(0.1 + k as f64 * std::f64::consts::TAU / 12.0).extend(0.0) * r + Vec3::Z * z).collect()
}
fn radial(p: Vec3) -> Vec3 {
Vec3::new(p.x, p.y, 0.0).normalized().unwrap()
}
#[test]
fn an_opened_tube_end_capped_g1_and_g2_meets_the_wall() {
let (open, rim) = open_tube();
let mut kappas = Vec::new();
for g in [Continuity::G1, Continuity::G2] {
let name = format!("tube end {}", g.as_str());
let started = std::time::Instant::now();
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
eprintln!("{name}: filled in {:?}", started.elapsed());
report(&name, &r);
assert!(r.general);
let area = check_face(&name, &open, &fill);
// The wall's normal is radial and it does not bend along its axis.
let (gap, angle, kappa) = along_the_loop(&fill, &radial, &|_| 0.0);
eprintln!(
"{name}: along the rim (1600 points): within {gap:e} of it, normal within {angle:.5}° of the wall's, curvature across {kappa:e} /mm (the wall's 0)"
);
assert!(angle < 0.1, "{name}: the normal turns {angle}° from the wall's");
kappas.push(kappa);
// A dome over the rim, not a flat lid: more than the disc.
assert!(area > std::f64::consts::PI * 100.0 * 1.05, "{name}: {area}");
// Sealed: the tube's volume plus the dome's, the dome's by the divergence theorem over the
// patch (∫∫ (z − 30)·n_z dA), independent of the mesher.
let closed = stitch(&[&open, &fill], 0.0).unwrap();
assert!(!closed.is_surface(), "{name}: the cap closes the tube");
let face = fill.solid.face_iter().next().unwrap().clone();
let dome = quadrature(&face, 500, &|p, n| (p.z - 30.0) * n.z).abs();
let (got, want) = (measure(&closed).unwrap().volume, std::f64::consts::PI * 100.0 * 30.0 + dome);
eprintln!("{name}: sealed volume {got:.3}, tube + dome {want:.3}");
assert!((got - want).abs() < 2e-4 * want, "{name}: {got} vs {want}");
}
eprintln!("tube end: curvature across the rim g1 {:e}, g2 {:e} /mm", kappas[0], kappas[1]);
assert!(kappas[1] < kappas[0], "g2 holds the wall's curvature closer than g1");
}
/// A cylinder about z (radius 10, 40 high) with a window `half` either side of the x axis
/// through its wall, from z = 15 to 25; the wall and both ends without the faces the cut made (the
/// window's edges bound only the wall), and a point on each of the window's edges.
fn windowed(half: f64) -> (Body, Body, Vec<Vec3>) {
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 40.0).unwrap();
let x = 10.0 * half.cos();
let tool = box_solid(Vec3::new(x, -11.0, 15.0), Vec3::new(11.0, 11.0, 25.0)).unwrap();
let cut = boolean(&solid, &tool, BoolOp::Cut).unwrap().unwrap();
let open = curved_and_ends(&cut, &[0.0, 40.0]);
let rim = free_edge_points(&open);
(solid, open, rim)
}
#[test]
fn a_window_cut_through_a_cylinder_wall_past_half_way_fills_g1_close_to_the_cylinder() {
// 240° round: seen along any direction the window folds over itself, so it bounds no height
// field; the old fill refused it.
let half = 2.0 * std::f64::consts::PI / 3.0;
let (solid, open, rim) = windowed(half);
for g in [Continuity::G0, Continuity::G1] {
let name = format!("window {}", g.as_str());
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
report(&name, &r);
let area = check_face(&name, &open, &fill);
let (gap, angle, _) = along_the_loop(&fill, &radial, &|_| 0.0);
eprintln!("{name}: along the window (1600 points): within {gap:e}, normal within {angle:.5}° of the wall's");
// How far the fill strays from the cylinder it replaces, inside.
let face = fill.solid.face_iter().next().unwrap().clone();
let s = face.surface();
let (Some((u0, u1)), Some((v0, v1))) = s.try_range_tuple() else { panic!() };
let mut stray = 0.0f64;
for i in 0..=40 {
for j in 0..=40 {
let p = from_p3(s.subs(u0 + (u1 - u0) * i as f64 / 40.0, v0 + (v1 - v0) * j as f64 / 40.0));
let ang = p.y.atan2(p.x);
if ang.abs() < half - 0.05 && p.z > 15.5 && p.z < 24.5 {
stray = stray.max((Vec2::new(p.x, p.y).len() - 10.0).abs());
}
}
}
let exact = 10.0 * 2.0 * half * 10.0;
let closed = stitch(&[&open, &fill], 0.0).unwrap();
let (got, want) = (measure(&closed).unwrap().volume, measure(&solid).unwrap().volume);
eprintln!(
"{name}: area {area:.3} (the cylinder's window {exact:.3}), the fill within {stray:.4} of the cylinder inside, sealed volume {got:.2} against the whole cylinder's {want:.2}"
);
assert!(!closed.is_surface());
if g == Continuity::G1 {
assert!(angle < 0.1, "{name}: the normal turns {angle}° from the wall's");
assert!((area - exact).abs() < 0.01 * exact && stray < 0.25, "{name}: {area} vs {exact}, stray {stray}");
assert!((got - want).abs() < 0.005 * want, "{name}: {got} vs {want}");
}
}
}
#[test]
fn a_saddle_window_in_a_torus_fills_g0_and_g1() {
// The inner wall of a torus (major 20, minor 8) is saddle-shaped; a window 240° round it
// between z = ±3 bounds no height field.
let solid = torus(Vec3::ZERO, 20.0, 8.0).unwrap();
let tool = box_solid(Vec3::new(-6.0, -13.0, -3.0), Vec3::new(13.0, 13.0, 3.0)).unwrap();
let cut = boolean(&solid, &tool, BoolOp::Cut).unwrap().unwrap();
let open = curved_and_ends(&cut, &[]);
let rim = free_edge_points(&open);
// The torus's normal: from the tube's centre circle.
let normal = |p: Vec3| {
let c = Vec3::new(p.x, p.y, 0.0).normalized().unwrap() * 20.0;
(p - c).normalized().unwrap()
};
for g in [Continuity::G0, Continuity::G1] {
let name = format!("torus window {}", g.as_str());
let (fill, r) = patch_edges_report(&open, &rim, &spec(g)).unwrap();
report(&name, &r);
check_face(&name, &open, &fill);
let (gap, angle, _) = along_the_loop(&fill, &normal, &|_| 0.0);
eprintln!("{name}: along the window (1600 points): within {gap:e}, normal within {angle:.5}° of the torus's");
if g == Continuity::G1 {
assert!(angle < 0.1, "{name}: {angle}°");
}
}
}
/// A cylinder about z (radius 10, 40 high) with a pentagonal pocket cut into its wall from the
/// +x side (the pentagon in the yz plane about z = 20, circumradius 6): the wall and both ends
/// copied out, and a point on each of the pocket rim's five edges.
fn pentagon_window() -> (Body, Vec<Vec3>) {
let solid = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 40.0).unwrap();
let corners: Vec<Vec2> = (0..5).map(|k| Vec2::from_angle(0.3 + k as f64 * std::f64::consts::TAU / 5.0) * 6.0 + Vec2::new(0.0, 20.0)).collect();
let prism = extrude(&Plane::YZ, &[Region2 { outer: Loop2::polygon(&corners), holes: vec![] }], 0.0, 15.0).unwrap().pop().unwrap();
let cut = boolean(&solid, &prism, BoolOp::Cut).unwrap().unwrap();
let open = curved_and_ends(&cut, &[0.0, 40.0]);
let rim = free_edge_points(&open);
(open, rim)
}
#[test]
fn a_five_sided_window_fills_as_one_face_g1_and_through_a_point() {
let (open, rim) = pentagon_window();
let (fill, r) = patch_edges_report(&open, &rim, &spec(Continuity::G1)).unwrap();
report("pentagon g1", &r);
assert!(fill.edge_count() >= 5, "the rim's own edges (five, or more where a seam splits one)");
check_face("pentagon g1", &open, &fill);
let (gap, angle, _) = along_the_loop(&fill, &radial, &|_| 0.0);
eprintln!("pentagon g1: along the rim: within {gap:e}, normal within {angle:.5}° of the wall's");
assert!(angle < 0.1, "{angle}°");
// Through a point half a millimetre out from the wall in the middle (G0 and G1).
let point = Vec3::new(10.5, 0.0, 20.0);
for g in [Continuity::G0, Continuity::G1] {
let name = format!("pentagon {} through a point", g.as_str());
let spec = PatchSpec { continuity: g, points: vec![point], ..PatchSpec::default() };
let (fill, r) = patch_edges_report(&open, &rim, &spec).unwrap();
report(&name, &r);
check_face(&name, &open, &fill);
let s = fill.solid.face_iter().next().unwrap().surface();
let (u, v) = nearest(&s, point, None);
let off = from_p3(s.subs(u, v)).dist(point);
eprintln!("{name}: the point is {off:e} off the surface");
assert!(off <= face_tolerance(&open), "{name}: {off}");
}
}
#[test]
fn a_hexagonal_rim_fills_through_a_point_and_g1_to_walls_meeting_at_angles_holds_away_from_the_corners() {
// A hexagonal prism's top cut by a sideways cylinder: six walls meeting at 120°.
let corners: Vec<Vec2> = (0..6).map(|k| Vec2::from_angle(0.3 + k as f64 * std::f64::consts::TAU / 6.0) * 20.0).collect();
let prism = extrude(&Plane::XY, &[Region2 { outer: Loop2::polygon(&corners), holes: vec![] }], 0.0, 20.0).unwrap().pop().unwrap();
let tool = cylinder(Vec3::new(0.0, -60.0, 50.0), Vec3::Y, 40.0, 120.0).unwrap();
let solid = boolean(&prism, &tool, BoolOp::Cut).unwrap().unwrap();
let mut keep = vec![Vec3::ZERO];
let mut rim = Vec::new();
for k in 0..6 {
let m = corners[k].lerp(corners[(k + 1) % 6], 0.5);
keep.push(m.extend(5.0));
rim.push(m.extend(50.0 - (1600.0 - m.x * m.x).sqrt()));
}
let open = copy_faces(&solid, &keep).unwrap();
let plain = patch_edges(&open, &rim).unwrap();
let sealed = |f: &Body| measure(&stitch(&[&open, f], 0.0).unwrap()).unwrap().volume;
// Six sides, G0, through a point 3 mm above the middle of the plain fill: one face.
let s = plain.solid.face_iter().next().unwrap().surface();
let (u, v) = nearest(&s, Vec3::new(0.0, 0.0, 12.0), None);
let point = from_p3(s.subs(u, v)) + Vec3::Z * 3.0;
let (lifted, r) = patch_edges_report(&open, &rim, &PatchSpec { points: vec![point], ..PatchSpec::default() }).unwrap();
report("hexagon through a point", &r);
assert_eq!(lifted.edge_count(), 6, "the rim's own six edges");
check_face("hexagon through a point", &open, &lifted);
let s = lifted.solid.face_iter().next().unwrap().surface();
let (u, v) = nearest(&s, point, None);
let off = from_p3(s.subs(u, v)).dist(point);
eprintln!("hexagon through a point: the point is {off:e} off the surface; sealed {:.2} against {:.2} plain", sealed(&lifted), sealed(&plain));
assert!(off <= face_tolerance(&open) && sealed(&lifted) > sealed(&plain));
// G1 to the walls: at a corner no surface is tangent to both walls (its tangent plane there
// holds both rim edges), so G1 is asked only away from the corners. On walls standing square
// to the patch the corner is pinched too sharply for the finest grid to hold the rim within
// the fill's tolerance, and the fill says so.
match patch_edges_report(&open, &rim, &spec(Continuity::G1)) {
Ok((fill, r)) => {
report("hexagon g1", &r);
check_face("hexagon g1", &open, &fill);
assert!(r.normal_deviation_deg <= PATCH_ANGLE_TOL_DEG);
assert!(sealed(&fill) > sealed(&plain));
}
Err(e) => {
eprintln!("hexagon g1: {e}");
assert!(e.to_string().contains("not supported yet"), "{e}");
}
}
}
#[test]
fn per_edge_continuity_and_hostile_specs() {
let (open, rim) = pentagon_window();
// G1 on two edges only.
let spec = PatchSpec { edges: vec![(rim[0], Continuity::G1), (rim[2], Continuity::G2)], ..PatchSpec::default() };
let (fill, r) = patch_edges_report(&open, &rim, &spec).unwrap();
report("pentagon, two edges g1/g2", &r);
check_face("pentagon, two edges g1/g2", &open, &fill);
let bad = |spec: PatchSpec| patch_edges_report(&open, &rim, &spec).is_err();
let g1 = PatchSpec { continuity: Continuity::G1, ..PatchSpec::default() };
assert!(bad(PatchSpec { continuity: Continuity::G3, ..g1.clone() }));
assert!(bad(PatchSpec { edges: vec![(rim[0], Continuity::G3)], ..g1.clone() }));
assert!(bad(PatchSpec { edges: vec![(Vec3::new(0.0, 0.0, 40.0), Continuity::G1)], ..g1.clone() }), "an edge off the loop");
assert!(bad(PatchSpec { edges: vec![(Vec3::new(f64::NAN, 0.0, 0.0), Continuity::G1)], ..g1.clone() }));
assert!(bad(PatchSpec { points: vec![Vec3::new(f64::INFINITY, 0.0, 0.0)], ..g1.clone() }));
assert!(bad(PatchSpec { points: vec![Vec3::new(10.0, 0.0, 20.0); 65], ..g1.clone() }));
assert!(bad(PatchSpec { points: vec![Vec3::new(-10.0, 0.0, 20.0)], ..g1.clone() }), "a point off the far side");
for crown in [f64::NAN, 3.5, -1.0] {
assert!(bad(PatchSpec { crown, ..g1.clone() }), "crown {crown}");
}
assert!(bad(PatchSpec { crown: 0.1, points: vec![Vec3::new(10.2, 0.0, 20.0)], ..PatchSpec::default() }), "a crown with no g1 edge");
// A crown fills the middle out from the wall (the side the wall's normals face) and leaves
// the loop, and G1 across it, as they were.
let (plain, _) = patch_edges_report(&open, &rim, &g1).unwrap();
let (full, r) = patch_edges_report(&open, &rim, &PatchSpec { crown: 0.1, ..g1.clone() }).unwrap();
report("pentagon g1 crown 0.1", &r);
check_face("pentagon g1 crown 0.1", &open, &full);
let (_, angle, _) = along_the_loop(&full, &radial, &|_| 0.0);
let middle = |b: &Body| {
let s = b.solid.face_iter().next().unwrap().surface();
let (u, v) = nearest(&s, Vec3::new(10.0, 0.0, 20.0), None);
let p = from_p3(s.subs(u, v));
Vec2::new(p.x, p.y).len()
};
let (a, b) = (middle(&plain), middle(&full));
eprintln!("pentagon g1: the middle {a:.4} from the axis plain, {b:.4} with crown 0.1; normal within {angle:.5}° of the wall's");
assert!(b > a + 0.5 && angle < 0.1, "{a} {b} {angle}");
// The edges of a closed solid lie between two faces: g1 has no one face to meet.
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let top = [Vec3::new(5.0, 0.0, 10.0), Vec3::new(10.0, 5.0, 10.0), Vec3::new(5.0, 10.0, 10.0), Vec3::new(0.0, 5.0, 10.0)];
assert!(patch_edges_report(&b, &top, &PatchSpec { continuity: Continuity::G1, ..PatchSpec::default() }).is_err());
}
+2 -2
View File
@@ -122,14 +122,14 @@ pub use blend_corner::fillet_radii;
pub use blend_variable::{RadiusSpline, Taper, fillet_chord, fillet_tapered, fillet_variable, fillet_variable_points, fillet_variable_spline, taper};
pub use body::{Body, CapSignature, DISPLAY_ANGLE, EdgeInfo, FaceInfo, FacePaint, FaceSource, Paint};
pub use boolean_diagnostics::{BooleanDiagnostics, BooleanFallback, BooleanStrategy, capture_boolean_diagnostics};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, sample_grid};
pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid};
pub use build::{
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};
pub use fill::patch_edges_crowned;
pub use fill::{PATCH_ANGLE_TOL_DEG, PatchReport, PatchSpec, patch_edges_crowned, patch_edges_report, patch_edges_with};
pub use helix::{loft_to_point, sweep_helix, sweep_helix_mesh};
pub use iges_in::{MAX_IGES_BYTES, iges_import, iges_to_step};
pub use iges_out::iges_export_bodies;
+55 -10
View File
@@ -9,7 +9,7 @@ use solidcraft_engine::geom::Vec3;
use crate::SolveApp;
use crate::dialogs::{Kind, OP_LABELS, OPS, combo, field, op_index, row_label};
use crate::selection::{AXES, BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput};
use crate::selection::{AXES, BODIES, CURVES, EDGES, FACES, PLANES, PROFILES, SelInput, VERTICES};
/// A surface tool dialog's values.
#[derive(Clone, Debug, PartialEq)]
@@ -27,7 +27,11 @@ pub enum Surf {
},
Sweep(crate::dialogs_sweep::SweepOpts),
Loft,
Patch,
/// `continuity`: an index into [`PATCH_CONTINUITY`] (how a patch on edges meets the faces
/// beside its loop).
Patch {
continuity: usize,
},
Unstitch,
ReverseNormal,
Offset {
@@ -70,7 +74,7 @@ impl Surf {
Surf::Revolve { .. } => "SURFACE REVOLVE",
Surf::Sweep(_) => "SURFACE SWEEP",
Surf::Loft => "SURFACE LOFT",
Surf::Patch => "PATCH",
Surf::Patch { .. } => "PATCH",
Surf::Unstitch => "UNSTITCH",
Surf::ReverseNormal => "REVERSE NORMAL",
Surf::Offset { .. } => "OFFSET SURFACE",
@@ -97,6 +101,9 @@ impl Surf {
}
}
/// How a patch on edges meets the faces beside its loop (labels, and the `continuity` parameter).
const PATCH_CONTINUITY: [(&str, &str); 3] = [("Position (G0)", "g0"), ("Tangent (G1)", "g1"), ("Curvature (G2)", "g2")];
/// Which holes Untrim takes away: every one, the picked ones, or none (the outer boundary only).
const UNTRIM_HOLES: [&str; 3] = ["All", "Picked", "None"];
@@ -116,7 +123,7 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
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::Patch { .. } => vec![SelInput::new("Boundary", PROFILES | EDGES, true), SelInput::new("Pass through", VERTICES | FACES, true)],
Surf::Unstitch => vec![SelInput::new("Body, or faces to take out", BODIES | FACES, true)],
Surf::ReverseNormal => vec![SelInput::new("Surface, or faces to turn over", BODIES | FACES, true)],
Surf::Offset { .. } => vec![SelInput::new("Faces", FACES, true)],
@@ -143,7 +150,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.revolve" => Surf::Revolve { angle: "360 deg".into(), symmetric: false, axis: None },
"surface.sweep" => Surf::Sweep(crate::dialogs_sweep::SweepOpts::default()),
"surface.loft" => Surf::Loft,
"surface.patch" => Surf::Patch,
"surface.patch" => Surf::Patch { continuity: 0 },
"surface.unstitch" => Surf::Unstitch,
"surface.reverse_normal" => Surf::ReverseNormal,
"surface.offset" => Surf::Offset { distance: "2 mm".into(), chain: false },
@@ -247,8 +254,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
ui.checkbox(in_place, "");
ui.end_row();
}
Surf::Patch { continuity } => {
row_label(ui, "Continuity");
combo(ui, "pa_cont", &PATCH_CONTINUITY.map(|(label, _)| label), continuity);
ui.end_row();
}
Surf::Sweep(opts) => enter |= crate::dialogs_sweep::rows(ui, opts, "ss"),
Surf::Patch | Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
Surf::Trim | Surf::SplitFace | Surf::Unstitch | Surf::ReverseNormal | Surf::Loft => {}
}
enter
}
@@ -390,7 +402,7 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
.collect::<Result<_, String>>()?;
("surface.loft", json!({ "sections": sections }))
}
Surf::Patch => {
Surf::Patch { continuity } => {
need(0, "a boundary (sketch profiles or a loop of edges)")?;
let profiles: Vec<(u64, usize)> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Profile { sketch, index } = x { Some((*sketch, *index)) } else { None }).collect();
@@ -401,7 +413,21 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
let edges: Vec<Value> =
items(inputs, 0).iter().filter_map(|x| if let Sel::Edge { point, .. } = x { Some(pt(*point)) } else { None }).collect();
let body = items(inputs, 0).iter().find_map(|x| if let Sel::Edge { body, .. } = x { Some(body.clone()) } else { None });
("surface.patch", json!({"edges": edges, "body": body}))
let mut p = json!({"edges": edges, "body": body});
if let Some((_, word)) = PATCH_CONTINUITY.get(*continuity).filter(|_| *continuity > 0) {
p["continuity"] = json!(word);
}
let points: Vec<Value> = items(inputs, 1)
.iter()
.filter_map(|x| match x {
Sel::Vertex { point, .. } | Sel::Face { point, .. } => Some(pt(*point)),
_ => None,
})
.collect();
if !points.is_empty() {
p["points"] = json!(points);
}
("surface.patch", p)
}
}
Surf::Offset { distance, chain } => {
@@ -614,7 +640,7 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
extra.insert("convention".into(), json!(extent.convention));
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::Patch { sketch, edges, .. } => {
FeatureKind::Patch { sketch, edges, crown, continuity, points, .. } => {
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
if let Some(sk) = sketch {
let st = s.world_state();
@@ -622,7 +648,26 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
sels.extend((0..ss.profiles.len()).map(|index| Sel::Profile { sketch: *sk, index }));
}
}
(Surf::Patch, vec![sels])
let index = PATCH_CONTINUITY.iter().position(|(_, w)| *w == continuity.all.as_str()).unwrap_or(0);
// What the dialog does not show is kept as it was: the crown, edges with a
// continuity of their own, and points that are not on a face any more.
let through: Vec<Sel> = points.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect();
let mut extra = Map::new();
if *crown != 0.0 {
extra.insert("crown".into(), json!(crown));
}
if !continuity.edges.is_empty() {
extra.insert("continuity".into(), serde_json::to_value(continuity).unwrap_or(Value::Null));
}
if through.len() != points.len() {
extra.insert("points".into(), json!(points.iter().map(|p| pt(*p)).collect::<Vec<_>>()));
}
let k = Surf::Patch { continuity: index };
let mut ins = inputs(&k);
for (input, picks) in ins.iter_mut().zip([sels, through]) {
input.items = picks;
}
return Some((Kind::Surface(k), ins, extra));
}
FeatureKind::SurfaceOffset { faces, distance, chain, .. } => {
(Surf::Offset { distance: distance.clone(), chain: *chain }, vec![faces.iter().filter_map(|p| crate::dialogs::face_sel(s, *p)).collect()])
+51
View File
@@ -1216,3 +1216,54 @@ fn a_primitive_without_a_pick_or_with_its_position_cleared_stays_at_the_origin()
let (p, _) = primitive_on(&mut app, vec![v], "solid.sphere", |_| {});
assert_eq!(p["center"], json!([40.0, 30.0, 20.0]), "{p}");
}
#[test]
fn patch_dialog_takes_continuity_and_points_and_brings_them_back() {
// A tube with its top face taken off: the rim's edges, G1, through a point above it.
let mut app = app_with(json!([
{"command": "solid.cylinder", "params": {"radius": 10, "height": 30, "body_name": "Tube"}},
{"command": "surface.unstitch", "params": {"body": "Tube", "faces": [[0, 0, 30]]}}
]));
let rim: Vec<Sel> = {
let st = app.session.model.state();
let m = st.body("Tube").unwrap().mesh();
m.edges
.iter()
.enumerate()
.filter(|(_, e)| e.iter().all(|p| (p.z - 30.0).abs() < 1e-6))
.map(|(i, e)| Sel::Edge { body: "Tube".into(), index: i, point: e[e.len() / 2] })
.collect()
};
assert!(!rim.is_empty());
let mut d = start(&mut app, vec![], "surface.patch");
d.inputs[0].items = rim;
d.inputs[1].items = vec![Sel::Vertex { body: "Tube".into(), point: Vec3::new(0.0, 0.0, 36.0) }];
if let Kind::Surface(crate::dialogs_surface::Surf::Patch { continuity }) = &mut d.kind {
*continuity = 1;
}
let (id, p) = apply_commands(&app, &d).unwrap().remove(0);
assert_eq!((id.as_str(), &p["continuity"], &p["points"]), ("surface.patch", &json!("g1"), &json!([[0.0, 0.0, 36.0]])), "{p}");
app.session.execute(&id, &p).unwrap();
let top = app.session.model.state().bodies.last().unwrap().mesh().bounds().max.z;
assert!(top > 35.9, "the cap reaches the point: {top}");
// Edit Feature: G1 again, and the point (on no face) kept as it was.
let fid = app.session.doc.features.last().unwrap().id;
let back = crate::dialogs::for_feature(&app, fid, None).unwrap();
assert!(matches!(back.kind, Kind::Surface(crate::dialogs_surface::Surf::Patch { continuity: 1 })));
let (_, p) = apply_commands(&app, &back).unwrap().remove(0);
assert_eq!((&p["params"]["continuity"], &p["params"]["points"]), (&json!("g1"), &json!([[0.0, 0.0, 36.0]])), "{p}");
}
#[test]
fn patch_dialog_rows_are_translated() {
for (lang, continuity, through) in [("en", "Continuity", "Pass through"), ("de", "Stetigkeit", "Durch Punkte"), ("ja", "連続性", "通過点")]
{
let mut h = crate::scenario::Harness::new();
h.app.preferences.language = lang.into();
h.app.home.open = false;
h.call("ui.start", json!({"command": "surface.patch"}));
h.frames(3);
assert!(h.widget(continuity).is_some(), "{lang}: the Continuity row must show {continuity:?}");
assert!(h.widget(through).is_some(), "{lang}: the points input must show {through:?}");
}
}