Files
ckochandClaude Opus 5.5 72ffefcd38 Seam part files named for how many to print; publish/ ignored
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>
2026-11-01 14:45:46 -05:00

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)))