John's editor taxonomy: kink/A-frame/donkey/down/square rails, pole jam, hubba, low/table boxes, inclined manny, pyramid, euro gap, vert quarter, wallride wall, volcano, roller, jersey barrier, parking block, hydrant, pool coping, chain fence, floodlight, bleachers, drain grate. propkit: alpha + emissive materials; lathe winding fixed (was inside-out). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
16 KiB
Python
343 lines
16 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.
|
|
tint alpha < 1 -> BLEND (chain-link mesh); emissive -> emissiveFactor."""
|
|
def __init__(self, name, tex=None, tint=(1, 1, 1, 1), rough=0.9, metal=0.0,
|
|
uv=1.0, double=False, emissive=None):
|
|
self.name, self.tex, self.tint = name, tex, tuple(tint)
|
|
self.rough, self.metal, self.uv, self.double = rough, metal, uv, double
|
|
self.emissive = emissive
|
|
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]
|
|
# rows=angle, cols=profile: unflipped winding already faces OUTWARD
|
|
# (profile-tangent x angle-tangent = radial). flip=True shipped every
|
|
# lathe inside-out — invisible on cylinders, obvious on the volcano cone.
|
|
pt = sheet(rows)
|
|
# 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.tint[3] < 1.0: m["alphaMode"] = "BLEND"
|
|
if mat.emissive: m["emissiveFactor"] = list(mat.emissive)
|
|
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
|