#!/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/.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__)