Compare commits

..

No commits in common. "876ca92f077350bba4fc26019b654ac8c8c004a4" and "beac39b2bbd6789d9ea7d5e4acd935eb900973d1" have entirely different histories.

5 changed files with 0 additions and 827 deletions

1
glb/.gitignore vendored
View file

@ -1 +0,0 @@
*.glb

View file

@ -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();

Binary file not shown.

View file

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

View file

@ -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" <<EOF
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>$BASE</title>
</head>
<body style="margin:0;background:#222;overflow:hidden;">
<div style="
position:absolute;
bottom:10px;
left:50%;
transform:translateX(-50%);
z-index:10;
padding:6px 12px;
background:rgba(0,0,0,0.65);
color:#fff;
font-family:monospace;
font-size:14px;
border-radius:4px;
max-width:80vw;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
">$BASE</div>
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
<model-viewer src="$BASE_URL"
camera-controls
auto-rotate
style="width:100vw;height:100vh;">
</model-viewer>
</body>
</html>
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"