Sand table v3: build fixes, bolted hub, counterweight, print guide
- print/ reorganised: v1-original (first export), v2-current, the v2.1-v2.7 fixes found while building, and v3/ with every printed part. - v3: deck screws you can reach, seam joints from outside, arm bolted straight to the hub, 6804 hub bearings with a 15 mm bore for connectors, clamp collar, counterweight, 12.5 mm sand ring for a raised floor, Uno turnaround plate. - print/README.md: full v3 parts list, what changed and why, assembly order. - docs/: diagrams for each change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,142 @@
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SRC = '/home/ckoch/ComfyUI/output/sandtable/step{}_00001_.png'
|
||||
OUT = '/home/ckoch/Documents/sandTable/docs/build-guide.png'
|
||||
F = '/usr/share/fonts/truetype/noto/NotoSans-{}.ttf'
|
||||
MONO = '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'
|
||||
INK, GRAY, TEAL, RULE = (27, 36, 32), (90, 103, 98), (31, 128, 116), (214, 222, 218)
|
||||
|
||||
STEPS = [
|
||||
("Shell and deck",
|
||||
["Join the 4 shell quarters at the seams.",
|
||||
"Screw the 4 deck quarters onto the wall ledge. Deck Q1 (controller standoffs) goes under shell Q1 (cable notch)."],
|
||||
"8x M3x16 + nuts · 8x M3x8 button"),
|
||||
("Center plate and hub",
|
||||
["Screw the center plate onto the deck.",
|
||||
"Press the two 608 bearings into the housing, one from each side, and drop the hub shaft through."],
|
||||
"8x M3x8 button · 2x 608"),
|
||||
("Slip ring (seen from below)",
|
||||
["Glue the adapter onto the bottom of the shaft, pressing up on the lower bearing: no play, still spins freely.",
|
||||
"Push the printed coupler's 3 pegs into the adapter's holes (drop of CA); its slot slides over the top wires.",
|
||||
"Only the slip ring's center boss turns: press it into the coupler's socket with a dab of gel CA. The flange stays free.",
|
||||
"Slide the holder (part 27) up around the slip-ring body and onto the end of the tower; peg in a flange hole. Glue only its block to the tower."],
|
||||
"CA glue · coupler (26) · holder (27)"),
|
||||
("Spin motor (seen from below)",
|
||||
["Hang the motor under the center plate with its 20T pulley, and loop the 200 mm belt over the hub.",
|
||||
"Slide the motor in its slots to tension the belt, then tighten."],
|
||||
"4x M3x12 socket"),
|
||||
("Arm",
|
||||
["Bolt the 220 mm extrusion to the hub, 80 mm behind center and 140 mm ahead.",
|
||||
"Tip: idler bracket, 608 and sleeve. Back: motor bracket, arm motor, and the switch mount on top."],
|
||||
"4x M5x12 socket · 3x M5x8 · 7x T-nuts · 4x M3x6 · 1x 608"),
|
||||
("Carriage",
|
||||
["Fit the 4 roller bearings with washers and slide the carriage on.",
|
||||
"Loop the belt around the motor pulley and idler; clamp both ends in the carriage.",
|
||||
"Magnets and spacer in the cup, felt on top, glue in. Glue the striker on the back edge."],
|
||||
"5x M3x8 button · 4x 608 · 2x magnets"),
|
||||
("Electronics (seen from below)",
|
||||
["Mount the Arduino + CNC shield under deck Q1, components facing down.",
|
||||
"The arm motor (4 wires) and home switch (2 wires) run through the slip ring.",
|
||||
"Fit all 3 microstep jumpers per driver and set each DRV8825's current before plugging in motors."],
|
||||
"4x M3x6 socket"),
|
||||
("Close it up",
|
||||
["Test both axes with the floor off first.",
|
||||
"Then the plexi floor, sand ring, about 4 mm of fine sand, the 3/8\" ball, and finally the glass."],
|
||||
"~0.4 kg sand · 3/8\" steel ball"),
|
||||
]
|
||||
|
||||
W, M, G = 2400, 80, 70
|
||||
PW = (W - 2 * M - G) // 2
|
||||
f_title = ImageFont.truetype(F.format('Bold'), 84)
|
||||
f_sub = ImageFont.truetype(F.format('Regular'), 34)
|
||||
f_step = ImageFont.truetype(F.format('Bold'), 46)
|
||||
f_num = ImageFont.truetype(F.format('Bold'), 40)
|
||||
f_body = ImageFont.truetype(F.format('Regular'), 31)
|
||||
f_hw = ImageFont.truetype(MONO, 27)
|
||||
f_hwl = ImageFont.truetype(F.format('Bold'), 25)
|
||||
f_foot = ImageFont.truetype(F.format('Regular'), 29)
|
||||
f_footb = ImageFont.truetype(F.format('Bold'), 29)
|
||||
|
||||
|
||||
def wrap(draw, text, font, width):
|
||||
words, lines, cur = text.split(), [], ''
|
||||
for w in words:
|
||||
t = (cur + ' ' + w).strip()
|
||||
if draw.textlength(t, font=font) <= width:
|
||||
cur = t
|
||||
else:
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
lines.append(cur)
|
||||
return lines
|
||||
|
||||
|
||||
def panel(i, title, bullets, hw):
|
||||
img = Image.open(SRC.format(i)).convert('RGB')
|
||||
img = img.resize((PW, round(img.height * PW / img.width)), Image.LANCZOS)
|
||||
tmp = ImageDraw.Draw(Image.new('RGB', (1, 1)))
|
||||
body = []
|
||||
for b in bullets:
|
||||
body.append(wrap(tmp, b, f_body, PW - 40))
|
||||
lh = 44
|
||||
hw_lines, cur = [], ''
|
||||
for tok in hw.split(' · '):
|
||||
t = (cur + ' · ' + tok) if cur else tok
|
||||
if tmp.textlength(t, font=f_hw) <= PW - 160:
|
||||
cur = t
|
||||
else:
|
||||
hw_lines.append(cur)
|
||||
cur = tok
|
||||
hw_lines.append(cur)
|
||||
h = 80 + img.height + 20 + sum(len(b) * lh + 10 for b in body) + 20 + 40 * len(hw_lines) + 10
|
||||
p = Image.new('RGB', (PW, h), 'white')
|
||||
d = ImageDraw.Draw(p)
|
||||
d.ellipse((0, 4, 64, 68), fill=TEAL)
|
||||
d.text((32, 36), str(i), font=f_num, fill='white', anchor='mm')
|
||||
d.text((86, 36), title, font=f_step, fill=INK, anchor='lm')
|
||||
y = 80
|
||||
p.paste(img, (0, y))
|
||||
y += img.height + 20
|
||||
for lines in body:
|
||||
d.ellipse((6, y + 15, 18, y + 27), fill=TEAL)
|
||||
for ln in lines:
|
||||
d.text((40, y), ln, font=f_body, fill=INK)
|
||||
y += lh
|
||||
y += 10
|
||||
y += 14
|
||||
d.text((0, y + 6), 'HARDWARE', font=f_hwl, fill=TEAL)
|
||||
for ln in hw_lines:
|
||||
d.text((150, y + 4), ln, font=f_hw, fill=GRAY)
|
||||
y += 40
|
||||
return p
|
||||
|
||||
|
||||
panels = [panel(i + 1, *s) for i, s in enumerate(STEPS)]
|
||||
rows = [panels[k:k + 2] for k in range(0, 8, 2)]
|
||||
head_h = 260
|
||||
foot_lines = [
|
||||
("Home switch: ", "use a KW12-B with a straight lever. Reprint the switch mount and glue on the striker from print/v2.1-switch-update."),
|
||||
("Fit first: ", "print 00-fit-test-coupon; every printed part is set for shaft 8.1 mm and bearing pocket 22.2 mm."),
|
||||
]
|
||||
foot_h = 70 + 50 * len(foot_lines) + 60
|
||||
H = head_h + sum(max(p.height for p in r) + 70 for r in rows) + foot_h
|
||||
canvas = Image.new('RGB', (W, H), 'white')
|
||||
d = ImageDraw.Draw(canvas)
|
||||
d.text((M, 70), 'Sand table build guide', font=f_title, fill=INK)
|
||||
d.text((M, 180), '12" polar kinetic sand table · 8 steps · printed parts: print/v2-current + print/v2.1-switch-update', font=f_sub, fill=GRAY)
|
||||
y = head_h
|
||||
for r in rows:
|
||||
d.line((M, y, W - M, y), fill=RULE, width=3)
|
||||
y += 40
|
||||
for k, p in enumerate(r):
|
||||
canvas.paste(p, (M + k * (PW + G), y))
|
||||
y += max(p.height for p in r) + 30
|
||||
d.line((M, y, W - M, y), fill=RULE, width=3)
|
||||
y += 40
|
||||
for b, t in foot_lines:
|
||||
d.text((M, y), b, font=f_footb, fill=INK)
|
||||
d.text((M + d.textlength(b, font=f_footb), y), t, font=f_foot, fill=INK)
|
||||
y += 50
|
||||
d.text((M, y + 10), 'Drawings: renders of the SolidCraft model, restyled with Qwen-Image-Edit in ComfyUI.', font=f_foot, fill=GRAY)
|
||||
canvas.save(OUT, optimize=True)
|
||||
print(OUT, canvas.size)
|
||||
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 307 KiB |
@@ -0,0 +1,57 @@
|
||||
"""Restyle CAD renders with Qwen-Image-Edit-2511 (Lightning 8-step) through the local ComfyUI API."""
|
||||
import json, sys, time, urllib.request, random
|
||||
|
||||
API = 'http://127.0.0.1:8188'
|
||||
|
||||
STYLE = ("Redraw this 3D CAD render as a clean technical illustration for an assembly instruction manual. "
|
||||
"Pure white background. Crisp dark outlines and flat soft shading, like a high-quality IKEA or LEGO instruction drawing. "
|
||||
"Keep every part exactly where it is, with the same shapes, proportions, colors, camera angle and framing. "
|
||||
"Do not add, remove, move or reshape any part. No text, no labels, no arrows, no numbers.")
|
||||
|
||||
|
||||
def workflow(image, prompt, prefix, seed):
|
||||
return {
|
||||
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "qwen_image_edit_2511_fp8mixed.safetensors", "weight_dtype": "default"}},
|
||||
"2": {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0], "lora_name": "Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors", "strength_model": 1.0}},
|
||||
"3": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["2", 0], "shift": 3.1}},
|
||||
"4": {"class_type": "CFGNorm", "inputs": {"model": ["3", 0], "strength": 1.0}},
|
||||
"5": {"class_type": "CLIPLoader", "inputs": {"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors", "type": "qwen_image"}},
|
||||
"6": {"class_type": "VAELoader", "inputs": {"vae_name": "qwen_image_vae.safetensors"}},
|
||||
"7": {"class_type": "LoadImage", "inputs": {"image": image}},
|
||||
"8": {"class_type": "ImageScaleToTotalPixels", "inputs": {"image": ["7", 0], "upscale_method": "lanczos", "megapixels": 1.0, "resolution_steps": 16}},
|
||||
"9": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["5", 0], "prompt": prompt, "vae": ["6", 0], "image1": ["8", 0]}},
|
||||
"10": {"class_type": "TextEncodeQwenImageEditPlus", "inputs": {"clip": ["5", 0], "prompt": "", "vae": ["6", 0], "image1": ["8", 0]}},
|
||||
"11": {"class_type": "VAEEncode", "inputs": {"pixels": ["8", 0], "vae": ["6", 0]}},
|
||||
"12": {"class_type": "KSampler", "inputs": {"model": ["4", 0], "seed": seed, "steps": 8, "cfg": 1.0, "sampler_name": "euler",
|
||||
"scheduler": "simple", "positive": ["9", 0], "negative": ["10", 0],
|
||||
"latent_image": ["11", 0], "denoise": 1.0}},
|
||||
"13": {"class_type": "VAEDecode", "inputs": {"samples": ["12", 0], "vae": ["6", 0]}},
|
||||
"14": {"class_type": "SaveImage", "inputs": {"images": ["13", 0], "filename_prefix": prefix}},
|
||||
}
|
||||
|
||||
|
||||
def run(image, prompt, prefix, seed=None):
|
||||
seed = seed if seed is not None else random.randint(0, 2**31)
|
||||
body = json.dumps({"prompt": workflow(image, prompt, prefix, seed)}).encode()
|
||||
req = urllib.request.Request(API + '/prompt', data=body, headers={'Content-Type': 'application/json'})
|
||||
pid = json.load(urllib.request.urlopen(req))['prompt_id']
|
||||
t0 = time.time()
|
||||
while True:
|
||||
h = json.load(urllib.request.urlopen(f'{API}/history/{pid}'))
|
||||
if pid in h:
|
||||
st = h[pid].get('status', {})
|
||||
if st.get('status_str') == 'error':
|
||||
sys.exit(f'error: {json.dumps(st)[:2000]}')
|
||||
outs = [img for node in h[pid]['outputs'].values() for img in node.get('images', [])]
|
||||
if outs:
|
||||
f = outs[0]
|
||||
path = f"/home/ckoch/ComfyUI/output/{f['subfolder'] + '/' if f['subfolder'] else ''}{f['filename']}"
|
||||
print(f'{image} -> {path} ({time.time() - t0:.1f}s, seed {seed})', flush=True)
|
||||
return path
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
jobs = json.loads(sys.argv[1])
|
||||
for j in jobs:
|
||||
run(j['image'], j.get('prompt', STYLE), j['prefix'], j.get('seed'))
|
||||
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 877 KiB |