From 4e07d1da2d9d889dd0975878ef66ad010a86a8b4 Mon Sep 17 00:00:00 2001 From: John Poole Date: Fri, 6 Mar 2026 11:02:41 -0800 Subject: [PATCH] cleanup --- glb/.gitignore | 1 + scripts/create_glb_colorpie.py | 417 +++++++++++++++++++++++++++++++++ scripts/viewglb2 | 118 ++++++++++ 3 files changed, 536 insertions(+) create mode 100644 glb/.gitignore create mode 100644 scripts/create_glb_colorpie.py create mode 100755 scripts/viewglb2 diff --git a/glb/.gitignore b/glb/.gitignore new file mode 100644 index 0000000..f0378c7 --- /dev/null +++ b/glb/.gitignore @@ -0,0 +1 @@ +*.glb diff --git a/scripts/create_glb_colorpie.py b/scripts/create_glb_colorpie.py new file mode 100644 index 0000000..9ad0428 --- /dev/null +++ b/scripts/create_glb_colorpie.py @@ -0,0 +1,417 @@ +#!/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_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 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 + + # if len(parts) != 10: + # log(f"Found {len(parts)} mesh parts after separation:") + # for j, obj in enumerate(sort_parts_by_angle(parts)): + # c = world_bbox_center(obj) + # dims = obj.dimensions + # log( + # f" part {j:02d}: " + # f"name={obj.name!r} " + # f"angle={angle_deg_for_obj(obj):7.3f} " + # f"center=({c.x:8.3f},{c.y:8.3f},{c.z:8.3f}) " + # f"dims=({dims.x:8.3f},{dims.y:8.3f},{dims.z:8.3f})" + # ) + # die(f"Expected 10 wedge parts after separation; found {len(parts)}") + + # parts = sort_parts_by_angle(parts) + # the following line replaces all of the above rem'd + parts = pick_main_parts(parts, expected_groups=10, tolerance_deg=10.0) + delete_other_meshes(parts) + + for i, obj in enumerate(parts): + hexval = hexes[i] + srgb = hex_to_srgb_rgba(hexval) + linear = srgb_rgba_to_linear(srgb) + mat_name = f"PIE_{i}_{hexval.strip().lstrip('#').upper()}" + mat = ensure_material( + mat_name, + linear, + roughness=roughness, + metallic=metallic, + ) + assign_material(obj, mat) + obj.name = f"wedge_{i}" + log( + f"Assigned wedge_{i}: angle={angle_deg_for_obj(obj):7.3f} " + f"hex=#{hexval.strip().lstrip('#').upper()}" + ) + + center_all_meshes(parts) + export_glb(outp, parts) + log(f"Wrote: {outp}") + + +if __name__ == "__main__": + main() diff --git a/scripts/viewglb2 b/scripts/viewglb2 new file mode 100755 index 0000000..72ef8f0 --- /dev/null +++ b/scripts/viewglb2 @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# 20260305 ChatGPT +# $Header$ +# +# Copy/paste examples: +# chmod 755 viewglb +# ./viewglb '/home/jlpoole/work/Voron/test2/Voron-2/STLs/Z_Drive/[a]_belt_tensioner_a_x2.stl.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"