This commit is contained in:
Gabrieal Jimmy
2026-10-01 15:15:20 -05:00
commit 97a27f9a42
327 changed files with 7075 additions and 0 deletions

243
sketch.py Executable file
View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Authoring helper for part drafts. Build a part from primitive forms, shade it with
the shared light model, add hand details, and save the text grid to parts_src/.
The saved .txt grid is the source of truth afterwards; hand-edit it freely.
from sketch import Canvas, stamp, save
c = Canvas(40, 34)
c.capsule(26, 26, 26, 31, 2.4, r="F") # far leg (F = main ramp, in shadow)
c.ellipse(24, 24, 11, 8, r="M") # body (M = main ramp)
c.ellipse(15, 13, 12, 11, r="K") # big head in front (K = main ramp, front layer)
rows = c.shade(soft=["KM"], dark=0.84, light=(0.06, 0.50), side=(0.14, 0.80), min_light=4)
rows = stamp(rows, ["pp", "pp"], 10, 12) # hand details (face) on top
save("body/base_x", rows, marks=[("P", 22, 33), ("^", 15, 5), ("B", 28, 17), ("T", 33, 24), ("A", 24, 25)])
Region letters (see REGIONS): one letter per form layer. Same letter = one merged form.
main ramp: M N K (normal) F (far side, in shadow) U (extra lit)
accent ramp: C D (normal) G (far side) V (extra lit)
type ramp: Y Q (normal) J (far side) X (extra lit)
Literal palette chars (1 2 3 a b c x y z o w p) may also be used as a layer: painted flat.
Shading: every pixel of a form gets its position along the light chord (march toward the
light at the top-left and away from it until leaving the form). The far end of the chord
is core shadow, so the shadow is a crescent that thickens where the form is deep (no
outline-following band). The highlight is an off-outline blob on the lit side. Each form
is shaded with its full silhouette, so overlapping front forms do not distort it.
Where a front form overlaps a form behind it, a 1px line is drawn on the front form,
except for pairs listed in `soft`.
"""
import math
from pathlib import Path
REGIONS = {
"M": ("main", 0), "N": ("main", 0), "K": ("main", 0), "F": ("main", -1), "U": ("main", 1),
"C": ("accent", 0), "D": ("accent", 0), "G": ("accent", -1), "V": ("accent", 1),
"Y": ("type", 0), "Q": ("type", 0), "J": ("type", -1), "X": ("type", 1),
}
RAMPS = {"main": "123", "accent": "abc", "type": "xyz"}
LIGHT_DIR = (-0.55, -0.83) # toward the light, screen coords (y down)
DIRS4 = ((1, 0), (-1, 0), (0, 1), (0, -1))
def _march(cset, x, y, dx, dy):
n, fx, fy = 0, x + 0.5, y + 0.5
while True:
fx += dx * 0.5
fy += dy * 0.5
if (int(math.floor(fx)), int(math.floor(fy))) not in cset:
return n * 0.5
n += 1
def shade_form(pixels, ramp, bias=0, dark=0.76, light=(0.10, 0.42), side=(0.22, 0.72), min_light=5.0):
"""{(x,y): tone char} for one form."""
lx, ly = LIGHT_DIR
m = math.hypot(lx, ly)
lx, ly = lx / m, ly / m
px_, py_ = -ly, lx
out = {}
for x, y in pixels:
up, dn = _march(pixels, x, y, lx, ly), _march(pixels, x, y, -lx, -ly)
a, b = _march(pixels, x, y, px_, py_), _march(pixels, x, y, -px_, -py_)
chord, t = up + dn + 1, (up + 0.5) / (up + dn + 1)
q = (a + 0.5) / (a + b + 1)
d_th = dark - 0.30 * (bias < 0) + 0.10 * (bias > 0)
if t >= d_th:
tone = 2
elif (bias >= 0 and chord >= min_light and light[0] <= t <= light[1] + 0.12 * (bias > 0)
and side[0] <= q <= side[1]):
tone = 0
else:
tone = 1
out[(x, y)] = RAMPS[ramp][tone]
return out
def add_outline(rows, ch="o"):
"""1px outline (4-neighbour) around every opaque pixel."""
h, w = len(rows), len(rows[0])
g = [list(r) for r in rows]
add = []
for y in range(h):
for x in range(w):
if g[y][x] == "." and any(0 <= x + dx < w and 0 <= y + dy < h and g[y + dy][x + dx] != "."
for dx, dy in DIRS4):
add.append((x, y))
for x, y in add:
g[y][x] = ch
return ["".join(r) for r in g]
def stamp(rows, st, x0, y0):
"""Paint text rows onto rows at x0,y0. '_' is skipped (see-through); '.' erases."""
g = [list(r) for r in rows]
for j, s in enumerate(st):
for i, ch in enumerate(s):
if ch != "_" and 0 <= y0 + j < len(g) and 0 <= x0 + i < len(g[0]):
g[y0 + j][x0 + i] = ch
return ["".join(r) for r in g]
def replace(rows, pairs, region=None):
"""Swap characters, e.g. replace(rows, {'o': '.'}, region=(x0, y0, x1, y1)) (inclusive)."""
g = [list(r) for r in rows]
for y, r in enumerate(g):
for x, ch in enumerate(r):
if region and not (region[0] <= x <= region[2] and region[1] <= y <= region[3]):
continue
if ch in pairs:
g[y][x] = pairs[ch]
return ["".join(r) for r in g]
def flip(rows):
return [r[::-1] for r in rows]
def save(key, rows, marks=()):
"""Write parts_src/<key>.txt, placing marker chars at (x, y)."""
g = [list(r) for r in rows]
widths = {len(r) for r in g}
if len(widths) != 1:
raise ValueError(f"{key}: ragged rows {sorted(widths)}")
for ch, x, y in marks:
g[y][x] = ch
path = Path("parts_src") / f"{key}.txt"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join("".join(r) for r in g) + "\n")
return ["".join(r) for r in g]
def load(key):
return [r for r in (Path("parts_src") / f"{key}.txt").read_text().splitlines() if r and not r.startswith("#")]
class Canvas:
"""Collects forms as ordered layers; shade() turns them into palette rows."""
def __init__(self, w, h):
self.w, self.h = w, h
self.layers = [] # [(char, set of pixels)], back to front
def _add(self, px, r):
px = {(x, y) for x, y in px if 0 <= x < self.w and 0 <= y < self.h}
if self.layers and self.layers[-1][0] == r:
self.layers[-1][1].update(px)
return
for ch, s in self.layers: # same letter again = same form, keep its depth
if ch == r and r in REGIONS:
s.update(px)
return
self.layers.append((r, px))
def ellipse(self, cx, cy, rx, ry, r="M", angle=0.0, clip=None):
"""angle in degrees, positive rotates clockwise on screen."""
ca, sa = math.cos(math.radians(angle)), math.sin(math.radians(angle))
rr = max(rx, ry)
px = set()
for y in range(int(cy - rr) - 1, int(cy + rr) + 2):
for x in range(int(cx - rr) - 1, int(cx + rr) + 2):
ux, uy = x + 0.5 - cx, y + 0.5 - cy
lx, ly = ux * ca + uy * sa, -ux * sa + uy * ca
if (lx / rx) ** 2 + (ly / ry) ** 2 <= 1.0 and (clip is None or clip(x, y)):
px.add((x, y))
self._add(px, r)
def capsule(self, x0, y0, x1, y1, r0, r="M", r1=None):
"""Tapered limb from (x0,y0) radius r0 to (x1,y1) radius r1 (default r0)."""
r1 = r0 if r1 is None else r1
vx, vy = x1 - x0, y1 - y0
ln2 = vx * vx + vy * vy or 1e-9
px = set()
big = max(r0, r1)
for y in range(int(min(y0, y1) - big) - 1, int(max(y0, y1) + big) + 2):
for x in range(int(min(x0, x1) - big) - 1, int(max(x0, x1) + big) + 2):
cx, cy = x + 0.5, y + 0.5
t = max(0.0, min(1.0, ((cx - x0) * vx + (cy - y0) * vy) / ln2))
rr = r0 + (r1 - r0) * t
if math.hypot(cx - (x0 + vx * t), cy - (y0 + vy * t)) <= rr:
px.add((x, y))
self._add(px, r)
def poly(self, pts, r="M"):
px = set()
ys = [p[1] for p in pts]
for y in range(int(min(ys)) - 1, int(max(ys)) + 2):
yc = y + 0.5
xs = []
for (ax, ay), (bx, by) in zip(pts, pts[1:] + pts[:1]):
if (ay <= yc < by) or (by <= yc < ay):
xs.append(ax + (yc - ay) * (bx - ax) / (by - ay))
xs.sort()
for a, b in zip(xs[::2], xs[1::2]):
for x in range(int(math.floor(a)), int(math.ceil(b)) + 1):
if a <= x + 0.5 <= b:
px.add((x, y))
self._add(px, r)
def mask(self, rows, x0=0, y0=0):
"""Add forms from a hand-drawn letter mask (each letter becomes/extends its layer)."""
by = {}
for j, row in enumerate(rows):
for i, ch in enumerate(row):
if ch not in "._ ":
by.setdefault(ch, set()).add((x0 + i, y0 + j))
for ch, px in by.items():
self._add(px, ch)
def erase(self, pts):
for _, s in self.layers:
s.difference_update(pts)
def shade(self, soft=(), outline=True, seps=True, **kw):
out = [["."] * self.w for _ in range(self.h)]
owner = [[-1] * self.w for _ in range(self.h)]
softset = {frozenset(s) for s in soft}
for k, (ch, px) in enumerate(self.layers):
if ch in REGIONS:
ramp, bias = REGIONS[ch]
tones = shade_form(px, ramp, bias, **kw)
else:
tones = {p: ch for p in px}
line = set()
if seps and ch in REGIONS:
for x, y in px:
for dx, dy in DIRS4:
nx, ny = x + dx, y + dy
if (nx, ny) in px or not (0 <= nx < self.w and 0 <= ny < self.h):
continue
o = owner[ny][nx]
if o >= 0 and out[ny][nx] != "." and self.layers[o][0] in REGIONS \
and frozenset((ch, self.layers[o][0])) not in softset and self.layers[o][0] != ch:
line.add((x, y))
break
for (x, y), t in tones.items():
out[y][x] = "o" if (x, y) in line else t
owner[y][x] = k
rows = ["".join(r) for r in out]
return add_outline(rows) if outline else rows
if __name__ == "__main__":
print(__doc__)