loadpath 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- loadpath-0.1.1.dist-info/top_level.txt +1 -0
loadpath/cadimport.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CAD in: a STEP (or STL/3MF/OBJ) part as a study's design space, with its
|
|
3
|
+
interfaces found for you.
|
|
4
|
+
|
|
5
|
+
Fusion's generative workspace asks you to click the faces a part bolts on
|
|
6
|
+
by, the bores its shafts run in and the faces it must not grow over. Most of
|
|
7
|
+
that is in the geometry already. `import_part` reads the part, heals it
|
|
8
|
+
(real exports are often open shells: five of GRIP's link STEPs are), and
|
|
9
|
+
finds:
|
|
10
|
+
|
|
11
|
+
- bores: concave cylinders, merged across split faces and stacked with
|
|
12
|
+
their coaxial steps (a counterbore is one bore with two segments), with
|
|
13
|
+
their axis, extent, through or blind, and "bolt" (through radius up to
|
|
14
|
+
M6 clearance) or "bore" (a bearing seat, a shaft, a socket);
|
|
15
|
+
- flat faces the part can sit on: planar, exposed (nothing of the part in
|
|
16
|
+
front of them), with the bolt holes that open onto each;
|
|
17
|
+
- interfaces: a face with its bolt pattern, a bolt pattern on its own, or
|
|
18
|
+
a bore. These are where a part meets the rest of the machine.
|
|
19
|
+
|
|
20
|
+
`draft_study` turns them into a study an agent finishes: the part (or its
|
|
21
|
+
hull or box) as the design space, each interface's material preserved (a
|
|
22
|
+
ring round every bore and a pad under every bolted face, cut to the part),
|
|
23
|
+
every bore a keep-out, mating faces cleared when the space grows past the
|
|
24
|
+
part, and a suggested support and load: the strongest bolted interface
|
|
25
|
+
holds the part, the interface farthest from it takes a placeholder force
|
|
26
|
+
across the lever. The force is a placeholder: features.json lists every
|
|
27
|
+
interface so the real loads go on the right ones.
|
|
28
|
+
|
|
29
|
+
From a STEP file the bores and faces are exact (OpenCascade surface types);
|
|
30
|
+
from a mesh they are fitted (circles to smooth patches, coplanar facets).
|
|
31
|
+
STEP needs `pip install "loadpath[step]"` (cadquery-ocp, shared with
|
|
32
|
+
step.py's export).
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
__all__ = ["Part", "load_part", "detect_features", "suggest_setup", "summarize", "draft_study", "import_part",
|
|
44
|
+
"heal_mesh"]
|
|
45
|
+
|
|
46
|
+
STEP_EXT = (".step", ".stp")
|
|
47
|
+
# mm chord error of the STEP tessellation: a tenth of the finest grid the
|
|
48
|
+
# optimizer runs on, and coarse enough to tessellate a GRIP link in a second.
|
|
49
|
+
DEFAULT_TOLERANCE = 0.05
|
|
50
|
+
# mm: through radius up to an M6 clearance hole (3.2) counts as a bolt
|
|
51
|
+
BOLT_MAX_R = 3.6
|
|
52
|
+
# mm: a plain bore narrower than this (a cable pass, a pilot hole) is kept
|
|
53
|
+
# open but not ringed with preserved material unless it is loaded or holds:
|
|
54
|
+
# a ring there forces a strut out to it (GRIP's yoke grew one to a 4 mm pass)
|
|
55
|
+
SEAT_MIN_R = 5.0
|
|
56
|
+
# rad: a cylinder whose faces together cover less than this is a fillet or a
|
|
57
|
+
# slot end, not a hole
|
|
58
|
+
FULL_SPAN = 1.8 * math.pi
|
|
59
|
+
# a gap wider than this (mm) was a missing face, not a crack: say so
|
|
60
|
+
HEAL_WARN = 5.0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Part:
|
|
65
|
+
mesh: object # watertight trimesh
|
|
66
|
+
path: str | None = None
|
|
67
|
+
brep: object = None # OpenCascade shape (STEP only)
|
|
68
|
+
face_of: np.ndarray | None = None # B-rep face index of each triangle (STEP only)
|
|
69
|
+
healed: dict = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---- reading and healing -------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def heal_mesh(mesh):
|
|
75
|
+
"""Close a nearly closed mesh: every boundary loop is fanned from its
|
|
76
|
+
centroid. Cracks along unsewn edges and small missing faces close with a
|
|
77
|
+
negligible change in volume; returns (mesh, report)."""
|
|
78
|
+
import trimesh
|
|
79
|
+
from collections import defaultdict
|
|
80
|
+
|
|
81
|
+
if mesh.is_watertight:
|
|
82
|
+
return mesh, {"loops": 0}
|
|
83
|
+
e = mesh.edges
|
|
84
|
+
es = np.sort(e, axis=1)
|
|
85
|
+
_, inv, cnt = np.unique(es, axis=0, return_inverse=True, return_counts=True)
|
|
86
|
+
b = e[cnt[inv.ravel()] == 1]
|
|
87
|
+
nxt = defaultdict(list)
|
|
88
|
+
for a, c in b:
|
|
89
|
+
nxt[int(a)].append(int(c))
|
|
90
|
+
used, loops = set(), []
|
|
91
|
+
for a, c in b:
|
|
92
|
+
a, c = int(a), int(c)
|
|
93
|
+
if (a, c) in used:
|
|
94
|
+
continue
|
|
95
|
+
loop, cur = [a], (a, c)
|
|
96
|
+
while cur not in used:
|
|
97
|
+
used.add(cur)
|
|
98
|
+
v = cur[1]
|
|
99
|
+
if v == loop[0]:
|
|
100
|
+
break
|
|
101
|
+
loop.append(v)
|
|
102
|
+
nx = [w for w in nxt[v] if (v, w) not in used]
|
|
103
|
+
if not nx:
|
|
104
|
+
loop = None
|
|
105
|
+
break
|
|
106
|
+
cur = (v, nx[0])
|
|
107
|
+
if loop and len(loop) >= 3:
|
|
108
|
+
loops.append(loop)
|
|
109
|
+
V = [np.asarray(mesh.vertices, float)]
|
|
110
|
+
F = [np.asarray(mesh.faces, np.int64)]
|
|
111
|
+
n = len(mesh.vertices)
|
|
112
|
+
widest = 0.0
|
|
113
|
+
for L in loops:
|
|
114
|
+
P = mesh.vertices[L]
|
|
115
|
+
widest = max(widest, float(np.ptp(P, axis=0).max()))
|
|
116
|
+
V.append(P.mean(0)[None])
|
|
117
|
+
L = np.asarray(L)
|
|
118
|
+
# the loop runs along the existing faces' half-edges; the fan runs back
|
|
119
|
+
F.append(np.stack([np.roll(L, -1), L, np.full(len(L), n)], axis=1))
|
|
120
|
+
n += 1
|
|
121
|
+
out = trimesh.Trimesh(np.concatenate(V), np.concatenate(F), process=True)
|
|
122
|
+
out.merge_vertices()
|
|
123
|
+
if not out.is_winding_consistent:
|
|
124
|
+
trimesh.repair.fix_winding(out)
|
|
125
|
+
if out.volume < 0:
|
|
126
|
+
out.invert()
|
|
127
|
+
rep = {"loops": len(loops), "widestGap": round(widest, 2),
|
|
128
|
+
"volumeChange": round(float(out.volume - mesh.volume), 1), "watertight": bool(out.is_watertight)}
|
|
129
|
+
if widest > HEAL_WARN:
|
|
130
|
+
rep["warning"] = (f"a {widest:.1f} mm gap was closed with a flat fan: the file was missing faces, "
|
|
131
|
+
"check the part's shape there")
|
|
132
|
+
return out, rep
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _tessellate(shape, tolerance):
|
|
136
|
+
"""Triangulate an OpenCascade shape, remembering each triangle's face."""
|
|
137
|
+
import trimesh
|
|
138
|
+
from OCP.BRep import BRep_Tool
|
|
139
|
+
from OCP.BRepMesh import BRepMesh_IncrementalMesh
|
|
140
|
+
from OCP.TopAbs import TopAbs_FACE, TopAbs_REVERSED
|
|
141
|
+
from OCP.TopExp import TopExp_Explorer
|
|
142
|
+
from OCP.TopLoc import TopLoc_Location
|
|
143
|
+
from OCP.TopoDS import TopoDS
|
|
144
|
+
|
|
145
|
+
BRepMesh_IncrementalMesh(shape, tolerance, False, 0.2, True)
|
|
146
|
+
V, F, owner, faces = [], [], [], []
|
|
147
|
+
off = 0
|
|
148
|
+
ex = TopExp_Explorer(shape, TopAbs_FACE)
|
|
149
|
+
while ex.More():
|
|
150
|
+
f = TopoDS.Face(ex.Current())
|
|
151
|
+
fi = len(faces)
|
|
152
|
+
faces.append(f)
|
|
153
|
+
loc = TopLoc_Location()
|
|
154
|
+
tri = BRep_Tool.Triangulation_s(f, loc)
|
|
155
|
+
if tri is not None:
|
|
156
|
+
tr = loc.Transformation()
|
|
157
|
+
for i in range(1, tri.NbNodes() + 1):
|
|
158
|
+
p = tri.Node(i).Transformed(tr)
|
|
159
|
+
V.append((p.X(), p.Y(), p.Z()))
|
|
160
|
+
rev = f.Orientation() == TopAbs_REVERSED
|
|
161
|
+
for i in range(1, tri.NbTriangles() + 1):
|
|
162
|
+
a, b, c = tri.Triangle(i).Get()
|
|
163
|
+
F.append((off + a - 1, off + c - 1, off + b - 1) if rev else (off + a - 1, off + b - 1, off + c - 1))
|
|
164
|
+
owner.append(fi)
|
|
165
|
+
off += tri.NbNodes()
|
|
166
|
+
ex.Next()
|
|
167
|
+
m = trimesh.Trimesh(np.asarray(V, float), np.asarray(F, np.int64), process=False)
|
|
168
|
+
m.merge_vertices(digits_vertex=6)
|
|
169
|
+
keep = m.nondegenerate_faces()
|
|
170
|
+
owner = np.asarray(owner, np.int64)[keep]
|
|
171
|
+
m.update_faces(keep)
|
|
172
|
+
m.remove_unreferenced_vertices()
|
|
173
|
+
return m, owner, faces
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def load_part(path, tolerance: float = DEFAULT_TOLERANCE) -> Part:
|
|
177
|
+
"""A part from a STEP, STL, 3MF or OBJ file, closed if it was not."""
|
|
178
|
+
import trimesh
|
|
179
|
+
|
|
180
|
+
ext = os.path.splitext(str(path))[1].lower()
|
|
181
|
+
if ext in STEP_EXT:
|
|
182
|
+
from OCP.BRepBuilderAPI import BRepBuilderAPI_Sewing
|
|
183
|
+
|
|
184
|
+
from .step import read_step
|
|
185
|
+
shape = read_step(path)
|
|
186
|
+
sew = BRepBuilderAPI_Sewing(1e-3)
|
|
187
|
+
sew.Add(shape)
|
|
188
|
+
sew.Perform()
|
|
189
|
+
shape = sew.SewedShape()
|
|
190
|
+
mesh, owner, faces = _tessellate(shape, tolerance)
|
|
191
|
+
if len(mesh.faces) == 0:
|
|
192
|
+
raise ValueError(f"{path}: the STEP file has no surfaces to mesh")
|
|
193
|
+
n0 = len(mesh.faces)
|
|
194
|
+
healed, rep = heal_mesh(mesh)
|
|
195
|
+
# healing only appends fans: the original triangles keep their order
|
|
196
|
+
# (merge_vertices does not reorder faces)
|
|
197
|
+
owner = np.concatenate([owner, np.full(len(healed.faces) - n0, -1)]) if len(healed.faces) >= n0 else None
|
|
198
|
+
part = Part(healed, str(path), shape, owner, rep)
|
|
199
|
+
part.faces = faces
|
|
200
|
+
return part
|
|
201
|
+
mesh = trimesh.load(path, force="mesh")
|
|
202
|
+
if mesh.volume < 0:
|
|
203
|
+
mesh.invert()
|
|
204
|
+
healed, rep = heal_mesh(mesh)
|
|
205
|
+
return Part(healed, str(path), healed=rep)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---- cylinders and planes ------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
def _unit(v):
|
|
211
|
+
v = np.asarray(v, float)
|
|
212
|
+
return v / np.linalg.norm(v)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _canon_axis(p, d):
|
|
216
|
+
"""A line as (point nearest the origin, direction with its largest
|
|
217
|
+
component positive): equal lines get equal keys."""
|
|
218
|
+
d = _unit(d)
|
|
219
|
+
if d[np.argmax(np.abs(d))] < 0:
|
|
220
|
+
d = -d
|
|
221
|
+
p = np.asarray(p, float)
|
|
222
|
+
return p - np.dot(p, d) * d, d
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _concave(mesh, pts, axis_p, axis_d, r):
|
|
226
|
+
"""True where the cylinder at these surface points is a hole: just
|
|
227
|
+
inside the surface, towards the axis, there is no material."""
|
|
228
|
+
pts = np.asarray(pts, float).reshape(-1, 3)
|
|
229
|
+
foot = axis_p + np.outer((pts - axis_p) @ axis_d, axis_d)
|
|
230
|
+
radial = pts - foot
|
|
231
|
+
radial /= np.maximum(np.linalg.norm(radial, axis=1, keepdims=True), 1e-12)
|
|
232
|
+
probe = pts - radial * min(0.3, 0.2 * r)
|
|
233
|
+
return ~mesh.contains(probe)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _brep_surfaces(part: Part):
|
|
237
|
+
"""Exact cylinders and planes of a STEP part, one record per face."""
|
|
238
|
+
from OCP.BRepAdaptor import BRepAdaptor_Surface
|
|
239
|
+
from OCP.BRepGProp import BRepGProp
|
|
240
|
+
from OCP.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane
|
|
241
|
+
from OCP.GProp import GProp_GProps
|
|
242
|
+
from OCP.TopAbs import TopAbs_REVERSED
|
|
243
|
+
|
|
244
|
+
cyl, planes = [], []
|
|
245
|
+
tris_of = {}
|
|
246
|
+
if part.face_of is not None:
|
|
247
|
+
for t, fi in enumerate(part.face_of):
|
|
248
|
+
tris_of.setdefault(int(fi), []).append(t)
|
|
249
|
+
for fi, f in enumerate(part.faces):
|
|
250
|
+
a = BRepAdaptor_Surface(f)
|
|
251
|
+
kind = a.GetType()
|
|
252
|
+
tris = np.asarray(tris_of.get(fi, []), np.int64)
|
|
253
|
+
if kind == GeomAbs_Cylinder:
|
|
254
|
+
c = a.Cylinder()
|
|
255
|
+
ax = c.Axis()
|
|
256
|
+
p0 = np.array([ax.Location().X(), ax.Location().Y(), ax.Location().Z()])
|
|
257
|
+
d = np.array([ax.Direction().X(), ax.Direction().Y(), ax.Direction().Z()])
|
|
258
|
+
u0, u1 = a.FirstUParameter(), a.LastUParameter()
|
|
259
|
+
v0, v1 = a.FirstVParameter(), a.LastVParameter()
|
|
260
|
+
um, vm = 0.5 * (u0 + u1), 0.5 * (v0 + v1)
|
|
261
|
+
pm = a.Value(um, vm)
|
|
262
|
+
cyl.append({"r": float(c.Radius()), "p": p0, "d": d, "span": float(u1 - u0),
|
|
263
|
+
"ends": [p0 + v0 * d, p0 + v1 * d], "sample": np.array([pm.X(), pm.Y(), pm.Z()]),
|
|
264
|
+
"tris": tris})
|
|
265
|
+
elif kind == GeomAbs_Plane:
|
|
266
|
+
pl = a.Plane()
|
|
267
|
+
n = np.array([pl.Axis().Direction().X(), pl.Axis().Direction().Y(), pl.Axis().Direction().Z()])
|
|
268
|
+
if f.Orientation() == TopAbs_REVERSED:
|
|
269
|
+
n = -n
|
|
270
|
+
g = GProp_GProps()
|
|
271
|
+
BRepGProp.SurfaceProperties_s(f, g)
|
|
272
|
+
cm = g.CentreOfMass()
|
|
273
|
+
planes.append({"n": n, "c": np.array([cm.X(), cm.Y(), cm.Z()]), "area": float(g.Mass()),
|
|
274
|
+
"tris": tris})
|
|
275
|
+
return cyl, planes
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _mesh_surfaces(mesh):
|
|
279
|
+
"""Fitted cylinders and planes of a mesh. Cylinders: smooth patches
|
|
280
|
+
(dihedral under 25 degrees) whose normals lie square to one axis and
|
|
281
|
+
whose points fit a circle, several per patch when fillets join them.
|
|
282
|
+
Planes: coplanar facets of three or more triangles that no cylinder
|
|
283
|
+
claimed (a finely cut cylinder's strips are coplanar facets too)."""
|
|
284
|
+
import trimesh
|
|
285
|
+
|
|
286
|
+
planes, cyl = [], []
|
|
287
|
+
adj = mesh.face_adjacency
|
|
288
|
+
ok = mesh.face_adjacency_angles < math.radians(25)
|
|
289
|
+
claimed = np.zeros(len(mesh.faces), bool)
|
|
290
|
+
for comp in trimesh.graph.connected_components(adj[ok], nodes=np.arange(len(mesh.faces)), min_len=6):
|
|
291
|
+
left = np.asarray(comp)
|
|
292
|
+
for _ in range(4):
|
|
293
|
+
if len(left) < 6:
|
|
294
|
+
break
|
|
295
|
+
fit = _fit_cylinder(mesh, left)
|
|
296
|
+
if fit is None:
|
|
297
|
+
break
|
|
298
|
+
cyl.append(fit)
|
|
299
|
+
claimed[fit["tris"]] = True
|
|
300
|
+
left = left[~claimed[left]]
|
|
301
|
+
for fac, n, area in zip(mesh.facets, mesh.facets_normal, mesh.facets_area):
|
|
302
|
+
if len(fac) < 3 or claimed[fac].mean() > 0.5:
|
|
303
|
+
continue
|
|
304
|
+
w = mesh.area_faces[fac]
|
|
305
|
+
planes.append({"n": np.asarray(n, float), "c": (mesh.triangles_center[fac] * w[:, None]).sum(0) / w.sum(),
|
|
306
|
+
"area": float(area), "tris": np.asarray(fac)})
|
|
307
|
+
return cyl, planes
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _fit_cylinder(mesh, comp):
|
|
311
|
+
"""A cylinder fitted to a smooth patch of triangles, or None. Faces
|
|
312
|
+
whose normals leave the axis's plane (a fillet rolling onto a face) are
|
|
313
|
+
peeled off and the axis refitted; then points off the circle (the far
|
|
314
|
+
side of a fillet, a flat beside it) are trimmed."""
|
|
315
|
+
for _ in range(4):
|
|
316
|
+
nrm = mesh.face_normals[comp]
|
|
317
|
+
w = mesh.area_faces[comp]
|
|
318
|
+
M = (nrm * w[:, None]).T @ nrm
|
|
319
|
+
evals, evecs = np.linalg.eigh(M)
|
|
320
|
+
d = evecs[:, 0]
|
|
321
|
+
off = np.abs(nrm @ d) > 0.2
|
|
322
|
+
if not off.any():
|
|
323
|
+
break
|
|
324
|
+
comp = comp[~off]
|
|
325
|
+
if len(comp) < 6:
|
|
326
|
+
return None
|
|
327
|
+
if evals[0] > 0.02 * evals.sum() or evals[1] < 0.05 * evals.sum():
|
|
328
|
+
return None # not square to one axis, or flat
|
|
329
|
+
vid = np.unique(mesh.faces[comp])
|
|
330
|
+
P = mesh.vertices[vid]
|
|
331
|
+
e1 = _unit(np.cross(d, [1, 0, 0] if abs(d[0]) < 0.9 else [0, 1, 0]))
|
|
332
|
+
e2 = np.cross(d, e1)
|
|
333
|
+
x, y = P @ e1, P @ e2
|
|
334
|
+
inl = np.ones(len(P), bool)
|
|
335
|
+
for _ in range(3):
|
|
336
|
+
A = np.stack([x[inl], y[inl], np.ones(inl.sum())], 1)
|
|
337
|
+
sol, *_ = np.linalg.lstsq(A, x[inl] ** 2 + y[inl] ** 2, rcond=None)
|
|
338
|
+
cx, cy = sol[0] / 2, sol[1] / 2
|
|
339
|
+
r = math.sqrt(max(sol[2] + cx * cx + cy * cy, 0.0))
|
|
340
|
+
if not 0 < r < 1e4:
|
|
341
|
+
return None
|
|
342
|
+
resid = np.abs(np.hypot(x - cx, y - cy) - r)
|
|
343
|
+
inl = resid <= max(0.01 * r, 0.02)
|
|
344
|
+
if inl.sum() < 6:
|
|
345
|
+
return None
|
|
346
|
+
if inl.sum() < 6:
|
|
347
|
+
return None
|
|
348
|
+
on = np.zeros(len(mesh.vertices), bool)
|
|
349
|
+
on[vid[inl]] = True
|
|
350
|
+
comp = comp[on[mesh.faces[comp]].all(1)]
|
|
351
|
+
if len(comp) < 4 or (inl.mean() < 0.7 and len(comp) < 12):
|
|
352
|
+
return None
|
|
353
|
+
a = np.sort(np.arctan2(y[inl] - cy, x[inl] - cx))
|
|
354
|
+
gaps = np.diff(np.concatenate([a, a[:1] + 2 * math.pi]))
|
|
355
|
+
t = P[inl] @ d
|
|
356
|
+
p0 = cx * e1 + cy * e2
|
|
357
|
+
w = mesh.area_faces[comp]
|
|
358
|
+
return {"r": float(r), "p": p0, "d": d, "span": float(2 * math.pi - gaps.max()),
|
|
359
|
+
"ends": [p0 + t.min() * d, p0 + t.max() * d],
|
|
360
|
+
"sample": mesh.triangles_center[comp[np.argmax(w)]], "tris": comp}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ---- features -----------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
def _group_cylinders(mesh, cyl, scale):
|
|
366
|
+
"""Merge cylinder faces into holes (and bosses): same line, same radius,
|
|
367
|
+
then coaxial steps into one bore."""
|
|
368
|
+
tol_p, tol_r = 1e-3 * scale + 0.01, 0.02
|
|
369
|
+
groups = []
|
|
370
|
+
for c in cyl:
|
|
371
|
+
p, d = _canon_axis(c["p"], c["d"])
|
|
372
|
+
for g in groups:
|
|
373
|
+
if abs(abs(np.dot(g["d"], d)) - 1) < 1e-4 and np.linalg.norm(g["p"] - p) < tol_p \
|
|
374
|
+
and abs(g["r"] - c["r"]) < tol_r:
|
|
375
|
+
g["faces"].append(c)
|
|
376
|
+
break
|
|
377
|
+
else:
|
|
378
|
+
groups.append({"p": p, "d": d, "r": c["r"], "faces": [c]})
|
|
379
|
+
holes, bosses = [], []
|
|
380
|
+
for g in groups:
|
|
381
|
+
span = sum(f["span"] for f in g["faces"])
|
|
382
|
+
if span < FULL_SPAN:
|
|
383
|
+
continue # a fillet, a slot end, a partial arc
|
|
384
|
+
ts = np.concatenate([[(e - g["p"]) @ g["d"] for e in f["ends"]] for f in g["faces"]])
|
|
385
|
+
g["t0"], g["t1"] = float(ts.min()), float(ts.max())
|
|
386
|
+
if g["t1"] - g["t0"] < 1e-3:
|
|
387
|
+
continue
|
|
388
|
+
samples = np.array([f["sample"] for f in g["faces"]])
|
|
389
|
+
hole = _concave(mesh, samples, g["p"], g["d"], g["r"])
|
|
390
|
+
g["tris"] = np.concatenate([f["tris"] for f in g["faces"]])
|
|
391
|
+
(holes if hole.mean() > 0.5 else bosses).append(g)
|
|
392
|
+
# coaxial holes: one bore with steps
|
|
393
|
+
bores = []
|
|
394
|
+
for h in sorted(holes, key=lambda h: h["r"]):
|
|
395
|
+
for b in bores:
|
|
396
|
+
if abs(abs(np.dot(b["d"], h["d"])) - 1) < 1e-4 and np.linalg.norm(b["p"] - h["p"]) < tol_p:
|
|
397
|
+
b["segments"].append(h)
|
|
398
|
+
break
|
|
399
|
+
else:
|
|
400
|
+
bores.append({"p": h["p"], "d": h["d"], "segments": [h]})
|
|
401
|
+
return bores, bosses
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _inside(mesh, pts):
|
|
405
|
+
return mesh.contains(np.asarray(pts, float).reshape(-1, 3))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _bore_record(mesh, b, i, scale):
|
|
409
|
+
segs = sorted(b["segments"], key=lambda s: s["t0"])
|
|
410
|
+
d, p = b["d"], b["p"]
|
|
411
|
+
t0 = min(s["t0"] for s in segs)
|
|
412
|
+
t1 = max(s["t1"] for s in segs)
|
|
413
|
+
r_min = min(s["r"] for s in segs)
|
|
414
|
+
eps = 0.5
|
|
415
|
+
through = not bool(_inside(mesh, [p + (t0 - eps) * d, p + (t1 + eps) * d]).any())
|
|
416
|
+
return {"id": f"bore-{i}", "kind": "bolt" if r_min <= BOLT_MAX_R else "bore",
|
|
417
|
+
"axis": d.round(6).tolist(), "center": (p + 0.5 * (t0 + t1) * d).round(4).tolist(),
|
|
418
|
+
"p0": (p + t0 * d).round(4).tolist(), "p1": (p + t1 * d).round(4).tolist(),
|
|
419
|
+
"r": round(r_min, 4), "length": round(t1 - t0, 4), "through": through,
|
|
420
|
+
"segments": [{"r": round(s["r"], 4), "p0": (p + s["t0"] * d).round(4).tolist(),
|
|
421
|
+
"p1": (p + s["t1"] * d).round(4).tolist()} for s in segs]}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _exposed(mesh, plane, samples=12):
|
|
425
|
+
"""Share of the face that sees nothing of the part along its normal."""
|
|
426
|
+
tris = plane["tris"]
|
|
427
|
+
if len(tris) == 0:
|
|
428
|
+
return 0.0
|
|
429
|
+
rng = np.random.default_rng(0)
|
|
430
|
+
pick = tris if len(tris) <= samples else rng.choice(tris, samples, replace=False, p=None)
|
|
431
|
+
origins = mesh.triangles_center[pick] + plane["n"] * 1e-3
|
|
432
|
+
dirs = np.repeat(plane["n"][None], len(origins), 0)
|
|
433
|
+
hit = mesh.ray.intersects_any(origins, dirs)
|
|
434
|
+
return float(1.0 - hit.mean())
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def detect_features(part: Part) -> dict:
|
|
438
|
+
"""Bores, flat mounting faces and interfaces of a part (see the module
|
|
439
|
+
docstring). Units mm."""
|
|
440
|
+
mesh = part.mesh
|
|
441
|
+
scale = float(np.max(mesh.extents))
|
|
442
|
+
if part.brep is not None:
|
|
443
|
+
cyl, planes = _brep_surfaces(part)
|
|
444
|
+
source = "brep"
|
|
445
|
+
else:
|
|
446
|
+
cyl, planes = _mesh_surfaces(mesh)
|
|
447
|
+
source = "mesh"
|
|
448
|
+
raw_bores, bosses = _group_cylinders(mesh, cyl, scale)
|
|
449
|
+
raw_bores.sort(key=lambda b: (-max(s["r"] for s in b["segments"]),
|
|
450
|
+
tuple(np.round(b["p"] + b["segments"][0]["t0"] * b["d"], 3))))
|
|
451
|
+
bores = [_bore_record(mesh, b, i, scale) for i, b in enumerate(raw_bores)]
|
|
452
|
+
|
|
453
|
+
# mounting faces: exposed planes big enough to sit on
|
|
454
|
+
total = float(mesh.area)
|
|
455
|
+
# big enough to bolt to: 1% of the surface (a chamfer or a step is not
|
|
456
|
+
# a mounting face; a plain L bracket read 14 of them at 0.2%)
|
|
457
|
+
min_area = max(10.0, 0.01 * total)
|
|
458
|
+
faces = []
|
|
459
|
+
for pl in sorted(planes, key=lambda q: -q["area"]):
|
|
460
|
+
if pl["area"] < min_area:
|
|
461
|
+
continue
|
|
462
|
+
exp = _exposed(mesh, pl)
|
|
463
|
+
if exp < 0.6:
|
|
464
|
+
continue
|
|
465
|
+
n, c = _unit(pl["n"]), pl["c"]
|
|
466
|
+
pts = mesh.vertices[np.unique(mesh.faces[pl["tris"]])] if len(pl["tris"]) else c[None]
|
|
467
|
+
holes = []
|
|
468
|
+
for b in bores:
|
|
469
|
+
d = np.asarray(b["axis"])
|
|
470
|
+
if abs(np.dot(d, n)) < 0.98:
|
|
471
|
+
continue
|
|
472
|
+
ends = [np.asarray(b["p0"]), np.asarray(b["p1"])]
|
|
473
|
+
for s in b["segments"]:
|
|
474
|
+
ends += [np.asarray(s["p0"]), np.asarray(s["p1"])]
|
|
475
|
+
if min(abs(np.dot(e - c, n)) for e in ends) > 0.05:
|
|
476
|
+
continue
|
|
477
|
+
# where the axis meets the plane, the face lies within a wall's reach
|
|
478
|
+
q = np.asarray(b["center"]) + d * np.dot(c - np.asarray(b["center"]), n) / np.dot(d, n)
|
|
479
|
+
rmax = max(s["r"] for s in b["segments"])
|
|
480
|
+
if np.min(np.linalg.norm(pts - q, axis=1)) <= rmax + 3.0:
|
|
481
|
+
holes.append(b["id"])
|
|
482
|
+
faces.append({"id": f"face-{len(faces)}", "normal": n.round(6).tolist(), "center": c.round(4).tolist(),
|
|
483
|
+
"area": round(pl["area"], 2), "exposed": round(exp, 2), "bores": holes,
|
|
484
|
+
"_tris": pl["tris"]})
|
|
485
|
+
|
|
486
|
+
# interfaces: bolted faces, loose bolt patterns, bores
|
|
487
|
+
by_id = {b["id"]: b for b in bores}
|
|
488
|
+
used = set()
|
|
489
|
+
interfaces = []
|
|
490
|
+
for f in faces:
|
|
491
|
+
bolts = [h for h in f["bores"] if by_id[h]["kind"] == "bolt" and h not in used]
|
|
492
|
+
big = [h for h in f["bores"] if by_id[h]["kind"] == "bore"]
|
|
493
|
+
if len(bolts) >= 2 or (bolts and f["area"] > 0.02 * total):
|
|
494
|
+
used.update(bolts)
|
|
495
|
+
interfaces.append({"id": f"{f['id']}-mount", "kind": "bolted-face", "face": f["id"],
|
|
496
|
+
"bores": bolts, "alsoOnFace": big})
|
|
497
|
+
loose = [b for b in bores if b["kind"] == "bolt" and b["id"] not in used]
|
|
498
|
+
while loose:
|
|
499
|
+
b0 = loose.pop(0)
|
|
500
|
+
d0 = np.asarray(b0["axis"])
|
|
501
|
+
grp = [b0]
|
|
502
|
+
for b in list(loose):
|
|
503
|
+
if abs(abs(np.dot(np.asarray(b["axis"]), d0)) - 1) < 1e-3 and abs(b["r"] - b0["r"]) < 0.05 and \
|
|
504
|
+
abs(np.dot(np.asarray(b["p0"]) - np.asarray(b0["p0"]), d0)) < 0.5:
|
|
505
|
+
grp.append(b)
|
|
506
|
+
loose.remove(b)
|
|
507
|
+
used.update(b["id"] for b in grp)
|
|
508
|
+
interfaces.append({"id": f"{grp[0]['id']}-pattern" if len(grp) > 1 else grp[0]["id"],
|
|
509
|
+
"kind": "bolt-pattern" if len(grp) > 1 else "bolt", "bores": [b["id"] for b in grp]})
|
|
510
|
+
for b in bores:
|
|
511
|
+
if b["kind"] == "bore":
|
|
512
|
+
interfaces.append({"id": b["id"], "kind": "bore", "bores": [b["id"]]})
|
|
513
|
+
for it in interfaces:
|
|
514
|
+
cs = np.array([by_id[h]["center"] for h in it["bores"]]) if it["bores"] else None
|
|
515
|
+
if it["kind"] == "bolted-face":
|
|
516
|
+
# centred on its bolts, which carry the load, not on the face
|
|
517
|
+
fc = next(f for f in faces if f["id"] == it["face"])
|
|
518
|
+
it["center"] = cs.mean(0).round(4).tolist()
|
|
519
|
+
it["normal"] = fc["normal"]
|
|
520
|
+
else:
|
|
521
|
+
it["center"] = cs.mean(0).round(4).tolist()
|
|
522
|
+
it["normal"] = by_id[it["bores"][0]]["axis"]
|
|
523
|
+
out = {"source": source, "units": "mm", "bounds": np.asarray(mesh.bounds).round(4).tolist(),
|
|
524
|
+
"volume": round(float(mesh.volume), 1), "area": round(total, 1),
|
|
525
|
+
"bores": bores, "bosses": [{"axis": b["d"].round(6).tolist(), "r": round(b["r"], 4),
|
|
526
|
+
"p0": (b["p"] + b["t0"] * b["d"]).round(4).tolist(),
|
|
527
|
+
"p1": (b["p"] + b["t1"] * b["d"]).round(4).tolist()} for b in bosses],
|
|
528
|
+
"faces": faces, "interfaces": interfaces}
|
|
529
|
+
if part.healed.get("loops"):
|
|
530
|
+
out["healed"] = part.healed
|
|
531
|
+
return out
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _interface_size(it, feats):
|
|
535
|
+
"""How much an interface carries, roughly: bearing area of its bores
|
|
536
|
+
(r x length, mm^2) plus a tenth of its face."""
|
|
537
|
+
by_id = {b["id"]: b for b in feats["bores"]}
|
|
538
|
+
s = sum(by_id[h]["r"] * by_id[h]["length"] for h in it["bores"])
|
|
539
|
+
if it["kind"] == "bolted-face":
|
|
540
|
+
s += 0.1 * next(f["area"] for f in feats["faces"] if f["id"] == it["face"])
|
|
541
|
+
return s
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def suggest_setup(feats: dict, force: float = 100.0) -> dict:
|
|
545
|
+
"""A support and a load to start from. A part is held by its bolts:
|
|
546
|
+
the bolted face (or bolt pattern) with the most bolts holds it, the
|
|
547
|
+
largest by area on a tie (any interface when nothing is bolted). The
|
|
548
|
+
load goes where the most is carried farthest from that (size x lever:
|
|
549
|
+
a bearing seat or a socket across the part), `force` N square to the
|
|
550
|
+
lever, as close to straight down (-z) as that allows. A placeholder for
|
|
551
|
+
the real loads, which only the machine knows."""
|
|
552
|
+
its = feats["interfaces"]
|
|
553
|
+
if not its:
|
|
554
|
+
return {}
|
|
555
|
+
bolted = [it for it in its if it["kind"] in ("bolted-face", "bolt-pattern")]
|
|
556
|
+
pool = bolted or its
|
|
557
|
+
hold = max(pool, key=lambda it: (len(it["bores"]) if bolted else 0, _interface_size(it, feats)))
|
|
558
|
+
c0 = np.asarray(hold["center"])
|
|
559
|
+
rest = [it for it in its if it is not hold]
|
|
560
|
+
if not rest:
|
|
561
|
+
return {"support": hold["id"]}
|
|
562
|
+
load = max(rest, key=lambda it: _interface_size(it, feats) * np.linalg.norm(np.asarray(it["center"]) - c0))
|
|
563
|
+
lever = np.asarray(load["center"]) - c0
|
|
564
|
+
lever = lever / max(np.linalg.norm(lever), 1e-9)
|
|
565
|
+
g = np.array([0.0, 0.0, -1.0])
|
|
566
|
+
dirn = g - np.dot(g, lever) * lever
|
|
567
|
+
if np.linalg.norm(dirn) < 0.3:
|
|
568
|
+
a = np.asarray(load["normal"], float)
|
|
569
|
+
dirn = np.cross(lever, a)
|
|
570
|
+
if np.linalg.norm(dirn) < 1e-6:
|
|
571
|
+
dirn = np.cross(lever, [1.0, 0.0, 0.0] if abs(lever[0]) < 0.9 else [0.0, 1.0, 0.0])
|
|
572
|
+
dirn = dirn / np.linalg.norm(dirn)
|
|
573
|
+
return {"support": hold["id"], "load": load["id"], "force": (force * dirn).round(3).tolist(),
|
|
574
|
+
"lever": round(float(np.linalg.norm(np.asarray(load["center"]) - c0)), 2),
|
|
575
|
+
"note": f"placeholder {force:g} N: replace with the part's real loads"}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# ---- the draft study ----------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
def _extrude(mesh, tris, normal, depth):
|
|
581
|
+
"""The triangles `tris` of `mesh` (a flat face) swept `depth` mm along
|
|
582
|
+
`normal`, closed: a pad behind a face (depth < 0) or a clearance slab in
|
|
583
|
+
front of it."""
|
|
584
|
+
import trimesh
|
|
585
|
+
|
|
586
|
+
F = mesh.faces[tris]
|
|
587
|
+
vid, inv = np.unique(F, return_inverse=True)
|
|
588
|
+
top = mesh.vertices[vid]
|
|
589
|
+
bot = top + depth * np.asarray(normal)
|
|
590
|
+
n = len(vid)
|
|
591
|
+
f_top = inv.reshape(-1, 3)
|
|
592
|
+
# boundary half-edges of the face
|
|
593
|
+
e = np.concatenate([f_top[:, [0, 1]], f_top[:, [1, 2]], f_top[:, [2, 0]]])
|
|
594
|
+
key = np.sort(e, axis=1)
|
|
595
|
+
_, ix, cnt = np.unique(key, axis=0, return_inverse=True, return_counts=True)
|
|
596
|
+
be = e[cnt[ix.ravel()] == 1]
|
|
597
|
+
sides = np.concatenate([np.stack([be[:, 1], be[:, 0], be[:, 0] + n], 1),
|
|
598
|
+
np.stack([be[:, 1], be[:, 0] + n, be[:, 1] + n], 1)])
|
|
599
|
+
faces = np.concatenate([f_top, f_top[:, ::-1] + n, sides])
|
|
600
|
+
m = trimesh.Trimesh(np.concatenate([top, bot]), faces, process=True)
|
|
601
|
+
if m.volume < 0:
|
|
602
|
+
m.invert()
|
|
603
|
+
return m
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _tube(seg, wall, extend=0.0):
|
|
607
|
+
from .shapes import Cylinder
|
|
608
|
+
p0, p1 = np.asarray(seg["p0"], float), np.asarray(seg["p1"], float)
|
|
609
|
+
a = _unit(p1 - p0)
|
|
610
|
+
return Cylinder(p0 - extend * a, p1 + extend * a, seg["r"] + wall, 0.0)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _load_pairs(loads):
|
|
614
|
+
"""[(interface id, force)] from a list of pairs or {id, force} dicts, or
|
|
615
|
+
a dict {id: force}."""
|
|
616
|
+
if not loads:
|
|
617
|
+
return []
|
|
618
|
+
if isinstance(loads, dict):
|
|
619
|
+
return [(k, v) for k, v in loads.items()]
|
|
620
|
+
out = []
|
|
621
|
+
for x in loads:
|
|
622
|
+
if isinstance(x, dict):
|
|
623
|
+
out.append((x.get("interface") or x.get("region") or x.get("id"), x["force"]))
|
|
624
|
+
else:
|
|
625
|
+
out.append((x[0], x[1]))
|
|
626
|
+
for k, f in out:
|
|
627
|
+
if len(f) != 3:
|
|
628
|
+
raise ValueError(f"load on {k!r}: force is [fx, fy, fz] in N")
|
|
629
|
+
return out
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def washer_r(r):
|
|
633
|
+
"""mm: the radius a bolt's head or washer bears on, for a clearance
|
|
634
|
+
hole of radius r (ISO metric, fasteners.METRIC; about 1.9 r between)."""
|
|
635
|
+
from .fasteners import METRIC
|
|
636
|
+
best = min(METRIC.values(), key=lambda v: abs(v[0] / 2 - r))
|
|
637
|
+
return best[1] / 2 if abs(best[0] / 2 - r) < 0.4 else 1.9 * r
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _bolt_ends(b, face_n=None, face_c=None):
|
|
641
|
+
"""(head end, far end, head-recess radius) of a bolt bore. The head sits
|
|
642
|
+
in a counterbore when there is one (at its step); else on the end away
|
|
643
|
+
from the face the part bolts against."""
|
|
644
|
+
p0, p1 = np.asarray(b["p0"], float), np.asarray(b["p1"], float)
|
|
645
|
+
segs = b["segments"]
|
|
646
|
+
if len(segs) > 1:
|
|
647
|
+
cb = max(segs, key=lambda s: s["r"])
|
|
648
|
+
q0, q1 = np.asarray(cb["p0"], float), np.asarray(cb["p1"], float)
|
|
649
|
+
# the counterbore's outer end is the bore's own end; its other end is the step
|
|
650
|
+
if np.linalg.norm(q0 - p0) < np.linalg.norm(q1 - p1):
|
|
651
|
+
return q1, p1, cb["r"]
|
|
652
|
+
return q0, p0, cb["r"]
|
|
653
|
+
if face_n is not None and abs(np.dot(p1 - face_c, face_n)) < abs(np.dot(p0 - face_c, face_n)):
|
|
654
|
+
return p0, p1, b["r"]
|
|
655
|
+
return p1, p0, b["r"]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def ring_wall(r):
|
|
659
|
+
"""mm of material kept round a bore of radius r: three FDM perimeters
|
|
660
|
+
round a bolt, a little more round a bearing seat."""
|
|
661
|
+
return float(np.clip(2.0 + 0.1 * r, 2.0, 5.0))
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _write_stl(mesh, path):
|
|
665
|
+
mesh.export(path)
|
|
666
|
+
return os.path.basename(path)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def draft_study(part: Part, feats: dict, out_dir: str, *, name: str | None = None, space: str = "part",
|
|
670
|
+
material: str = "petg", force: float = 100.0, pad: float = 2.0, grow: float = 0.0,
|
|
671
|
+
safety_factor: float = 2.0, process: str | None = None, support: str | None = None,
|
|
672
|
+
loads=None, ring: float | None = None) -> dict:
|
|
673
|
+
"""Write a study for the part into `out_dir` (design space, preserved
|
|
674
|
+
interfaces and clearance slabs as STL beside it) and return it.
|
|
675
|
+
`support` (an interface id) and `loads` ([(interface id, [fx, fy, fz] N)],
|
|
676
|
+
or a dict of them; each its own load case) replace the suggested setup;
|
|
677
|
+
the suggestion stands for whichever is not given. `ring` (mm): every bore
|
|
678
|
+
keeps an exact tube of that wall (a cylinder in the study, not cut to the
|
|
679
|
+
part) instead of the default ring."""
|
|
680
|
+
import trimesh
|
|
681
|
+
|
|
682
|
+
from .shapes import Cylinder, manifold_from_trimesh, trimesh_from_manifold
|
|
683
|
+
|
|
684
|
+
if space not in ("part", "hull", "box"):
|
|
685
|
+
raise ValueError("space is 'part' (the part as drawn), 'hull' (its convex hull) or 'box'")
|
|
686
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
687
|
+
mesh = part.mesh
|
|
688
|
+
stem = name or os.path.splitext(os.path.basename(part.path or "part"))[0]
|
|
689
|
+
by_id = {b["id"]: b for b in feats["bores"]}
|
|
690
|
+
faces = {f["id"]: f for f in feats["faces"]}
|
|
691
|
+
|
|
692
|
+
if space == "part":
|
|
693
|
+
ds_mesh = mesh
|
|
694
|
+
elif space == "hull":
|
|
695
|
+
ds_mesh = mesh.convex_hull
|
|
696
|
+
else:
|
|
697
|
+
lo, hi = mesh.bounds
|
|
698
|
+
ds_mesh = trimesh.creation.box(bounds=[lo - grow, hi + grow])
|
|
699
|
+
if grow > 0 and space != "box":
|
|
700
|
+
from .shapes import MeshShape, grow as grow_shape
|
|
701
|
+
ds_spec_shape = grow_shape(MeshShape(ds_mesh), grow)
|
|
702
|
+
ds_mesh = trimesh_from_manifold(ds_spec_shape.to_manifold())
|
|
703
|
+
design = [{"type": "mesh", "path": _write_stl(ds_mesh, os.path.join(out_dir, f"{stem}-design-space.stl"))}]
|
|
704
|
+
part_m = manifold_from_trimesh(mesh)
|
|
705
|
+
|
|
706
|
+
preserve, keep_out = [], []
|
|
707
|
+
lo, hi = np.asarray(ds_mesh.bounds[0]), np.asarray(ds_mesh.bounds[1])
|
|
708
|
+
reach = float(np.linalg.norm(hi - lo))
|
|
709
|
+
|
|
710
|
+
def bore_cyls(b, extend):
|
|
711
|
+
segs = [dict(s) for s in b["segments"]]
|
|
712
|
+
ends = [np.asarray(b["p0"], float), np.asarray(b["p1"], float)]
|
|
713
|
+
out = []
|
|
714
|
+
for s in segs:
|
|
715
|
+
p0, p1 = np.asarray(s["p0"]), np.asarray(s["p1"])
|
|
716
|
+
a = _unit(p1 - p0)
|
|
717
|
+
ext = (reach if extend else 0.5) if b["through"] else 0.0
|
|
718
|
+
# only past the bore's own ends: a counterbore stretched past its
|
|
719
|
+
# step would cut away the material its screw clamps
|
|
720
|
+
e0, e1 = (ext if min(np.linalg.norm(q - e) for e in ends) < 1e-6 else 0.0 for q in (p0, p1))
|
|
721
|
+
out.append({"type": "cylinder", "p0": (p0 - e0 * a).round(4).tolist(),
|
|
722
|
+
"p1": (p1 + e1 * a).round(4).tolist(), "r": s["r"]})
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
def bore_path(b, face_n=None, face_c=None):
|
|
726
|
+
"""The bore's line out of each end, from near where its keep-out
|
|
727
|
+
stops (0.5 mm past the end) to past the design space, not over the
|
|
728
|
+
hole: coincident cylinders (or caps) are what OpenCascade's booleans
|
|
729
|
+
(the STEP export) handle worst, to the point of hanging. Not out of
|
|
730
|
+
a bolt's head end: its driver path is there already."""
|
|
731
|
+
ends = [np.asarray(b[k], float) for k in ("p0", "p1")]
|
|
732
|
+
if b["kind"] == "bolt":
|
|
733
|
+
far = _bolt_ends(b, face_n, face_c)[1]
|
|
734
|
+
ends = [min(ends, key=lambda e: np.linalg.norm(e - far))]
|
|
735
|
+
out = []
|
|
736
|
+
for s in b["segments"]:
|
|
737
|
+
p0, p1 = np.asarray(s["p0"], float), np.asarray(s["p1"], float)
|
|
738
|
+
a = _unit(p1 - p0)
|
|
739
|
+
for q, d in ((p0, -a), (p1, a)):
|
|
740
|
+
if min(np.linalg.norm(q - e) for e in ends) < 1e-6:
|
|
741
|
+
# overlapping the hole's last 0.25 mm, 0.01 mm thinner:
|
|
742
|
+
# no shared face, coincident or end to end
|
|
743
|
+
out.append({"type": "cylinder", "p0": (q + 0.25 * d).round(4).tolist(),
|
|
744
|
+
"p1": (q + reach * d).round(4).tolist(), "r": round(s["r"] - 0.01, 4)})
|
|
745
|
+
return out
|
|
746
|
+
|
|
747
|
+
grown = space != "part" or grow > 0
|
|
748
|
+
setup = suggest_setup(feats, force)
|
|
749
|
+
ids = {it["id"] for it in feats["interfaces"]}
|
|
750
|
+
pairs = _load_pairs(loads)
|
|
751
|
+
for k in [support] + [p[0] for p in pairs]:
|
|
752
|
+
if k is not None and k not in ids:
|
|
753
|
+
raise ValueError(f"no interface {k!r}; this part has {sorted(ids)} (see features.json)")
|
|
754
|
+
used = {support or setup.get("support")} | ({p[0] for p in pairs} if pairs else {setup.get("load")})
|
|
755
|
+
for it in feats["interfaces"]:
|
|
756
|
+
bores = [by_id[h] for h in it["bores"]]
|
|
757
|
+
cyls = [c for b in bores for c in bore_cyls(b, False)]
|
|
758
|
+
keep_out.append({"id": f"{it['id']}-hole" if it["kind"] == "bolted-face" or len(bores) > 1
|
|
759
|
+
else it["id"],
|
|
760
|
+
"shape": cyls[0] if len(cyls) == 1 else {"type": "union", "shapes": cyls}})
|
|
761
|
+
fn = fc = None
|
|
762
|
+
if it["kind"] == "bolted-face":
|
|
763
|
+
fn, fc = np.asarray(faces[it["face"]]["normal"]), np.asarray(faces[it["face"]]["center"])
|
|
764
|
+
path = [c for b in bores if grown and b["through"] for c in bore_path(b, fn, fc)]
|
|
765
|
+
if path:
|
|
766
|
+
# the bolts and shafts run on out of a grown space (or material
|
|
767
|
+
# grows over a bearing seat's ends and the shaft can't go in):
|
|
768
|
+
# kept clear, but not the region that holds or loads the
|
|
769
|
+
# interface, or the optimizer hangs the part off the line far
|
|
770
|
+
# from the bore
|
|
771
|
+
keep_out.append({"id": f"{it['id']}-path",
|
|
772
|
+
"shape": path[0] if len(path) == 1 else {"type": "union", "shapes": path}})
|
|
773
|
+
if it["kind"] == "bore" and bores[0]["r"] < SEAT_MIN_R and it["id"] not in used:
|
|
774
|
+
continue # a cable pass or a pilot hole: kept open, not kept solid
|
|
775
|
+
# preserved: rings round the bores, a pad under a bolted face, cut to the part
|
|
776
|
+
keep = []
|
|
777
|
+
face_n = face_c = None
|
|
778
|
+
if it["kind"] == "bolted-face":
|
|
779
|
+
f = faces[it["face"]]
|
|
780
|
+
face_n, face_c = np.asarray(f["normal"]), np.asarray(f["center"])
|
|
781
|
+
for b in bores:
|
|
782
|
+
if b["kind"] == "bolt":
|
|
783
|
+
# a bolt needs a flat seat under its head and where it clamps,
|
|
784
|
+
# not a boss: a washer-sized ring `pad` mm deep at each (the way
|
|
785
|
+
# fasteners.py preserves a bolted joint)
|
|
786
|
+
head, far, r_head = _bolt_ends(b, face_n, face_c)
|
|
787
|
+
a = _unit(far - head)
|
|
788
|
+
rw = washer_r(b["r"])
|
|
789
|
+
keep.append(Cylinder(head - 0.5 * a, head + pad * a, rw, 0.0).to_manifold())
|
|
790
|
+
keep.append(Cylinder(far - pad * a, far + 0.5 * a, rw, 0.0).to_manifold())
|
|
791
|
+
if grown:
|
|
792
|
+
# the head and the driver come in from outside
|
|
793
|
+
keep_out.append({"id": f"{b['id']}-driver", "bore": False, "shape": {
|
|
794
|
+
"type": "cylinder", "p0": (head - reach * a).round(4).tolist(),
|
|
795
|
+
"p1": head.round(4).tolist(), "r": round(max(r_head, rw), 4)}})
|
|
796
|
+
continue
|
|
797
|
+
if ring:
|
|
798
|
+
for s in b["segments"]:
|
|
799
|
+
p0, p1 = np.asarray(s["p0"]), np.asarray(s["p1"])
|
|
800
|
+
preserve.append({"id": f"{b['id']}-ring" + (f"-{len(preserve)}" if len(b["segments"]) > 1 else ""),
|
|
801
|
+
"shape": {"type": "cylinder", "p0": p0.round(4).tolist(),
|
|
802
|
+
"p1": p1.round(4).tolist(), "r": round(s["r"] + ring, 4),
|
|
803
|
+
"rInner": s["r"]}})
|
|
804
|
+
continue
|
|
805
|
+
w = ring_wall(b["r"])
|
|
806
|
+
for s in b["segments"]:
|
|
807
|
+
# past the bore's ends, so no cap lies on the part's own face
|
|
808
|
+
# (coplanar caps leave slivers that do not survive an STL)
|
|
809
|
+
keep.append(_tube(s, w, extend=0.5).to_manifold())
|
|
810
|
+
if it["kind"] == "bolted-face" and grown:
|
|
811
|
+
# don't grow over the mating face: clear a slab in front of it
|
|
812
|
+
slab = _extrude(mesh, f["_tris"], face_n, max(grow, 0.0) + reach)
|
|
813
|
+
keep_out.append({"id": f"{it['id']}-clear", "bore": False,
|
|
814
|
+
"shape": {"type": "mesh", "path": _write_stl(
|
|
815
|
+
slab, os.path.join(out_dir, f"{stem}-{it['id']}-clear.stl"))}})
|
|
816
|
+
if not keep:
|
|
817
|
+
continue
|
|
818
|
+
acc = keep[0]
|
|
819
|
+
for k in keep[1:]:
|
|
820
|
+
acc = acc + k
|
|
821
|
+
kept = trimesh_from_manifold(acc ^ part_m)
|
|
822
|
+
if len(kept.faces) == 0:
|
|
823
|
+
continue
|
|
824
|
+
preserve.append({"id": f"{it['id']}-keep",
|
|
825
|
+
"shape": {"type": "mesh", "path": _write_stl(
|
|
826
|
+
kept, os.path.join(out_dir, f"{stem}-{it['id']}-keep.stl"))}})
|
|
827
|
+
# every other bore stays a hole
|
|
828
|
+
covered = {h for it in feats["interfaces"] for h in it["bores"]}
|
|
829
|
+
for b in feats["bores"]:
|
|
830
|
+
if b["id"] not in covered:
|
|
831
|
+
cyls = bore_cyls(b, False)
|
|
832
|
+
keep_out.append({"id": b["id"], "shape": cyls[0] if len(cyls) == 1 else
|
|
833
|
+
{"type": "union", "shapes": cyls}})
|
|
834
|
+
|
|
835
|
+
study = {"name": stem,
|
|
836
|
+
"description": f"Drafted by `loadpath import` from {os.path.basename(part.path or 'a mesh')}: "
|
|
837
|
+
f"{len(feats['bores'])} bores, {len(feats['faces'])} mounting faces, "
|
|
838
|
+
f"{len(feats['interfaces'])} interfaces. Check the loads before trusting a result.",
|
|
839
|
+
"designSpace": design, "preserve": preserve, "keepOut": keep_out,
|
|
840
|
+
"material": material, "goal": {"safetyFactor": safety_factor}}
|
|
841
|
+
if process:
|
|
842
|
+
study["process"] = process
|
|
843
|
+
ko_of = {}
|
|
844
|
+
for it in feats["interfaces"]:
|
|
845
|
+
bores = it["bores"]
|
|
846
|
+
ko_of[it["id"]] = f"{it['id']}-hole" if it["kind"] == "bolted-face" or len(bores) > 1 else it["id"]
|
|
847
|
+
hold = support or setup.get("support")
|
|
848
|
+
if hold:
|
|
849
|
+
study["supports"] = [{"region": ko_of[hold]}]
|
|
850
|
+
given = bool(pairs)
|
|
851
|
+
if not pairs and setup.get("load"):
|
|
852
|
+
pairs = [(setup["load"], setup["force"])]
|
|
853
|
+
cases = []
|
|
854
|
+
for k, (iid, fv) in enumerate(pairs):
|
|
855
|
+
it = next(i for i in feats["interfaces"] if i["id"] == iid)
|
|
856
|
+
ld = {"region": ko_of[iid], "force": [float(x) for x in fv]}
|
|
857
|
+
if it["kind"] == "bore" and len(by_id[it["bores"][0]]["segments"]) == 1:
|
|
858
|
+
ld["distribution"] = "bearing"
|
|
859
|
+
cases.append({"name": f"load-{k + 1}" if given else "suggested", "loads": [ld]})
|
|
860
|
+
if cases:
|
|
861
|
+
study["loadCases"] = cases
|
|
862
|
+
feats_out = {k: v for k, v in feats.items() if k != "faces"}
|
|
863
|
+
feats_out["faces"] = [{k: v for k, v in f.items() if not k.startswith("_")} for f in feats["faces"]]
|
|
864
|
+
feats_out["suggested"] = setup
|
|
865
|
+
with open(os.path.join(out_dir, f"{stem}.study.json"), "w") as fh:
|
|
866
|
+
json.dump(study, fh, indent=1)
|
|
867
|
+
with open(os.path.join(out_dir, f"{stem}.features.json"), "w") as fh:
|
|
868
|
+
json.dump(feats_out, fh, indent=1)
|
|
869
|
+
return study
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def summarize(feats: dict) -> dict:
|
|
873
|
+
"""The short read of a part's features: counts, and each interface in a
|
|
874
|
+
line an agent can pick from."""
|
|
875
|
+
by_id = {b["id"]: b for b in feats["bores"]}
|
|
876
|
+
rows = []
|
|
877
|
+
for it in feats["interfaces"]:
|
|
878
|
+
bs = [by_id[h] for h in it["bores"]]
|
|
879
|
+
if it["kind"] == "bore":
|
|
880
|
+
b = bs[0]
|
|
881
|
+
what = f"bore r {b['r']:g} mm x {b['length']:g} mm, {'through' if b['through'] else 'blind'}"
|
|
882
|
+
else:
|
|
883
|
+
rs = sorted({round(b["r"], 2) for b in bs})
|
|
884
|
+
what = f"{len(bs)} bolt hole(s) r {'/'.join(f'{r:g}' for r in rs)} mm" + (
|
|
885
|
+
f" on {it['face']}" if it["kind"] == "bolted-face" else "")
|
|
886
|
+
rows.append({"id": it["id"], "kind": it["kind"], "what": what, "center": it["center"]})
|
|
887
|
+
return {"bolts": sum(b["kind"] == "bolt" for b in feats["bores"]),
|
|
888
|
+
"bores": sum(b["kind"] == "bore" for b in feats["bores"]),
|
|
889
|
+
"boltedFaces": sum(it["kind"] == "bolted-face" for it in feats["interfaces"]),
|
|
890
|
+
"mountingFaces": len(feats["faces"]), "interfaces": rows}
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def import_part(path, out_dir: str | None = None, *, tolerance: float = DEFAULT_TOLERANCE, **kw) -> dict:
|
|
894
|
+
"""Read a part, find its features and (with `out_dir`) draft its study.
|
|
895
|
+
Returns {"features", "study"?}."""
|
|
896
|
+
part = load_part(path, tolerance)
|
|
897
|
+
feats = detect_features(part)
|
|
898
|
+
out = {"features": {k: v for k, v in feats.items() if k != "faces"}}
|
|
899
|
+
out["features"]["faces"] = [{k: v for k, v in f.items() if not k.startswith("_")} for f in feats["faces"]]
|
|
900
|
+
out["features"]["suggested"] = suggest_setup(feats, kw.get("force", 100.0))
|
|
901
|
+
out["summary"] = summarize(out["features"])
|
|
902
|
+
if out_dir is not None:
|
|
903
|
+
out["study"] = draft_study(part, feats, out_dir, **kw)
|
|
904
|
+
return out
|