建立方向盘项目初始版本
This commit is contained in:
134
scripts/build_pcd50_print_plate.py
Normal file
134
scripts/build_pcd50_print_plate.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Place existing PCD50 STL meshes on two print plates without changing shapes.
|
||||
|
||||
Run with FreeCAD's Python, for example:
|
||||
& 'C:\\Users\\billy\\scoop\\apps\\freecad\\current\\bin\\python.exe' scripts/build_pcd50_print_plate.py
|
||||
|
||||
The main plate is one bracket and four 0.5 mm shims. The second plate holds
|
||||
four optional 1 mm shims. Output is independent of the input files.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import itertools
|
||||
import math
|
||||
import FreeCAD as App
|
||||
import Mesh
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SOURCE = ROOT / 'models' / 'pcd50_open_pillar_v1'
|
||||
OUT = ROOT / 'models' / 'pcd50_print_plate'
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
V = App.Vector
|
||||
|
||||
def load(name):
|
||||
mesh = Mesh.Mesh(str(SOURCE / name))
|
||||
if len(mesh.getSeparateComponents()) != 1 or not mesh.isSolid():
|
||||
raise RuntimeError('Input is not one closed component: ' + name)
|
||||
return mesh
|
||||
|
||||
bracket = load('EncoderBracket.stl')
|
||||
shim05 = load('Shim05.stl')
|
||||
shim10 = load('Shim10.stl')
|
||||
|
||||
# X positions are chosen to give at least 8 mm between the shims' actual
|
||||
# bounding boxes. The 49 mm bracket sits 8 mm above their actual Y maximum.
|
||||
# Every placement uses the original tessellation; no rotations or rescaling.
|
||||
row = [(6 + 20*i, 6.0) for i in range(4)]
|
||||
bracket_pos = (35.77, 44.5)
|
||||
|
||||
def moved(mesh, x, y):
|
||||
result = mesh.copy()
|
||||
result.translate(x, y, -mesh.BoundBox.ZMin)
|
||||
return result
|
||||
|
||||
def bounds(m):
|
||||
b = m.BoundBox
|
||||
return [b.XMin, b.XMax, b.YMin, b.YMax, b.ZMin, b.ZMax]
|
||||
|
||||
def distance_xy(a, b):
|
||||
a, b = a.BoundBox, b.BoundBox
|
||||
dx = max(a.XMin - b.XMax, b.XMin - a.XMax, 0.0)
|
||||
dy = max(a.YMin - b.YMax, b.YMin - a.YMax, 0.0)
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
def equivalent(a, b):
|
||||
"""Compare vertices and facet topology after removing translation.
|
||||
|
||||
Float STL coordinates round slightly when translated/reloaded. A small
|
||||
tolerance is therefore used rather than comparing quantized text.
|
||||
"""
|
||||
if a.CountFacets != b.CountFacets:
|
||||
return False
|
||||
pa, fa = a.Topology; pb, fb = b.Topology
|
||||
if len(pa) != len(pb):
|
||||
return False
|
||||
ba, bb = a.BoundBox, b.BoundBox
|
||||
def norm(p, box): return (p.x-box.XMin,p.y-box.YMin,p.z-box.ZMin)
|
||||
qa=[norm(p,ba) for p in pa]; qb=[norm(p,bb) for p in pb]
|
||||
tol=0.0001
|
||||
def key(v): return tuple(math.floor(x/tol) for x in v)
|
||||
bins={}
|
||||
for i,v in enumerate(qa):bins.setdefault(key(v),[]).append(i)
|
||||
matches=[]
|
||||
for v in qb:
|
||||
k=key(v); candidates=[]
|
||||
for offset in itertools.product((-1,0,1),repeat=3):
|
||||
for i in bins.get(tuple(k[j]+offset[j] for j in range(3)),[]):
|
||||
dist=max(abs(v[j]-qa[i][j]) for j in range(3))
|
||||
if dist<tol:candidates.append((dist,i))
|
||||
if not candidates:return False
|
||||
matches.append(min(candidates)[1])
|
||||
if len(set(matches)) != len(qa):return False
|
||||
af=sorted(tuple(sorted(tri)) for tri in fa)
|
||||
bf=sorted(tuple(sorted(matches[i] for i in tri)) for tri in fb)
|
||||
return af==bf
|
||||
|
||||
def plate(stem, specs):
|
||||
document = App.newDocument(stem)
|
||||
meshes = []
|
||||
combined = Mesh.Mesh()
|
||||
info = []
|
||||
for name, original, x, y in specs:
|
||||
m = moved(original, x, y)
|
||||
if not m.isSolid() or len(m.getSeparateComponents()) != 1:
|
||||
raise RuntimeError('Invalid input mesh after placement: ' + name)
|
||||
if not equivalent(original, m):
|
||||
raise RuntimeError('Geometry changed during placement: ' + name)
|
||||
obj = document.addObject('Mesh::Feature', name)
|
||||
obj.Label = name
|
||||
obj.Mesh = m
|
||||
combined.addMesh(m)
|
||||
meshes.append(m)
|
||||
info.append({'name':name, 'facets':m.CountFacets,
|
||||
'volume_mm3':m.Volume, 'bounds_mm':bounds(m)})
|
||||
for i, a in enumerate(meshes):
|
||||
for b in meshes[i+1:]:
|
||||
d=distance_xy(a,b)
|
||||
if d < 8.0:
|
||||
raise RuntimeError('Parts too close: %.6f mm bounding-box distance' % d)
|
||||
min_gap=min(distance_xy(a,b) for i,a in enumerate(meshes) for b in meshes[i+1:])
|
||||
combined.write(str(OUT / (stem+'.stl')))
|
||||
document.recompute()
|
||||
document.saveAs(str(OUT / (stem+'.FCStd')))
|
||||
App.closeDocument(document.Name)
|
||||
reloaded=Mesh.Mesh(str(OUT / (stem+'.stl')))
|
||||
parts=reloaded.getSeparateComponents()
|
||||
if len(parts)!=len(specs) or any(not p.isSolid() for p in parts):
|
||||
raise RuntimeError('Export does not contain the expected closed components')
|
||||
for expected in meshes:
|
||||
candidates=[p for p in parts if abs(p.BoundBox.XMin-expected.BoundBox.XMin)<0.0001
|
||||
and abs(p.BoundBox.YMin-expected.BoundBox.YMin)<0.0001]
|
||||
if len(candidates)!=1 or not equivalent(expected,candidates[0]):
|
||||
raise RuntimeError('Exported mesh differs from the placed original mesh')
|
||||
if min(p.BoundBox.ZMin for p in parts) < -0.001 or max(abs(p.BoundBox.ZMin) for p in parts) > .001:
|
||||
raise RuntimeError('Parts are not all at Z=0')
|
||||
return {'file':stem+'.stl', 'components':len(parts),
|
||||
'all_closed':True, 'geometry_preserved':True,
|
||||
'minimum_bounding_box_gap_mm':min_gap,
|
||||
'bounds_mm':bounds(reloaded), 'objects':info}
|
||||
|
||||
main=[('Bracket',bracket,*bracket_pos)]+[('Shim05_%d'%(i+1),shim05,*xy) for i,xy in enumerate(row)]
|
||||
spare=[('Shim10_%d'%(i+1),shim10,*xy) for i,xy in enumerate(row)]
|
||||
result={'main':plate('支架和四个0.5mm垫片',main),
|
||||
'spare':plate('四个1mm备用垫片',spare)}
|
||||
(OUT/'布局检查.json').write_text(json.dumps(result,ensure_ascii=False,indent=2),encoding='utf-8')
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2))
|
||||
Reference in New Issue
Block a user