diff --git a/glb/.gitignore b/glb/.gitignore deleted file mode 100644 index f0378c7..0000000 --- a/glb/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.glb diff --git a/scad/wedge.scad b/scad/wedge.scad deleted file mode 100644 index b63c444..0000000 --- a/scad/wedge.scad +++ /dev/null @@ -1,255 +0,0 @@ -// -// colorpie.scad -// 2026-03-06 ChatGPT -// $Header$ -// -// Preview: -// openscad colorpie.scad -// -// STL export: -// openscad -o output.stl wedge.scad -// - -$fn = 90; - -// ------------------------------------------------------------ -// Global parameters -// ------------------------------------------------------------ - -slice_count = 10; -slice_angle = 360 / slice_count; // 36 degrees -explode_gap = 10; - -// Footprint -inner_r = 22; -outer_r = 82; -thickness = 6; - -// Crown profile across the wedge width -crown_height = 10; -table_frac = 0.42; // fraction of width occupied by table -shoulder_frac = 0.18; // fraction on each side used by bevel/shoulder -stations = 13; // more stations = more facet slices across width - - -// Labeling -label_size = 8; -label_depth = 1.0; -label_font = "Liberation Sans:style=Bold"; - -// Small geometry tolerance -eps = 0.01; - - -// ------------------------------------------------------------ -// Helper functions -// ------------------------------------------------------------ - -function deg(a) = a; -function lerp(a,b,t) = a + (b-a)*t; - -// u is normalized from 0 at left cut face to 1 at right cut face -// This defines the outside-elevation style profile: -// -// low edge -> bevel up -> flat table -> bevel down -> low edge -// -function crown_profile(u) = - let( - t0 = shoulder_frac, - t1 = 0.5 - table_frac/2, - t2 = 0.5 + table_frac/2, - t3 = 1.0 - shoulder_frac - ) - (u <= t0) ? lerp(0.00, 0.70, u / t0) : - (u <= t1) ? lerp(0.70, 1.00, (u - t0) / max(eps, t1 - t0)) : - (u <= t2) ? 1.00 : - (u <= t3) ? lerp(1.00, 0.70, (u - t2) / max(eps, t3 - t2)) : - lerp(0.70, 0.00, (u - t3) / max(eps, 1.0 - t3)); - -// Z value of top surface at station u -function top_z(u) = thickness + crown_height * crown_profile(u); - -// Angle at station i -function station_angle(i) = slice_angle * i / (stations - 1); - -// Polar point helpers -function p_inner(a,z) = [ inner_r * cos(a), inner_r * sin(a), z ]; -function p_outer(a,z) = [ outer_r * cos(a), outer_r * sin(a), z ]; - -// Indices for point grid -// per station: -// 0 = bottom inner -// 1 = bottom outer -// 2 = top inner -// 3 = top outer -function idx_bi(i) = 4*i + 0; -function idx_bo(i) = 4*i + 1; -function idx_ti(i) = 4*i + 2; -function idx_to(i) = 4*i + 3; - - -// ------------------------------------------------------------ -// Master wedge as a polyhedron -// Top shape traverses the wedge width -// ------------------------------------------------------------ - -module wedge_body() { - - pts = [ - for (i = [0:stations-1]) - let( - a = station_angle(i), - u = i / (stations - 1), - zt = top_z(u) - ) - each [ - p_inner(a, 0), // bottom inner - p_outer(a, 0), // bottom outer - p_inner(a, zt), // top inner - p_outer(a, zt) // top outer - ] - ]; - - faces = concat( - - // bottom, triangulated - [ - for (i = [0:stations-2]) each [ - [ idx_bi(i), idx_bi(i+1), idx_bo(i+1) ], - [ idx_bi(i), idx_bo(i+1), idx_bo(i) ] - ] - ], - - // top, triangulated - [ - for (i = [0:stations-2]) each [ - [ idx_ti(i), idx_to(i), idx_to(i+1) ], - [ idx_ti(i), idx_to(i+1), idx_ti(i+1) ] - ] - ], - - // inner radius face, triangulated - [ - for (i = [0:stations-2]) each [ - [ idx_bi(i), idx_ti(i), idx_ti(i+1) ], - [ idx_bi(i), idx_ti(i+1), idx_bi(i+1) ] - ] - ], - - // outer radius face, triangulated - [ - for (i = [0:stations-2]) each [ - [ idx_bo(i), idx_bo(i+1), idx_to(i+1) ], - [ idx_bo(i), idx_to(i+1), idx_to(i) ] - ] - ], - - // left cut face, triangulated - [ - [ idx_bi(0), idx_bo(0), idx_to(0) ], - [ idx_bi(0), idx_to(0), idx_ti(0) ] - ], - - // right cut face, triangulated - [ - [ idx_bi(stations-1), idx_ti(stations-1), idx_to(stations-1) ], - [ idx_bi(stations-1), idx_to(stations-1), idx_bo(stations-1) ] - ] - ); - - polyhedron(points = pts, faces = faces, convexity = 12); -} - - -// ------------------------------------------------------------ -// raised bottom label so readable and not engulged/hidden -// ------------------------------------------------------------ -module wedge_label_raise_OLD(label_txt="0") { - mid_r = (inner_r + outer_r) / 2; - mid_a = slice_angle / 2; - - x = mid_r * cos(mid_a); - y = mid_r * sin(mid_a); - - translate([x, y, 0]) - rotate([0, 0, mid_a - 90]) - linear_extrude(height = 1.2) - text(label_txt, - size = 18, - font = label_font, - halign = "center", - valign = "center"); -} - -// If the text is too close to the middle or too large, use this instead: - - -module wedge_label_raise_optional(label_txt="0") { - mid_r = inner_r + (outer_r - inner_r) * 0.63; - mid_a = slice_angle / 2; - - x = mid_r * cos(mid_a); - y = mid_r * sin(mid_a); - - translate([x, y, 0]) - rotate([0, 0, mid_a - 90]) - linear_extrude(height = 10.2) - text(label_txt, - size = 18, - font = label_font, - halign = "center", - valign = "center"); -} - -module wedge_label_raise(label_txt="0") { - mid_r = inner_r + (outer_r - inner_r) * 0.63; - mid_a = slice_angle / 2; - - x = mid_r * cos(mid_a); - y = mid_r * sin(mid_a); - - translate([x, y, -3.2]) - rotate([0, 0, mid_a - 90]) - linear_extrude(height = 3) - text(label_txt, - size = 16, - font = label_font, - halign = "center", - valign = "center"); -} -// ------------------------------------------------------------ -// One numbered wedge -// ------------------------------------------------------------ - -module wedge_unit(idx=0) { - wedge_body(); - wedge_label_raise(str(idx)); - //wedge_label_raise_optional(str(idx)); -} - - -// ------------------------------------------------------------ -// Assemble 10 wedges around the center -// Each wedge is exploded outward along its centerline -// ------------------------------------------------------------ - -module wheel_10() { - for (i = [0:slice_count-1]) { - a = i * slice_angle; - mid_a = a + slice_angle/2; - - dx = explode_gap * cos(mid_a); - dy = explode_gap * sin(mid_a); - - translate([dx, dy, 0]) - rotate([0,0,a]) - wedge_unit(i); - } -} - - -// ------------------------------------------------------------ -// Top level -// ------------------------------------------------------------ - -wheel_10(); diff --git a/scad/wedge.stl b/scad/wedge.stl deleted file mode 100644 index 355d94f..0000000 Binary files a/scad/wedge.stl and /dev/null differ diff --git a/scripts/create_glb_colorpie.py b/scripts/create_glb_colorpie.py deleted file mode 100644 index 61b85d2..0000000 --- a/scripts/create_glb_colorpie.py +++ /dev/null @@ -1,453 +0,0 @@ -#!/usr/bin/env python3 -# create_glb_colorpie.py — Blender headless STL -> GLB for colorpie -# -# Example: -# blender-bin-5.0.0 --background --python create_glb_colorpie.py -- \ -# wedge.stl wedge.glb \ -# --hexes FFFFFF,D9D9D9,BFBFBF,808080,606060,404040,202020,000000,FF6600,CC5500 -# -# Optional: -# --roughness 0.6 -# --metallic 0.0 -# -# Notes: -# - Expects one STL containing 10 disconnected wedge solids. -# - Separates loose parts after import. -# - Sorts parts by centroid angle around the origin. -# - Assigns colors 0..9 in that angular order. -# -# 2026-03-06 ChatGPT -# $Header$ - -import bpy -import bmesh -import sys -import os -import math -from mathutils import Vector - - -def delete_other_meshes(keep_objs): - keep_names = {o.name for o in keep_objs} - victims = [ - o for o in bpy.context.scene.objects - if o.type == 'MESH' and o.name not in keep_names - ] - - if victims: - bpy.ops.object.select_all(action='DESELECT') - for o in victims: - o.select_set(True) - bpy.context.view_layer.objects.active = victims[0] - bpy.ops.object.delete() - - log(f"Deleted {len(victims)} extra mesh objects; keeping {len(keep_objs)}") - -def part_size_score(obj): - """ - Score a part by bounding-box volume. - Good enough to distinguish the main wedge from tiny numeral residue. - """ - d = obj.dimensions - return d.x * d.y * d.z - - -def group_parts_by_angle(parts, tolerance_deg=10.0): - """ - Group parts whose centroid angles are close together. - For colorpie, each wedge and its tiny residue should land in the same group. - """ - parts_sorted = sort_parts_by_angle(parts) - groups = [] - - for obj in parts_sorted: - a = angle_deg_for_obj(obj) - placed = False - - for g in groups: - ga = g["angle"] - diff = abs(a - ga) - diff = min(diff, 360.0 - diff) - if diff <= tolerance_deg: - g["parts"].append(obj) - # keep a simple running average angle - g["angle"] = sum(angle_deg_for_obj(p) for p in g["parts"]) / len(g["parts"]) - placed = True - break - - if not placed: - groups.append({ - "angle": a, - "parts": [obj], - }) - - return groups - - -def pick_slot_pairs(parts, expected_groups=10, tolerance_deg=10.0): - groups = group_parts_by_angle(parts, tolerance_deg=tolerance_deg) - - log(f"Grouped {len(parts)} raw parts into {len(groups)} angle groups") - - slot_pairs = [] - - for gi, g in enumerate(groups): - g["parts"] = sorted(g["parts"], key=part_size_score, reverse=True) - log(f" group {gi:02d}: angle≈{g['angle']:7.3f} count={len(g['parts'])}") - for pj, obj in enumerate(g["parts"]): - dims = obj.dimensions - log( - f" part {pj}: name={obj.name!r} " - f"score={part_size_score(obj):10.3f} " - f"dims=({dims.x:8.3f},{dims.y:8.3f},{dims.z:8.3f})" - ) - - if len(g["parts"]) < 2: - die(f"Expected at least 2 parts in group {gi}, found {len(g['parts'])}") - - body = g["parts"][0] - text = g["parts"][1] - - slot_pairs.append({ - "angle": angle_deg_for_obj(body), - "body": body, - "text": text, - }) - - if len(slot_pairs) != expected_groups: - die(f"Expected {expected_groups} angle groups; found {len(slot_pairs)}") - - slot_pairs = sorted(slot_pairs, key=lambda s: s["angle"]) - return slot_pairs - -def pick_main_parts(parts, expected_groups=10, tolerance_deg=10.0): - groups = group_parts_by_angle(parts, tolerance_deg=tolerance_deg) - - log(f"Grouped {len(parts)} raw parts into {len(groups)} angle groups") - - for gi, g in enumerate(groups): - g["parts"] = sorted(g["parts"], key=part_size_score, reverse=True) - log(f" group {gi:02d}: angle≈{g['angle']:7.3f} count={len(g['parts'])}") - for pj, obj in enumerate(g["parts"]): - dims = obj.dimensions - log( - f" part {pj}: name={obj.name!r} " - f"score={part_size_score(obj):10.3f} " - f"dims=({dims.x:8.3f},{dims.y:8.3f},{dims.z:8.3f})" - ) - - if len(groups) != expected_groups: - die(f"Expected {expected_groups} angle groups; found {len(groups)}") - - main_parts = [g["parts"][0] for g in groups] - return sort_parts_by_angle(main_parts) - -def die(msg, rc=2): - print(f"ERROR: {msg}") - raise SystemExit(rc) - - -def log(msg): - print(msg) - - -def hex_to_srgb_rgba(hexstr): - """ - Accepts: - 'CC5500', '#CC5500', 'C50', '#C50', 'CC5500FF' - Returns: - (r, g, b, a) in sRGB 0..1 floats - """ - h = hexstr.strip() - if h.startswith("#"): - h = h[1:] - if len(h) == 3: - h = "".join([c * 2 for c in h]) - if len(h) == 6: - h = h + "FF" - if len(h) != 8: - die(f"Bad hex color '{hexstr}'. Use RRGGBB or #RRGGBB.") - r = int(h[0:2], 16) / 255.0 - g = int(h[2:4], 16) / 255.0 - b = int(h[4:6], 16) / 255.0 - a = int(h[6:8], 16) / 255.0 - return (r, g, b, a) - - -def srgb_chan_to_linear(c): - # IEC 61966-2-1 - return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 - - -def srgb_rgba_to_linear(rgba): - r, g, b, a = rgba - return ( - srgb_chan_to_linear(r), - srgb_chan_to_linear(g), - srgb_chan_to_linear(b), - a, - ) - - -def ensure_material(name, rgba, roughness=0.6, metallic=0.0): - mat = bpy.data.materials.get(name) - if not mat: - mat = bpy.data.materials.new(name=name) - mat.use_nodes = True - - bsdf = mat.node_tree.nodes.get("Principled BSDF") - if bsdf: - bsdf.inputs["Base Color"].default_value = rgba - bsdf.inputs["Metallic"].default_value = metallic - bsdf.inputs["Roughness"].default_value = roughness - - return mat - - -def clear_scene(): - bpy.ops.wm.read_factory_settings(use_empty=True) - - -def parse_args(argv): - pos = [] - hexes = None - roughness = 0.6 - metallic = 0.0 - - i = 0 - while i < len(argv): - a = argv[i] - - if a == "--hexes": - if i + 1 >= len(argv): - die("Missing value after --hexes") - hexes = [x.strip() for x in argv[i + 1].split(",") if x.strip()] - i += 2 - continue - - if a == "--roughness": - if i + 1 >= len(argv): - die("Missing value after --roughness") - roughness = float(argv[i + 1]) - i += 2 - continue - - if a == "--metallic": - if i + 1 >= len(argv): - die("Missing value after --metallic") - metallic = float(argv[i + 1]) - i += 2 - continue - - pos.append(a) - i += 1 - - if len(pos) == 1: - inp = pos[0] - base, _ = os.path.splitext(inp) - outp = base + ".glb" - elif len(pos) >= 2: - inp, outp = pos[0], pos[1] - else: - die( - "USAGE: blender --background --python create_glb_colorpie.py -- " - "input.stl [output.glb] " - "[--hexes h1,h2,...,h10] [--roughness 0.6] [--metallic 0.0]" - ) - - if not os.path.exists(inp): - die(f"Input not found: {inp}") - - if hexes is None: - hexes = [ - "FFFFFF", - "D9D9D9", - "BFBFBF", - "808080", - "606060", - "404040", - "202020", - "000000", - "FF6600", - "CC5500", - ] - - if len(hexes) != 10: - die(f"--hexes must contain exactly 10 entries; got {len(hexes)}") - - return inp, outp, hexes, roughness, metallic - - -def import_stl(inp): - res = bpy.ops.wm.stl_import(filepath=inp) - if 'FINISHED' not in res: - die(f"STL import failed for: {inp}") - - objs = [o for o in bpy.context.scene.objects if o.type == 'MESH'] - if not objs: - die("No mesh objects after import") - return objs - - -def select_only(obj): - bpy.ops.object.select_all(action='DESELECT') - obj.select_set(True) - bpy.context.view_layer.objects.active = obj - - -def separate_loose_parts_from_single_object(obj): - """ - Blender STL import often creates one mesh object containing - many disconnected shells. Separate them into individual objects. - """ - select_only(obj) - - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.mesh.select_all(action='SELECT') - bpy.ops.mesh.separate(type='LOOSE') - bpy.ops.object.mode_set(mode='OBJECT') - - parts = [o for o in bpy.context.scene.objects if o.type == 'MESH'] - if not parts: - die("No mesh objects after Separate by Loose Parts") - - return parts - - -def world_bbox_center(obj): - min_v = Vector((1e30, 1e30, 1e30)) - max_v = Vector((-1e30, -1e30, -1e30)) - - for corner in obj.bound_box: - v = obj.matrix_world @ Vector(corner) - min_v.x = min(min_v.x, v.x) - min_v.y = min(min_v.y, v.y) - min_v.z = min(min_v.z, v.z) - max_v.x = max(max_v.x, v.x) - max_v.y = max(max_v.y, v.y) - max_v.z = max(max_v.z, v.z) - - return (min_v + max_v) * 0.5 - - -def angle_deg_for_obj(obj): - c = world_bbox_center(obj) - a = math.degrees(math.atan2(c.y, c.x)) - if a < 0: - a += 360.0 - return a - - -def sort_parts_by_angle(parts): - return sorted(parts, key=angle_deg_for_obj) - - -def assign_material(obj, mat): - if obj.type != 'MESH': - return - if not obj.data.materials: - obj.data.materials.append(mat) - else: - obj.data.materials[0] = mat - - -def center_all_meshes(objs): - min_v = Vector((1e30, 1e30, 1e30)) - max_v = Vector((-1e30, -1e30, -1e30)) - - for o in objs: - for corner in o.bound_box: - v = o.matrix_world @ Vector(corner) - min_v.x = min(min_v.x, v.x) - min_v.y = min(min_v.y, v.y) - min_v.z = min(min_v.z, v.z) - max_v.x = max(max_v.x, v.x) - max_v.y = max(max_v.y, v.y) - max_v.z = max(max_v.z, v.z) - - center = (min_v + max_v) * 0.5 - - for o in objs: - o.location -= center - - -def export_glb(outp, objs): - bpy.ops.object.select_all(action='DESELECT') - for o in objs: - o.select_set(True) - bpy.context.view_layer.objects.active = objs[0] - - res = bpy.ops.export_scene.gltf( - filepath=outp, - export_format='GLB', - export_apply=True, - use_selection=True, - ) - if 'FINISHED' not in res: - die(f"GLB export failed: {outp}") - -def ensure_text_material(name="PIE_TEXT_BLACK"): - black = srgb_rgba_to_linear((0.0, 0.0, 0.0, 1.0)) - return ensure_material(name, black, roughness=0.7, metallic=0.0) - -def main(): - log("create_glb_colorpie.py starting") - log(f"Script path: {os.path.abspath(__file__)}") - log(f"Python executable: {sys.executable}") - log(f"argv: {sys.argv}") - - argv = sys.argv - argv = argv[argv.index("--") + 1:] if "--" in argv else [] - - inp, outp, hexes, roughness, metallic = parse_args(argv) - - clear_scene() - - imported = import_stl(inp) - - if len(imported) == 1: - parts = separate_loose_parts_from_single_object(imported[0]) - else: - parts = imported - - slot_pairs = pick_slot_pairs(parts, expected_groups=10, tolerance_deg=10.0) - text_mat = ensure_text_material() - export_objs = [] - - for i, slot in enumerate(slot_pairs): - body = slot["body"] - text = slot["text"] - - hexval = hexes[i] - srgb = hex_to_srgb_rgba(hexval) - linear = srgb_rgba_to_linear(srgb) - - body_mat_name = f"PIE_{i}_{hexval.strip().lstrip('#').upper()}" - body_mat = ensure_material( - body_mat_name, - linear, - roughness=roughness, - metallic=metallic, - ) - - assign_material(body, body_mat) - assign_material(text, text_mat) - - body.name = f"wedge_{i}" - text.name = f"label_{i}" - - export_objs.extend([body, text]) - - log( - f"Assigned wedge_{i}: angle={angle_deg_for_obj(body):7.3f} " - f"hex=#{hexval.strip().lstrip('#').upper()} " - f"text={text.name}" - ) - - center_all_meshes(export_objs) - export_glb(outp, export_objs) - log(f"Wrote: {outp}") - - -if __name__ == "__main__": - main() diff --git a/scripts/viewglb b/scripts/viewglb deleted file mode 100755 index 5c8f727..0000000 --- a/scripts/viewglb +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -# 20260305 ChatGPT -# $Header$ -# -# Copy/paste examples: -# -# ./viewglb model.glb - -FILE="$1" - -if [ -z "$FILE" ]; then - echo "Usage: $0 /path/to/file.glb" >&2 - exit 1 -fi - -if [ ! -f "$FILE" ]; then - echo "ERROR: file not found: $FILE" >&2 - exit 1 -fi - -DIR=$(dirname "$FILE") -BASE=$(basename "$FILE") -VIEWER="__glbviewer__.html" -LOG="/tmp/viewglb_httpserver.log" - -urlencode() { - python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$1" -} - -find_free_port() { - python3 - <<'PY' -import socket -s = socket.socket() -s.bind(('127.0.0.1', 0)) -print(s.getsockname()[1]) -s.close() -PY -} - -PORT=$(find_free_port) -BASE_URL=$(urlencode "$BASE") - -cd "$DIR" || exit 1 - -cat >"$VIEWER" < - - - -$BASE - - - -
$BASE
- - - - - - - - -EOF - -python3 -m http.server "$PORT" >"$LOG" 2>&1 & -SERVER=$! - -# wait up to ~5 seconds for server readiness -READY=0 -for _i in 1 2 3 4 5 6 7 8 9 10; do - if ! kill -0 "$SERVER" 2>/dev/null; then - echo "ERROR: http.server exited early." >&2 - echo "See log: $LOG" >&2 - rm -f "$VIEWER" - exit 1 - fi - - if curl -s "http://127.0.0.1:$PORT/$VIEWER" >/dev/null 2>&1; then - READY=1 - break - fi - - sleep 0.5 -done - -if [ "$READY" -ne 1 ]; then - echo "ERROR: server did not become ready on port $PORT" >&2 - echo "See log: $LOG" >&2 - kill "$SERVER" 2>/dev/null - rm -f "$VIEWER" - exit 1 -fi - -firefox-bin "http://127.0.0.1:$PORT/$VIEWER" >/dev/null 2>&1 & - -read -p "Press Enter to stop server..." - -kill "$SERVER" 2>/dev/null -rm -f "$VIEWER"