park_kit/tools/propkit.py
type-two 153e26c329 park_kit v0.1 — 27 textured park props + 18 MODELBEAST surfaces + manifest
propkit.py: textured-GLB writer (TEXCOORD_0, shared external textures,
MIRRORED_REPEAT). prop_author.py: parametric generators — ramps, rails,
furniture, trees, dressing — each with local-space grind metadata and
skatemakerpro element/collider hints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:47:34 +10:00

336 lines
15 KiB
Python

#!/usr/bin/env python3
"""propkit.py — textured-GLB authoring core for the park object library.
Upgrade over the character_kit writers: TEXCOORD_0 on every primitive and real
PBR textures. Images are EXTERNAL URIs (../textures/*.png) so every prop shares
one texture set — GLBs stay geometry-sized and the browser caches each surface
once. Keep props/ and textures/ folders side by side (bookquoy + skatemakerpro
both vendor the pair together).
Sampler uses MIRRORED_REPEAT: flux textures are tileable-*intent*, mirroring
kills the seam without any image post-processing.
Conventions: metres, Y up, prop origin at ground centre. UVs are WORLD scale —
`uv` on a Mat is metres-per-tile, so texture density matches across every prop.
"""
import json, math, os, struct
# ---------------------------------------------------------------- glb buffer
class Buf:
def __init__(self): self.b = bytearray()
def add(self, fmt, vals):
off = len(self.b)
for v in vals:
if isinstance(v, (list, tuple)): self.b += struct.pack("<" + fmt * len(v), *v)
else: self.b += struct.pack("<" + fmt, v)
while len(self.b) % 4: self.b += b"\0"
return off
def write_glb(path, gjson, bin_):
j = json.dumps(gjson, separators=(",", ":")).encode()
while len(j) % 4: j += b" "
while len(bin_) % 4: bin_ += b"\0"
length = 12 + 8 + len(j) + 8 + len(bin_)
with open(path, "wb") as f:
f.write(struct.pack("<III", 0x46546C67, 2, length))
f.write(struct.pack("<II", len(j), 0x4E4F534A)); f.write(j)
f.write(struct.pack("<II", len(bin_), 0x004E4942)); f.write(bin_)
# ---------------------------------------------------------------- materials
class Mat:
"""tex: basename in textures/ (no ext) or None for flat colour."""
def __init__(self, name, tex=None, tint=(1, 1, 1, 1), rough=0.9, metal=0.0,
uv=1.0, double=False):
self.name, self.tex, self.tint = name, tex, tuple(tint)
self.rough, self.metal, self.uv, self.double = rough, metal, uv, double
def key(self): return self.name
# ---------------------------------------------------------------- part math
def _p(P=None, N=None, UV=None, I=None):
return {"P": P or [], "N": N or [], "UV": UV or [], "I": I or []}
def merge(*parts):
out = _p()
for pt in parts:
base = len(out["P"])
out["P"] += pt["P"]; out["N"] += pt["N"]; out["UV"] += pt["UV"]
out["I"] += [base + i for i in pt["I"]]
return out
def translate(pt, dx, dy, dz):
return _p([(x + dx, y + dy, z + dz) for x, y, z in pt["P"]],
list(pt["N"]), list(pt["UV"]), list(pt["I"]))
def rotate_y(pt, ang):
c, s = math.cos(ang), math.sin(ang)
rp = [(x * c + z * s, y, -x * s + z * c) for x, y, z in pt["P"]]
rn = [(x * c + z * s, y, -x * s + z * c) for x, y, z in pt["N"]]
return _p(rp, rn, list(pt["UV"]), list(pt["I"]))
def scale(pt, sx, sy, sz):
rp = [(x * sx, y * sy, z * sz) for x, y, z in pt["P"]]
rn = []
for x, y, z in pt["N"]: # inverse-transpose for normals
nx, ny, nz = x / sx, y / sy, z / sz
l = math.sqrt(nx * nx + ny * ny + nz * nz) or 1
rn.append((nx / l, ny / l, nz / l))
return _p(rp, rn, list(pt["UV"]), list(pt["I"]))
def compute_normals(P, I):
N = [[0.0, 0.0, 0.0] for _ in P]
for i in range(0, len(I), 3):
a, b, c = I[i], I[i + 1], I[i + 2]
ux, uy, uz = (P[b][k] - P[a][k] for k in range(3))
vx, vy, vz = (P[c][k] - P[a][k] for k in range(3))
n = (uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)
for j in (a, b, c):
for k in range(3): N[j][k] += n[k]
out = []
for n in N:
l = math.sqrt(sum(c * c for c in n)) or 1
out.append((n[0] / l, n[1] / l, n[2] / l))
return out
# ---------------------------------------------------------------- primitives
def box(cx, cy, cz, sx, sy, sz):
"""Axis-aligned box, per-face planar UVs in world metres."""
x0, x1 = cx - sx / 2, cx + sx / 2
y0, y1 = cy - sy / 2, cy + sy / 2
z0, z1 = cz - sz / 2, cz + sz / 2
F = [ # (normal, corners ccw, uv picks: fn(corner)->uv)
((0, 0, 1), [(x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1)], lambda p: (p[0], p[1])),
((0, 0, -1), [(x1, y0, z0), (x0, y0, z0), (x0, y1, z0), (x1, y1, z0)], lambda p: (-p[0], p[1])),
((1, 0, 0), [(x1, y0, z1), (x1, y0, z0), (x1, y1, z0), (x1, y1, z1)], lambda p: (-p[2], p[1])),
((-1, 0, 0), [(x0, y0, z0), (x0, y0, z1), (x0, y1, z1), (x0, y1, z0)], lambda p: (p[2], p[1])),
((0, 1, 0), [(x0, y1, z1), (x1, y1, z1), (x1, y1, z0), (x0, y1, z0)], lambda p: (p[0], -p[2])),
((0, -1, 0), [(x0, y0, z0), (x1, y0, z0), (x1, y0, z1), (x0, y0, z1)], lambda p: (p[0], p[2])),
]
P, N, UV, I = [], [], [], []
for n, corners, uvf in F:
b = len(P)
P += corners; N += [n] * 4; UV += [uvf(p) for p in corners]
I += [b, b + 1, b + 2, b, b + 2, b + 3]
return _p(P, N, UV, I)
def sheet(rows, flip=False, smooth=True):
"""Grid surface from rows of (x,y,z). UVs by accumulated arc length."""
nr, nc = len(rows), len(rows[0])
P = [p for row in rows for p in row]
# arc-length UVs
us = [[0.0] * nc for _ in range(nr)]
vs = [[0.0] * nc for _ in range(nr)]
for r in range(nr):
for c in range(1, nc):
d = math.dist(rows[r][c], rows[r][c - 1])
us[r][c] = us[r][c - 1] + d
for c in range(nc):
for r in range(1, nr):
d = math.dist(rows[r][c], rows[r - 1][c])
vs[r][c] = vs[r - 1][c] + d
UV = [(us[r][c], vs[r][c]) for r in range(nr) for c in range(nc)]
I = []
for r in range(nr - 1):
for c in range(nc - 1):
a = r * nc + c; b = a + 1; d = a + nc; e = d + 1
quad = [a, b, e, a, e, d] if not flip else [a, e, b, a, d, e]
I += quad
N = compute_normals(P, I)
return _p(P, N, UV, I)
def lathe(profile, seg=16, jitter=None, cap_top=True, cap_bot=False):
"""Revolve profile [(radius, y), ...] around Y. jitter(iu, iv, r) -> r."""
rows = []
for iu in range(seg + 1):
th = 2 * math.pi * iu / seg
row = []
for iv, (r, y) in enumerate(profile):
rr = jitter(iu % seg, iv, r) if jitter and r > 0 else r
row.append((rr * math.cos(th), y, rr * math.sin(th)))
rows.append(row)
# weld the seam ring so jitter matches
rows[-1] = rows[0]
pt = sheet(rows, flip=True)
# match tube(): u wraps the girth, v climbs the profile (bark ridges stay
# vertical — the transposed mapping chevroned the fig trunk)
pt["UV"] = [(v, u) for (u, v) in pt["UV"]]
caps = []
if cap_top and profile[-1][0] > 0.001:
r, y = profile[-1]
caps.append(_disc(r, y, seg, up=True))
if cap_bot and profile[0][0] > 0.001:
r, y = profile[0]
caps.append(_disc(r, y, seg, up=False))
return merge(pt, *caps)
def _disc(r, y, seg, up=True):
P = [(0, y, 0)] + [(r * math.cos(2 * math.pi * i / seg), y,
r * math.sin(2 * math.pi * i / seg)) for i in range(seg)]
N = [(0, 1 if up else -1, 0)] * (seg + 1)
UV = [(p[0], p[2]) for p in P]
I = []
for i in range(seg):
j = 1 + i; k = 1 + (i + 1) % seg
I += [0, k, j] if up else [0, j, k]
return _p(P, N, UV, I)
def tube(pts, r, seg=10, caps=True):
"""Round tube along 3D polyline. Cylindrical UVs: u=around (girth m), v=along."""
def frame(d):
up = (0, 1, 0) if abs(d[1]) < 0.95 else (1, 0, 0)
sx = _cross(d, up); sx = _norm(sx)
sy = _cross(sx, d)
return sx, sy
P, N, UV, I = [], [], [], []
ring_n = seg + 1
v = 0.0
for i, p in enumerate(pts):
if i == 0: d = _norm(_sub(pts[1], pts[0]))
elif i == len(pts) - 1: d = _norm(_sub(pts[-1], pts[-2]))
else: d = _norm(_add(_norm(_sub(pts[i], pts[i - 1])), _norm(_sub(pts[i + 1], pts[i]))))
sx, sy = frame(d)
if i > 0: v += math.dist(pts[i], pts[i - 1])
for s in range(ring_n):
th = 2 * math.pi * s / seg
n = _add(_mul(sx, math.cos(th)), _mul(sy, math.sin(th)))
P.append(_add(p, _mul(n, r))); N.append(tuple(n))
UV.append((th * r, v))
for i in range(len(pts) - 1):
for s in range(seg):
a = i * ring_n + s; b = a + 1; c = a + ring_n; d2 = c + 1
I += [a, c, b, b, c, d2]
part = _p(P, N, UV, I)
if caps:
for idx, up in ((0, False), (len(pts) - 1, True)):
centre = pts[idx]
if idx == 0: d = _norm(_sub(pts[1], pts[0]))
else: d = _norm(_sub(pts[-1], pts[-2]))
nrm = d if up else _mul(d, -1)
b = len(part["P"])
ring = [part["P"][idx * ring_n + s] for s in range(seg)]
part["P"] += [centre] + ring
part["N"] += [tuple(nrm)] * (seg + 1)
part["UV"] += [(0, 0)] + [(math.cos(2 * math.pi * s / seg) * r,
math.sin(2 * math.pi * s / seg) * r) for s in range(seg)]
for s in range(seg):
j = b + 1 + s; k = b + 1 + (s + 1) % seg
part["I"] += [b, j, k] if up else [b, k, j]
return part
def _sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
def _add(a, b): return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
def _mul(a, s): return (a[0] * s, a[1] * s, a[2] * s)
def _cross(a, b): return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0])
def _norm(a):
l = math.sqrt(sum(c * c for c in a)) or 1
return (a[0] / l, a[1] / l, a[2] / l)
def blob(rx, ry, rz, seg=12, rings=8, amp=0.16, freq=5.0, seed=0.0):
"""Noisy ellipsoid (tree canopy). Deterministic sin-hash displacement."""
prof = []
for iv in range(rings + 1):
ph = math.pi * iv / rings # 0..pi pole to pole
prof.append((math.sin(ph), -math.cos(ph))) # (r, y) unit sphere
def jit(iu, iv, r):
n = (math.sin(iu * freq * 0.71 + iv * 1.7 + seed) +
math.sin(iu * 1.3 + iv * freq * 0.53 + seed * 2.1) * 0.6)
return r * (1 + amp * n / 1.6)
pt = lathe([(r, y) for r, y in prof], seg=seg, jitter=jit,
cap_top=False, cap_bot=False)
return scale(pt, rx, ry, rz)
# ---------------------------------------------------------------- prop builder
GLTF_MIRRORED = 33648
class Prop:
def __init__(self, pid, category, desc):
self.id, self.category, self.desc = pid, category, desc
self.parts = {} # mat.key -> (mat, merged part)
self.grinds = []
self.collider = None
self.element = None
def add(self, mat, *parts):
cur = self.parts.get(mat.key())
p = merge(*parts)
self.parts[mat.key()] = (mat, merge(cur[1], p) if cur else p)
return self
def grind(self, ax, az, bx, bz, ya, yb, kind="rail"):
"""Grindable edge in PROP-LOCAL metres (pre-rotation). Editor transforms."""
self.grinds.append({"a": [ax, az], "b": [bx, bz], "ya": ya, "yb": yb, "kind": kind})
return self
def save(self, props_dir, textures_dir):
buf = Buf(); accessors = []; views = []; prims = []
materials = []; images = []; textures = []; samplers = []
teximg = {}
def acc(fmt, ctype, count, atype, vals, minmax=False):
off = buf.add(fmt, vals)
comp = {"SCALAR": 1, "VEC2": 2, "VEC3": 3}[atype]
views.append({"buffer": 0, "byteOffset": off,
"byteLength": count * comp * (4 if ctype == 5126 else 4)})
a = {"bufferView": len(views) - 1, "componentType": ctype,
"count": count, "type": atype}
if minmax:
a["min"] = [min(v[i] for v in vals) for i in range(comp)]
a["max"] = [max(v[i] for v in vals) for i in range(comp)]
accessors.append(a)
return len(accessors) - 1
for key, (mat, part) in self.parts.items():
m = {"name": mat.name,
"pbrMetallicRoughness": {"baseColorFactor": list(mat.tint),
"metallicFactor": mat.metal,
"roughnessFactor": mat.rough}}
if mat.double: m["doubleSided"] = True
if mat.tex:
if mat.tex not in teximg:
ext = None
for e in (".png", ".jpg", ".jpeg", ".webp"):
if os.path.exists(os.path.join(textures_dir, mat.tex + e)):
ext = e; break
if ext is None:
raise SystemExit(f"{self.id}: texture missing: {mat.tex}")
if not samplers:
samplers.append({"magFilter": 9729, "minFilter": 9987,
"wrapS": GLTF_MIRRORED, "wrapT": GLTF_MIRRORED})
images.append({"uri": "../textures/" + mat.tex + ext})
textures.append({"sampler": 0, "source": len(images) - 1})
teximg[mat.tex] = len(textures) - 1
m["pbrMetallicRoughness"]["baseColorTexture"] = {"index": teximg[mat.tex]}
materials.append(m)
uvs = [(u / mat.uv, v / mat.uv) for u, v in part["UV"]]
pa = acc("f", 5126, len(part["P"]), "VEC3", [list(p) for p in part["P"]], minmax=True)
na = acc("f", 5126, len(part["N"]), "VEC3", [list(n) for n in part["N"]])
ta = acc("f", 5126, len(uvs), "VEC2", [list(u) for u in uvs])
ia = acc("I", 5125, len(part["I"]), "SCALAR", part["I"])
prims.append({"attributes": {"POSITION": pa, "NORMAL": na, "TEXCOORD_0": ta},
"indices": ia, "material": len(materials) - 1})
g = {"asset": {"version": "2.0", "generator": "park_kit propkit (original, GODVERSE)"},
"scene": 0, "scenes": [{"nodes": [0], "name": self.id}],
"nodes": [{"name": "Prop_" + self.id, "mesh": 0}],
"meshes": [{"name": self.id + "_mesh", "primitives": prims}],
"materials": materials,
"accessors": accessors, "bufferViews": views,
"buffers": [{"byteLength": len(buf.b)}]}
if samplers:
g["samplers"] = samplers; g["images"] = images; g["textures"] = textures
out = os.path.join(props_dir, self.id + ".glb")
write_glb(out, g, bytes(buf.b))
xs = [p[0] for _, (m, pt) in self.parts.items() for p in pt["P"]]
ys = [p[1] for _, (m, pt) in self.parts.items() for p in pt["P"]]
zs = [p[2] for _, (m, pt) in self.parts.items() for p in pt["P"]]
tris = sum(len(pt["I"]) for _, (m, pt) in self.parts.items()) // 3
entry = {"id": self.id, "src": "props/" + self.id + ".glb",
"category": self.category, "desc": self.desc,
"size": [round(max(xs) - min(xs), 3), round(max(ys), 3),
round(max(zs) - min(zs), 3)],
"tris": tris}
if self.grinds: entry["grinds"] = self.grinds
if self.collider: entry["collider"] = self.collider
if self.element: entry["element"] = self.element
print(f" {self.id}.glb {tris} tris {os.path.getsize(out)//1024}KB")
return entry