Add sail cloth textures and the storm dressing set
Textures (SPRINT2 §Lane E-2). sail_weave.png is a seamless 512² knitted shade-cloth weave with the stripe banding real cloth has; the script proves the wrap by evaluating a second tile and requiring an exact match, because Lane B is being told to set RepeatWrapping and a bad wrap would seam across the whole sail. Luminance rides in a narrow band so it multiplies the base colour instead of replacing it — high contrast reads as burlap, not HDPE. Dropped the per-pixel noise: invisible at ±0.012 and incompressible, it cost 305 KB of the 323. sail_tears.png is a strip of 4 escalating rips. Each is a lens, not a slit — fabric under tension parts widest in the middle and tapers to a point, and a constant-width gap reads as a drawn line. Threads bridge the gap, scaled to the local width; without them a dark lens is a hole rather than a tear. Dressing (§Lane E-3): wheelie_bin_01 (240 L, 12 kg, `lid` on its own node so it flaps before the bin goes over), washing_line_01 (a Hills Hoist — the `head` freewheels, giving a second wind tell at head height), garden_gnome_01 (collateral bait: a smashed gnome reads where a damage number doesn't). Bin and tramp land in models/debris/ for Lane C to glob. All 19 assets pass, 28 output files byte-identical across two runs, selftest 129/0/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7cd25a5d2a
commit
d3e849fcc3
@ -1,6 +1,22 @@
|
||||
{
|
||||
"blender": "5.1.2",
|
||||
"assets": [
|
||||
{
|
||||
"name": "ref_capsule",
|
||||
"dims": [
|
||||
0.4,
|
||||
0.4,
|
||||
1.7
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"head_height",
|
||||
"ref_capsule",
|
||||
"ref_capsule_mesh"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_gum_01",
|
||||
"dims": [
|
||||
@ -42,6 +58,285 @@
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
0.13,
|
||||
0.13,
|
||||
2.03
|
||||
],
|
||||
"tris": 24,
|
||||
"nodes": [
|
||||
"fence_post",
|
||||
"post"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_panel",
|
||||
"dims": [
|
||||
2.4,
|
||||
0.054,
|
||||
1.8194
|
||||
],
|
||||
"tris": 324,
|
||||
"nodes": [
|
||||
"fence_panel",
|
||||
"palings",
|
||||
"rails"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "gate",
|
||||
"dims": [
|
||||
1.045,
|
||||
0.0615,
|
||||
1.75
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"gate",
|
||||
"gate_frame",
|
||||
"gate_palings",
|
||||
"hinge_axis",
|
||||
"hinges"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "house_yardside",
|
||||
"dims": [
|
||||
9.2,
|
||||
1.0547,
|
||||
2.9
|
||||
],
|
||||
"tris": 200,
|
||||
"nodes": [
|
||||
"door",
|
||||
"fascia",
|
||||
"fascia_anchor_01",
|
||||
"fascia_anchor_02",
|
||||
"fascia_anchor_03",
|
||||
"gutter",
|
||||
"house_yardside",
|
||||
"roof",
|
||||
"wall",
|
||||
"window"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
2.58,
|
||||
1.9708,
|
||||
2.2224
|
||||
],
|
||||
"tris": 96,
|
||||
"nodes": [
|
||||
"door_anchor",
|
||||
"doors",
|
||||
"roof",
|
||||
"shed_01",
|
||||
"shell"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_table",
|
||||
"dims": [
|
||||
1.6,
|
||||
0.6,
|
||||
0.9
|
||||
],
|
||||
"tris": 72,
|
||||
"nodes": [
|
||||
"pickup_anchor",
|
||||
"shed_table",
|
||||
"table_frame",
|
||||
"table_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_bed",
|
||||
"dims": [
|
||||
3.0,
|
||||
1.2,
|
||||
0.8609
|
||||
],
|
||||
"tris": 2580,
|
||||
"nodes": [
|
||||
"bed",
|
||||
"garden_bed",
|
||||
"plants_dead",
|
||||
"plants_full",
|
||||
"plants_tattered",
|
||||
"soil"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "sail_post",
|
||||
"dims": [
|
||||
0.507,
|
||||
0.52,
|
||||
4.0327
|
||||
],
|
||||
"tris": 528,
|
||||
"nodes": [
|
||||
"footing",
|
||||
"pad_eye",
|
||||
"post",
|
||||
"rake_pivot",
|
||||
"sail_post",
|
||||
"top_anchor"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "ladder_01",
|
||||
"dims": [
|
||||
0.455,
|
||||
0.075,
|
||||
3.0
|
||||
],
|
||||
"tris": 276,
|
||||
"nodes": [
|
||||
"ladder",
|
||||
"ladder_01",
|
||||
"ladder_base",
|
||||
"ladder_top"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shackle",
|
||||
"dims": [
|
||||
0.0569,
|
||||
0.019,
|
||||
0.0744
|
||||
],
|
||||
"tris": 560,
|
||||
"nodes": [
|
||||
"bow",
|
||||
"pin",
|
||||
"shackle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "carabiner",
|
||||
"dims": [
|
||||
0.049,
|
||||
0.009,
|
||||
0.1027
|
||||
],
|
||||
"tris": 476,
|
||||
"nodes": [
|
||||
"body",
|
||||
"carabiner",
|
||||
"gate"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "turnbuckle",
|
||||
"dims": [
|
||||
0.0292,
|
||||
0.0341,
|
||||
0.1955
|
||||
],
|
||||
"tris": 728,
|
||||
"nodes": [
|
||||
"body",
|
||||
"eye_a",
|
||||
"eye_b",
|
||||
"turnbuckle"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tramp_01",
|
||||
"dims": [
|
||||
2.9555,
|
||||
2.9555,
|
||||
0.78
|
||||
],
|
||||
"tris": 976,
|
||||
"nodes": [
|
||||
"legs",
|
||||
"mat",
|
||||
"pad",
|
||||
"rim",
|
||||
"tramp_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "wheelie_bin_01",
|
||||
"dims": [
|
||||
0.58,
|
||||
0.6808,
|
||||
1.1188
|
||||
],
|
||||
"tris": 120,
|
||||
"nodes": [
|
||||
"bin_body",
|
||||
"lid",
|
||||
"lid_plate",
|
||||
"wheelie_bin_01",
|
||||
"wheels"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "washing_line_01",
|
||||
"dims": [
|
||||
2.8441,
|
||||
2.8441,
|
||||
2.2777
|
||||
],
|
||||
"tris": 336,
|
||||
"nodes": [
|
||||
"arms",
|
||||
"head",
|
||||
"mast",
|
||||
"washing_line_01"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "garden_gnome_01",
|
||||
"dims": [
|
||||
0.1427,
|
||||
0.15,
|
||||
0.365
|
||||
],
|
||||
"tris": 236,
|
||||
"nodes": [
|
||||
"garden_gnome_01",
|
||||
"gnome"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
}
|
||||
],
|
||||
"debris": [
|
||||
|
||||
@ -110,7 +110,13 @@ PAL = {
|
||||
"plant_full": "#5F8A3E",
|
||||
"plant_tatty": "#7A8446",
|
||||
"plant_dead": "#8A7550",
|
||||
"mat_black": "#2E2E30", # trampoline mat
|
||||
"mat_black": "#2E2E30", # trampoline mat, bin wheels
|
||||
"bin_green": "#3F5B44", # kerbside wheelie bin
|
||||
"bin_lid": "#C4A63A", # recycling-yellow lid
|
||||
"line_white": "#DCD9CF", # clothes line, gnome beard
|
||||
"gnome_skin": "#E0A986",
|
||||
"gnome_coat": "#3E6FA8",
|
||||
"gnome_hat": "#B33C36",
|
||||
"ref_pink": "#E85C8A", # the reference capsule — deliberately loud
|
||||
}
|
||||
|
||||
@ -988,9 +994,239 @@ def build_tramp_01(name):
|
||||
return root
|
||||
|
||||
|
||||
def build_wheelie_bin_01(name):
|
||||
"""240 L kerbside bin — 1.10 m, ~12 kg empty. The `lid` is its own node: it
|
||||
flaps before the bin goes over, which is a free tell that the wind is up."""
|
||||
root = add_empty(name)
|
||||
body_m = get_material("Mat_BinBody", PAL["bin_green"], 0.75)
|
||||
lid_m = get_material("Mat_BinLid", PAL["bin_lid"], 0.7)
|
||||
wheel_m = get_material("Mat_Rubber", PAL["mat_black"], 0.95)
|
||||
W, D, H = 0.58, 0.74, 1.02
|
||||
|
||||
body = [add_cone(f"{name}_shell", 0.40, 0.34, H, (0, 0, H / 2 + 0.06),
|
||||
body_m, verts=4, rot=(0, 0, math.radians(45)))]
|
||||
body.append(add_box(f"{name}_spine", (0.10, 0.06, H * 0.8),
|
||||
(0, D / 2 - 0.06, H * 0.5), body_m))
|
||||
join_group(body, "bin_body", root)
|
||||
|
||||
lid_pivot = (0, D / 2 - 0.10, H + 0.07)
|
||||
lid_grp = add_empty("lid", lid_pivot, root, size=0.2)
|
||||
lid = join_group([
|
||||
add_box(f"{name}_lid_plate", (W, D * 0.92, 0.035),
|
||||
(0, 0.02, H + 0.085), lid_m),
|
||||
add_box(f"{name}_lid_lip", (W, 0.04, 0.05), (0, -D / 2 + 0.10, H + 0.07),
|
||||
lid_m),
|
||||
], "lid_plate")
|
||||
parent_keep_transform(lid, lid_grp)
|
||||
lid_grp["flap_axis"] = "x"
|
||||
lid_grp["flap_max_deg"] = 75
|
||||
|
||||
wheels = [add_cyl(f"{name}_wheel_{sx}", 0.075, 0.05,
|
||||
(sx * (W / 2 - 0.06), D / 2 - 0.10, 0.075), wheel_m,
|
||||
verts=10, rot=(0, math.pi / 2, 0))
|
||||
for sx in (-1, 1)]
|
||||
join_group(wheels, "wheels", root)
|
||||
|
||||
stamp(root, name, "debris")
|
||||
root["mass_hint"] = 12.0 # empty; a full one does not blow over
|
||||
root["tumble_hint"] = "topples about the wheel axle first"
|
||||
return root
|
||||
|
||||
|
||||
def build_washing_line_01(name):
|
||||
"""A Hills Hoist. Australian back yards have exactly one, and it is the
|
||||
perfect storm prop: the `head` freewheels, so it spins up in a gust — a
|
||||
second wind tell, at head height, right where the player is working."""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
conc = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
line_m = get_material("Mat_Line", PAL["line_white"], 0.9)
|
||||
H, ARM = 2.05, 1.42
|
||||
|
||||
join_group([
|
||||
add_cyl(f"{name}_socket", 0.14, 0.10, (0, 0, 0.05), conc, verts=12),
|
||||
add_cyl(f"{name}_mast", 0.038, H, (0, 0, H / 2), steel, verts=10),
|
||||
], "mast", root)
|
||||
|
||||
# Everything above the collar spins.
|
||||
head = add_empty("head", (0, 0, H), root, size=0.4)
|
||||
head["spin_axis"] = "y"
|
||||
head["free_spin"] = True
|
||||
head["spin_hint"] = "freewheels; spin rate ~ wind speed"
|
||||
|
||||
parts = []
|
||||
for i in range(4):
|
||||
a = math.tau * i / 4
|
||||
tip = (math.cos(a) * ARM, math.sin(a) * ARM, H - 0.16)
|
||||
parts.append(add_tube_between(f"{name}_arm_{i}", (0, 0, H), tip, 0.018,
|
||||
steel, verts=6))
|
||||
parts.append(add_tube_between(f"{name}_stay_{i}", (0, 0, H + 0.22), tip,
|
||||
0.008, steel, verts=4))
|
||||
# Four courses of line between the arm tips.
|
||||
for ring in range(4):
|
||||
rr = ARM * (0.45 + 0.18 * ring)
|
||||
for i in range(4):
|
||||
a0, a1 = math.tau * i / 4, math.tau * (i + 1) / 4
|
||||
z = H - 0.16 + 0.02 * ring
|
||||
parts.append(add_tube_between(
|
||||
f"{name}_line_{ring}_{i}",
|
||||
(math.cos(a0) * rr, math.sin(a0) * rr, z),
|
||||
(math.cos(a1) * rr, math.sin(a1) * rr, z), 0.004, line_m, verts=3))
|
||||
spun = join_group(parts, "arms", None)
|
||||
parent_keep_transform(spun, head)
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["height"] = H
|
||||
return root
|
||||
|
||||
|
||||
def build_garden_gnome_01(name):
|
||||
"""37 cm of painted concrete. He is scoring bait: DESIGN.md's collateral rule
|
||||
wants something the player can fail to protect, and a smashed gnome reads
|
||||
instantly where a damage number does not."""
|
||||
root = add_empty(name)
|
||||
skin = get_material("Mat_Skin", PAL["gnome_skin"], 0.8)
|
||||
coat = get_material("Mat_Coat", PAL["gnome_coat"], 0.85)
|
||||
hat = get_material("Mat_Hat", PAL["gnome_hat"], 0.85)
|
||||
beard = get_material("Mat_Beard", PAL["line_white"], 0.9)
|
||||
base_m = get_material("Mat_Concrete", PAL["concrete"], 0.95)
|
||||
|
||||
parts = [
|
||||
add_cyl(f"{name}_base", 0.075, 0.02, (0, 0, 0.01), base_m, verts=10),
|
||||
add_cone(f"{name}_body", 0.072, 0.045, 0.16, (0, 0, 0.10), coat, verts=10),
|
||||
add_ico(f"{name}_head", 0.042, (0, 0, 0.205), skin, subdiv=2),
|
||||
add_cone(f"{name}_beard", 0.038, 0.004, 0.075, (0, -0.020, 0.176),
|
||||
beard, verts=8, rot=(math.radians(14), 0, 0)),
|
||||
add_cone(f"{name}_hat", 0.050, 0.002, 0.14, (0, 0.004, 0.295), hat,
|
||||
verts=10),
|
||||
add_ico(f"{name}_nose", 0.011, (0, -0.038, 0.208), skin, subdiv=1),
|
||||
]
|
||||
join_group(parts, "gnome", root)
|
||||
stamp(root, name, "prop")
|
||||
root["mass_hint"] = 4.5
|
||||
root["collateral_value"] = 25 # $ — Lane A's aftermath screen
|
||||
root["breakable"] = True
|
||||
return root
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# GRASS ATLAS — a texture, not geometry (PLAN3D §5-E item 9)
|
||||
# ============================================================================
|
||||
def save_png(arr, name):
|
||||
"""arr: (h, w, 4) float32 RGBA in 0..1, row 0 = BOTTOM (bpy's convention).
|
||||
Blender ships no PIL, so every texture here is numpy -> bpy's image API."""
|
||||
import numpy as np # noqa: F401
|
||||
h, w = arr.shape[0], arr.shape[1]
|
||||
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
||||
out = os.path.join(TEXTURES_DIR, f"{name}.png")
|
||||
img = bpy.data.images.new(name, w, h, alpha=True)
|
||||
img.pixels.foreach_set(arr.reshape(-1))
|
||||
img.filepath_raw = out
|
||||
img.file_format = 'PNG'
|
||||
img.save()
|
||||
bpy.data.images.remove(img)
|
||||
return out, os.path.getsize(out) // 1024
|
||||
|
||||
|
||||
def build_sail_textures():
|
||||
"""Shade-cloth weave + tear decals (SPRINT2 §Lane E-2).
|
||||
|
||||
sail_weave.png is SEAMLESS and meant to tile: every frequency is an integer
|
||||
number of cycles across the image, so the wrap is exact. Lane B sets
|
||||
wrapS/wrapT = RepeatWrapping and repeat ≈ (6,6) on a ~5 m sail.
|
||||
|
||||
Deliberately subtle — luminance rides in a narrow band so it multiplies the
|
||||
base colour rather than replacing it. A high-contrast weave reads as burlap,
|
||||
and this is knitted HDPE shade cloth.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
SIZE, K = 512, 64 # K threads across; 512/64 = 8 px per thread
|
||||
|
||||
def weave_lum(X, Y):
|
||||
# Over-under: in one checker cell the weft rides on top, in the next the
|
||||
# warp. Every frequency is an integer number of cycles across SIZE, which
|
||||
# is what makes the wrap exact.
|
||||
warp = 0.5 + 0.5 * np.cos(2 * np.pi * K * X / SIZE)
|
||||
weft = 0.5 + 0.5 * np.cos(2 * np.pi * K * Y / SIZE)
|
||||
over = (((X * K) // SIZE) + ((Y * K) // SIZE)) % 2 == 0
|
||||
knit = np.where(over, weft, warp)
|
||||
# The knit banding real shade cloth has, every 8th thread — the "UV stripe".
|
||||
stripe = 1.0 - 0.045 * ((((X * K) // SIZE) % 8) == 0)
|
||||
stripe *= 1.0 - 0.030 * ((((Y * K) // SIZE) % 8) == 0)
|
||||
# No per-pixel noise: at ±0.012 it was invisible, but it is incompressible
|
||||
# and took the PNG from 18 KB to 323 KB. The knit carries it alone.
|
||||
return np.clip((0.80 + 0.20 * knit) * stripe, 0.0, 1.0).astype(np.float32)
|
||||
|
||||
Y, X = np.mgrid[0:SIZE, 0:SIZE]
|
||||
lum = weave_lum(X, Y)
|
||||
|
||||
# Prove it tiles. Lane B is being told "RepeatWrapping, repeat ~(6,6)" — if
|
||||
# the wrap isn't exact that's a visible seam every tile across the whole sail,
|
||||
# so evaluating one tile to the right must reproduce this one exactly.
|
||||
Y2, X2 = np.mgrid[0:SIZE, SIZE:2 * SIZE]
|
||||
if not np.array_equal(lum, weave_lum(X2, Y2)):
|
||||
raise AssertionError("sail_weave is not seamless — it would seam on repeat")
|
||||
|
||||
weave = np.zeros((SIZE, SIZE, 4), dtype=np.float32)
|
||||
weave[:, :, 0] = lum
|
||||
weave[:, :, 1] = lum
|
||||
weave[:, :, 2] = lum * 0.985 # a hair warm, so white cloth isn't clinical
|
||||
weave[:, :, 3] = 1.0
|
||||
p1, kb1 = save_png(weave, "sail_weave")
|
||||
print(f" sail_weave.png {SIZE}x{SIZE}, seamless, {K} threads, {kb1} KB")
|
||||
|
||||
# --- tear decals ------------------------------------------------------
|
||||
# A strip of 4, RGBA, alpha 0 everywhere but the rip. Overlay on a damaged
|
||||
# panel for M3. Each tear = a jagged slit with frayed threads pulling out of
|
||||
# both lips, because fabric fails along the weave, not in a clean line.
|
||||
TW, TH = 1024, 256
|
||||
cell = TH
|
||||
tears = np.zeros((TH, TW, 4), dtype=np.float32)
|
||||
|
||||
def stamp(px, x, y, rgb, a):
|
||||
xi, yi = int(round(x)), int(round(y))
|
||||
if px <= xi < px + cell and 0 <= yi < TH: # clip inside this decal's cell
|
||||
tears[yi, xi, 0:3] = rgb
|
||||
tears[yi, xi, 3] = a
|
||||
|
||||
# Four escalating rips. Each is a LENS, not a slit: fabric under tension
|
||||
# parts widest in the middle and tapers to a point at both ends. A
|
||||
# constant-width gap reads as a drawn line, which is what the first pass did.
|
||||
for c in range(4):
|
||||
r = rng_for(f"sail_tear_{c}")
|
||||
px = c * cell
|
||||
length = cell * (0.48 + 0.09 * c)
|
||||
max_gap = cell * (0.055 + 0.042 * c) # the 4th gapes ~4x the 1st
|
||||
x0 = px + (cell - length) / 2
|
||||
steps = int(length)
|
||||
yy = cell * 0.5
|
||||
lips = []
|
||||
for s in range(steps):
|
||||
t = s / max(1, steps - 1)
|
||||
yy = max(cell * 0.3, min(cell * 0.7, yy + r.uniform(-1.1, 1.1)))
|
||||
half = max_gap * (math.sin(math.pi * t) ** 0.7)
|
||||
jag = r.uniform(-0.08, 0.08) * max_gap # ragged, not spiky
|
||||
top, bot = yy - half + jag, yy + half + jag
|
||||
for y in np.arange(top, bot, 0.5):
|
||||
stamp(px, x0 + s, y, (0.10, 0.09, 0.08), 1.0) # the gap
|
||||
if half > 1.5:
|
||||
lips.append((x0 + s, top, +1, half)) # +1 = toward the gap
|
||||
lips.append((x0 + s, bot, -1, half))
|
||||
# Threads pulling off both lips and bridging the gap. These are the tell:
|
||||
# without them a lens of dark pixels is a hole, not a tear. Length scales
|
||||
# with the LOCAL gap so some strands span it completely.
|
||||
for _ in range(int(55 + c * 30)):
|
||||
x, y, into, half = lips[r.randrange(len(lips))]
|
||||
span = half * r.uniform(0.5, 1.9)
|
||||
for s in np.arange(0.0, span, 0.5):
|
||||
stamp(px, x + r.uniform(-0.6, 0.6), y + into * (s + 1.0),
|
||||
(0.82, 0.76, 0.62), 1.0)
|
||||
p2, kb2 = save_png(tears, "sail_tears")
|
||||
print(f" sail_tears.png {TW}x{TH}, 4 decals, alpha, {kb2} KB")
|
||||
return [p1, p2]
|
||||
|
||||
|
||||
def build_grass_atlas():
|
||||
"""4-tuft billboard atlas, 2x2 cells. Drawn with numpy (no PIL in Blender's
|
||||
python) and saved through bpy's image API. Lane A instances quads with this."""
|
||||
@ -1032,16 +1268,8 @@ def build_grass_atlas():
|
||||
blade(px, py, base_x, py + 2, h, lean,
|
||||
cell * rng.uniform(0.012, 0.022), rgb)
|
||||
|
||||
os.makedirs(TEXTURES_DIR, exist_ok=True)
|
||||
out = os.path.join(TEXTURES_DIR, "grass_atlas.png")
|
||||
bimg = bpy.data.images.new("grass_atlas", SIZE, SIZE, alpha=True)
|
||||
bimg.pixels.foreach_set(img.reshape(-1))
|
||||
bimg.filepath_raw = out
|
||||
bimg.file_format = 'PNG'
|
||||
bimg.save()
|
||||
bpy.data.images.remove(bimg)
|
||||
kb = os.path.getsize(out) // 1024
|
||||
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS*CELLS} tufts, {kb} KB")
|
||||
out, kb = save_png(img, "grass_atlas")
|
||||
print(f" grass_atlas.png {SIZE}x{SIZE}, {CELLS * CELLS} tufts, {kb} KB")
|
||||
return out
|
||||
|
||||
|
||||
@ -1099,10 +1327,19 @@ ASSETS = [
|
||||
dict(name="turnbuckle", fn=build_turnbuckle,
|
||||
dims=((0.015, 0.05), (0.015, 0.05), (0.12, 0.20)),
|
||||
nodes=["body", "eye_a", "eye_b"]),
|
||||
# Lands in models/debris/ — Lane C spawns debris from that directory.
|
||||
# These land in models/debris/ — Lane C globs that directory to spawn from.
|
||||
dict(name="tramp_01", fn=build_tramp_01, dir=DEBRIS_DIR,
|
||||
dims=((2.8, 3.1), (2.8, 3.1), (0.70, 0.85)),
|
||||
nodes=["mat", "rim", "pad", "legs"]),
|
||||
dict(name="wheelie_bin_01", fn=build_wheelie_bin_01, dir=DEBRIS_DIR,
|
||||
dims=((0.50, 0.70), (0.65, 0.85), (1.00, 1.20)),
|
||||
nodes=["bin_body", "lid", "lid_plate", "wheels"]),
|
||||
dict(name="washing_line_01", fn=build_washing_line_01,
|
||||
dims=((2.7, 3.1), (2.7, 3.1), (2.0, 2.4)),
|
||||
nodes=["mast", "head", "arms"]),
|
||||
dict(name="garden_gnome_01", fn=build_garden_gnome_01,
|
||||
dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)),
|
||||
nodes=["gnome"]),
|
||||
]
|
||||
|
||||
|
||||
@ -1417,6 +1654,7 @@ def main():
|
||||
build_all(only)
|
||||
reset_to_empty()
|
||||
build_grass_atlas()
|
||||
build_sail_textures()
|
||||
debris = [] if no_debris else copy_debris()
|
||||
|
||||
failures = []
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 2.5 MiB |
@ -69,6 +69,10 @@ const ASSETS = [
|
||||
{ name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
|
||||
{ name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
|
||||
{ name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
|
||||
{ name: 'wheelie_bin_01', h: [1.0, 1.2],
|
||||
nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
|
||||
{ name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
|
||||
{ name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -176,6 +180,30 @@ export default async function run(t) {
|
||||
'the blob centre, not the trunk top, so the tree cannot visibly sway');
|
||||
});
|
||||
|
||||
// Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
|
||||
// it has to carry the arms round. If the arms were parented to the root, the
|
||||
// head would turn and nothing would move.
|
||||
t.test('washing line head carries the arms round when spun', () => {
|
||||
const g = loaded.get('washing_line_01');
|
||||
assert(g, 'washing_line_01 did not load');
|
||||
const head = g.scene.getObjectByName('head');
|
||||
const arms = g.scene.getObjectByName('arms');
|
||||
assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
|
||||
assert(arms.parent === head || arms.parent?.parent === head,
|
||||
'`arms` is not under `head` — spinning the head would move nothing');
|
||||
|
||||
g.scene.updateWorldMatrix(true, true);
|
||||
const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||||
head.rotation.y += Math.PI / 2;
|
||||
head.updateWorldMatrix(true, true);
|
||||
const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
|
||||
head.rotation.y -= Math.PI / 2;
|
||||
head.updateWorldMatrix(true, true);
|
||||
// The arms group's own origin sits on the spin axis, so its centre barely
|
||||
// moves — what must hold is that it is genuinely under the rotating node.
|
||||
assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
|
||||
});
|
||||
|
||||
// One GLB carries three wilt states as siblings; Lane A toggles .visible
|
||||
// rather than reloading, so all three have to be present at once.
|
||||
t.test('garden_bed carries all 3 damage states in one GLB', () => {
|
||||
|
||||
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
BIN
web/world/models/debris/wheelie_bin_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
BIN
web/world/models/garden_gnome_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/textures/sail_tears.png
Normal file
BIN
web/world/models/textures/sail_tears.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
web/world/models/textures/sail_weave.png
Normal file
BIN
web/world/models/textures/sail_weave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
web/world/models/washing_line_01_v1.glb
Normal file
BIN
web/world/models/washing_line_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user