31/32/33 each hold one piece, so "-x4"/"-x16" read as if the files held 4 and 16 copies. Renamed to -print-4 / -print-16 and rebuilt the Bambu Studio projects. The MakerWorld upload package lives in publish/ (copies, not in git). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
9.8 KiB
Python
203 lines
9.8 KiB
Python
"""Build Bambu Studio project files (.3mf) for the sand table from the STLs in print/v3.
|
|
|
|
Run from the repository root: python3 print/tools/make_3mf.py
|
|
Needs Bambu Studio (the flatpak, com.bambulab.BambuStudio) on this computer; it is used
|
|
from the command line to lay the parts out on plates. Writes:
|
|
print/3mf/sand-table-v3-P1S.3mf every part, on plates, oriented and with settings
|
|
print/3mf/sand-table-v3-P1S-options.3mf the alternatives (12.5 mm sand ring, M2 switch mount)
|
|
|
|
Open them in Bambu Studio, check the plates, slice, and save before uploading to MakerWorld.
|
|
"""
|
|
import json
|
|
import math
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
V3 = os.path.join(ROOT, 'print', 'v3')
|
|
OUT = os.path.join(ROOT, 'print', '3mf')
|
|
WORK = os.path.expanduser('~/.cache/sandtable-3mf') # the flatpak can only see the home folder
|
|
SYS = os.path.expanduser('~/.var/app/com.bambulab.BambuStudio/config/BambuStudio/system/BBL')
|
|
MACHINE, PROCESS = 'Bambu Lab P1S 0.4 nozzle', '0.20mm Standard @BBL X1C'
|
|
PLA, PETG = 'Bambu PLA Basic @BBL P1S 0.4 nozzle', 'Bambu PETG Basic @BBL X1C'
|
|
FLIP = 'flip' # print upside down (top face on the bed)
|
|
|
|
SUPPORTS = {'enable_support': '1', 'support_type': 'tree(auto)'}
|
|
SOLID = {'sparse_infill_density': '100%'}
|
|
STRONG = {'wall_loops': '4', 'sparse_infill_density': '40%'}
|
|
|
|
# (file, filament 1=PLA 2=PETG, orientation, count, print settings)
|
|
PLATES = [
|
|
('Fit tests - print these first', [
|
|
('00-fit-test-608.stl', 2, None, 1, {}),
|
|
('00b-fit-test-6804.stl', 2, None, 1, {}),
|
|
]),
|
|
('Shell 1 + 2 (PLA)', [('01-shell-Q1-cable-notch.stl', 1, None, 1, {}), ('02-shell-Q2.stl', 1, None, 1, {})]),
|
|
('Shell 3 + 4 (PLA)', [('03-shell-Q3.stl', 1, None, 1, {}), ('04-shell-Q4.stl', 1, None, 1, {})]),
|
|
('Deck 1 + 2 (PLA)', [('05-deck-Q1-arduino.stl', 1, FLIP, 1, {}), ('06-deck-Q2.stl', 1, FLIP, 1, {})]),
|
|
('Deck 3 + 4 (PLA)', [('07-deck-Q3.stl', 1, FLIP, 1, {}), ('08-deck-Q4.stl', 1, FLIP, 1, {})]),
|
|
('Sand ring (PLA)', [('21-sand-ring-Q1.stl', 1, None, 1, {}), ('22-sand-ring-Q2.stl', 1, None, 1, {}),
|
|
('23-sand-ring-Q3.stl', 1, None, 1, {}), ('24-sand-ring-Q4.stl', 1, None, 1, {})]),
|
|
('Centre (PETG)', [
|
|
('09-center-plate.stl', 2, FLIP, 1, {}),
|
|
('10-hub-80T-pulley-bolted.stl', 2, FLIP, 1, STRONG),
|
|
('11-hub-clamp-collar.stl', 2, None, 1, STRONG),
|
|
('26-slip-ring-coupler.stl', 2, None, 1, {}),
|
|
('27-slip-ring-holder.stl', 2, None, 1, {}),
|
|
]),
|
|
('Arm (PETG)', [
|
|
('17-carriage.stl', 2, FLIP, 1, STRONG),
|
|
('13-arm-motor-bracket-SUPPORTS-tall-window.stl', 2, FLIP, 1, dict(STRONG, **SUPPORTS)),
|
|
('15-idler-bracket.stl', 2, None, 1, STRONG),
|
|
('16-idler-sleeve-wide.stl', 2, None, 1, {}),
|
|
('18-magnet-cup-short.stl', 2, None, 1, {}),
|
|
('14-switch-mount-pegs.stl', 2, None, 1, {}),
|
|
('19-roller-washers-x4.stl', 2, None, 1, {}),
|
|
('19b-idler-washer.stl', 2, None, 1, {}),
|
|
('20-magnet-spacers-1-2-3mm.stl', 2, None, 1, {}),
|
|
('25-carriage-striker.stl', 2, None, 1, {}),
|
|
('30-motor-bracket-shim.stl', 2, None, 1, {}),
|
|
]),
|
|
('Counterweight, Uno plate, seam parts (PLA)', [
|
|
('34-counterweight.stl', 1, None, 1, SOLID),
|
|
('35-counterweight-lid.stl', 1, None, 1, {}),
|
|
('36-uno-turnaround-plate.stl', 1, None, 1, {}),
|
|
('31-seam-plate-low-print-4.stl', 1, None, 4, {}),
|
|
('32-seam-plate-high-print-4.stl', 1, None, 4, {}),
|
|
('33-seam-plug-print-16.stl', 1, None, 16, {}),
|
|
]),
|
|
]
|
|
|
|
OPTIONS = [
|
|
('Sand ring 12.5 mm 1 + 2 - only for a raised floor', [('21-sand-ring-Q1-12.5mm.stl', 1, None, 1, {}),
|
|
('22-sand-ring-Q2-12.5mm.stl', 1, None, 1, {})]),
|
|
('Sand ring 12.5 mm 3 + 4 - only for a raised floor', [('23-sand-ring-Q3-12.5mm.stl', 1, None, 1, {}),
|
|
('24-sand-ring-Q4-12.5mm.stl', 1, None, 1, {})]),
|
|
('Switch mount for 2x M2 screws (instead of the peg one)', [('14-switch-mount-M2.stl', 2, None, 1, {})]),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------- presets
|
|
def _find(kind, name):
|
|
for k in (kind, 'machine', 'process', 'filament'):
|
|
p = os.path.join(SYS, k, name + '.json')
|
|
if os.path.exists(p):
|
|
return p
|
|
raise FileNotFoundError(f'Bambu Studio preset {name!r} not found under {SYS}')
|
|
|
|
|
|
def flatten(kind, name):
|
|
"""A system preset with its `inherits` chain and `include` templates merged in, so the
|
|
command line gets the whole thing (it does not follow them itself)."""
|
|
with open(_find(kind, name)) as f:
|
|
own = json.load(f)
|
|
out = {}
|
|
if own.get('inherits'):
|
|
out.update(flatten(kind, own['inherits']))
|
|
for inc in own.get('include', []) or []:
|
|
out.update({k: v for k, v in flatten(kind, inc).items() if k not in ('name', 'inherits', 'include')})
|
|
out.update({k: v for k, v in own.items() if k not in ('inherits', 'include')})
|
|
return out
|
|
|
|
|
|
# ---------------------------------------------------------------- meshes
|
|
def write_oriented(src, dst, how):
|
|
"""Copy an STL turned the way it prints, centred, and sitting on z = 0 (the files keep their
|
|
place in the table's model, so most of them float far above the bed)."""
|
|
b = open(src, 'rb').read()
|
|
n = struct.unpack('<I', b[80:84])[0]
|
|
tris = []
|
|
for i in range(n):
|
|
v = list(struct.unpack('<12f', b[84 + 50 * i:84 + 50 * i + 48]))
|
|
if how == FLIP: # turn over about X: (x, y, z) -> (x, -y, -z)
|
|
for k in range(0, 12, 3):
|
|
v[k + 1], v[k + 2] = -v[k + 1], -v[k + 2]
|
|
tris.append(v)
|
|
pts = [(v[k], v[k + 1], v[k + 2]) for v in tris for k in (3, 6, 9)]
|
|
cx = (min(p[0] for p in pts) + max(p[0] for p in pts)) / 2
|
|
cy = (min(p[1] for p in pts) + max(p[1] for p in pts)) / 2
|
|
z0 = min(p[2] for p in pts)
|
|
out = bytearray(b[:84])
|
|
for v in tris:
|
|
for k in (3, 6, 9):
|
|
v[k], v[k + 1], v[k + 2] = v[k] - cx, v[k + 1] - cy, v[k + 2] - z0
|
|
out += struct.pack('<12fH', *v, 0)
|
|
open(dst, 'wb').write(out)
|
|
|
|
|
|
def build(plates, out_3mf):
|
|
os.makedirs(WORK, exist_ok=True)
|
|
os.makedirs(OUT, exist_ok=True)
|
|
settings = []
|
|
for kind, name in (('machine', MACHINE), ('process', PROCESS)):
|
|
p = os.path.join(WORK, f'{kind}.json')
|
|
json.dump(flatten(kind, name), open(p, 'w'), indent=1)
|
|
settings.append(p)
|
|
fils = []
|
|
for i, name in enumerate((PLA, PETG), 1):
|
|
p = os.path.join(WORK, f'filament{i}.json')
|
|
json.dump(flatten('filament', name), open(p, 'w'), indent=1)
|
|
fils.append(p)
|
|
asm = {'plates': []}
|
|
for title, objs in plates:
|
|
plate = {'plate_name': title, 'need_arrange': True, 'objects': []}
|
|
for fn, fil, how, count, params in objs:
|
|
dst = os.path.join(WORK, fn)
|
|
write_oriented(os.path.join(V3, fn), dst, how)
|
|
plate['objects'].append({'path': dst, 'count': count, 'filaments': [fil],
|
|
'pos_x': [0.0] * count, 'pos_y': [0.0] * count, 'pos_z': [0.0] * count,
|
|
'print_params': params})
|
|
asm['plates'].append(plate)
|
|
asm_path = os.path.join(WORK, 'assemble.json')
|
|
json.dump(asm, open(asm_path, 'w'), indent=1)
|
|
tmp = os.path.join(WORK, 'out.3mf')
|
|
if os.path.exists(tmp):
|
|
os.remove(tmp)
|
|
cmd = ['flatpak', 'run', '--command=bambu-studio', 'com.bambulab.BambuStudio',
|
|
'--load-settings', ';'.join(settings), '--load-filaments', ';'.join(fils),
|
|
'--load-assemble-list', asm_path, '--allow-rotations', '--export-3mf', 'out.3mf', '--outputdir', WORK]
|
|
r = subprocess.run(cmd, capture_output=True, text=True, timeout=900, cwd=WORK) # it leaves a result.json behind
|
|
if not os.path.exists(tmp):
|
|
sys.exit('Bambu Studio did not write the 3mf:\n' + r.stdout[-3000:] + r.stderr[-3000:])
|
|
set_bed_type(tmp, out_3mf)
|
|
return out_3mf
|
|
|
|
|
|
COLOURS = ['#161616', '#A8A8A8'] # PLA black (the case), PETG grey (the moving parts)
|
|
|
|
|
|
def set_bed_type(src, dst, bed='Textured PEI Plate'):
|
|
"""Fix up the project settings the command line leaves unfinished:
|
|
- the bed type (there is no option for it);
|
|
- per-filament lists that it leaves with one value although there are two filaments
|
|
(colour, filament-to-nozzle map and others). Slicing a plate printed in filament 2 then
|
|
crashes, so give every such list one value per filament."""
|
|
zin = zipfile.ZipFile(src)
|
|
with zipfile.ZipFile(dst + '.tmp', 'w', zipfile.ZIP_DEFLATED) as zout:
|
|
for item in zin.infolist():
|
|
data = zin.read(item.filename)
|
|
if item.filename == 'Metadata/project_settings.config':
|
|
cfg = json.loads(data)
|
|
cfg['curr_bed_type'] = bed
|
|
n = len(cfg['filament_type'])
|
|
for k, v in cfg.items():
|
|
if k.startswith(('filament_', 'first_x_layer')) and isinstance(v, list) and len(v) == 1 and n > 1:
|
|
cfg[k] = v * n
|
|
cfg['filament_colour'] = COLOURS[:n]
|
|
cfg['filament_map'] = ['1'] * n
|
|
# purge volumes when changing filament: an n x n table (no plate mixes the two)
|
|
cfg['flush_volumes_matrix'] = ['0' if i == j else '280' for i in range(n) for j in range(n)]
|
|
cfg['flush_volumes_vector'] = ['140'] * (2 * n)
|
|
data = json.dumps(cfg, indent=4).encode()
|
|
zout.writestr(item, data)
|
|
os.replace(dst + '.tmp', dst)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
for plates, name in ((PLATES, 'sand-table-v3-P1S.3mf'), (OPTIONS, 'sand-table-v3-P1S-options.3mf')):
|
|
print('wrote', build(plates, os.path.join(OUT, name)))
|