#!/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(" 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