diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 043f3fe..87ceeac 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -719,6 +719,193 @@ "anchors": [], "status": "PASS", "problems": [] + }, + { + "name": "pergola_01", + "dims": [ + 3.2, + 2.6, + 2.71 + ], + "tris": 276, + "nodes": [ + "beams", + "deck", + "pergola_01", + "pergola_anchor_01", + "pergola_anchor_02", + "posts", + "rafters" + ], + "anchors": [ + { + "node": "pergola_anchor_01", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + }, + { + "node": "pergola_anchor_02", + "anchor_type": "pergola", + "rating_hint": 0.65, + "collateral": "pergola" + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "pergola_01_wrecked", + "dims": [ + 3.3288, + 2.6, + 2.6648 + ], + "tris": 300, + "nodes": [ + "beam_down", + "beams", + "deck", + "pergola_01_wrecked", + "posts", + "rafters_down" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01", + "dims": [ + 2.645, + 1.945, + 2.3025 + ], + "tris": 356, + "nodes": [ + "door", + "frame", + "glasshouse_01", + "panes", + "ridge_anchor" + ], + "anchors": [ + { + "node": "ridge_anchor", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] + }, + { + "name": "glasshouse_01_wrecked", + "dims": [ + 4.3356, + 2.9387, + 2.3025 + ], + "tris": 680, + "nodes": [ + "door", + "frame", + "glasshouse_01_wrecked", + "panes_left", + "shards" + ], + "anchors": [], + "status": "PASS", + "problems": [] + }, + { + "name": "pool_kit_01", + "dims": [ + 5.645, + 4.5207, + 1.22 + ], + "tris": 2780, + "nodes": [ + "fence_post_01", + "fence_post_02", + "fence_post_03", + "fence_post_04", + "fence_post_05", + "fence_post_06", + "fence_post_07", + "fence_post_08", + "fence_post_09", + "fence_post_10", + "fence_post_11", + "fence_post_12", + "fence_post_13", + "fence_post_14", + "fence_rails", + "pool_gate", + "pool_kit_01", + "pool_shell", + "pool_water" + ], + "anchors": [ + { + "node": "fence_post_01", + "tie_off": false + }, + { + "node": "fence_post_02", + "tie_off": false + }, + { + "node": "fence_post_03", + "tie_off": false + }, + { + "node": "fence_post_04", + "tie_off": false + }, + { + "node": "fence_post_05", + "tie_off": false + }, + { + "node": "fence_post_06", + "tie_off": false + }, + { + "node": "fence_post_07", + "tie_off": false + }, + { + "node": "fence_post_08", + "tie_off": false + }, + { + "node": "fence_post_09", + "tie_off": false + }, + { + "node": "fence_post_10", + "tie_off": false + }, + { + "node": "fence_post_11", + "tie_off": false + }, + { + "node": "fence_post_12", + "tie_off": false + }, + { + "node": "fence_post_13", + "tie_off": false + }, + { + "node": "fence_post_14", + "tie_off": false + } + ], + "status": "PASS", + "problems": [] } ], "debris": [] diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 2108280..32b673c 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -137,6 +137,11 @@ PAL = { "bike_kid": "#D8483C", # the Henderson kid's bike — bought bright on purpose "bike_grip": "#3B4048", # grips and saddle "ref_pink": "#E85C8A", # the reference capsule — deliberately loud + "alu_frame": "#C9CED2", # powder-coated aluminium — glasshouse + pool fence + "pool_water": "#4FA8BE", # chlorinated blue, NOT the pond's grey-green — + # a pool is the one water in the game that is + # supposed to look inviting + "deck_board": "#A8845C", # decking oil, a shade lighter than fence timber } # Rainwater caught in a sail is not swimming-pool blue. It's shallow, murky, it @@ -1522,6 +1527,544 @@ def build_swing_set_01_wrecked(name): return root +# --------------------------------------------------------------------------- +# THE PERGOLA (SPRINT16 gate 5 — the honest middle rung, D-endorsed) +# --------------------------------------------------------------------------- +# One shape, stated once, used by the intact pergola AND the wreck — the +# _swing_frame precedent, kept on purpose: a wreck built from re-typed numbers +# drifts from its twin one edit at a time. +PERGOLA = dict( + DECK_W=3.2, # deck span, x + DECK_D=2.6, # deck depth, y + DECK_H=0.30, # two steps up — the deck IS the footing, that's the point + POST=0.09, # 90x90 treated pine + POST_H=2.20, # above the deck + INSET=0.18, # posts in from the deck edge + BEAM_H=0.14, # 140x45 beams along x on front/back post pairs + N_RAFTERS=7, # 70x35 rafters spanning y, the open roof +) + +# What it costs when the timber cracks. Proposal — Lane A owns the number, the +# ladder it slots into is gnome 25 < gutter 90 < PERGOLA 120 < swing 140 < +# carport 180 (< glasshouse 320, below): +# · the failure is PARTIAL by design: the bolts hold, the timber breaks — a +# torn-off beam and a snapped post on a structure that stays standing. The +# bill is one beam, one post and the labour to re-bolt, not a rebuild; +# · under ~90 it undercuts the gutter, and a structural timber repair does +# not cost less than a run of guttering; +# · over ~140 it outbids the swing set, whose failure is the WHOLE frame on +# the grass — a worse day than a cracked beam, and the prices must agree +# with the wrecks the player actually sees. +PERGOLA_COLLATERAL = 120 + + +def _pergola(name, root, deck_m, timber_m, dark_m, *, wrecked=False): + """Build the pergola. Returns the two anchor points (Blender coords) so the + caller hangs anchors on the geometry's own numbers, not a second copy of + the arithmetic. Intact and wrecked share every dimension through PERGOLA. + + HOW IT FAILS, and why that is the 0.65 story told twice: the posts are + BOLTED to the deck — real M12s through the joists, and the bolts hold. + What gives is the timber and the deck's own stiffness: the loaded front + beam tears off its post tops, the front posts snap at half height above + the racking bolts, and the rafters — still fixed at the back beam — hinge + down like a lean-to until their front ends hit the deck. The back half + stands. A pergola fails PARTIALLY, which is exactly why its rating (0.65) + sits above the unbolted swing frame (0.45) and below concrete (1.00), and + why its bill (a beam and a post) sits below the swing's (a whole frame). + """ + P = PERGOLA + hw, hd = P["DECK_W"] / 2, P["DECK_D"] / 2 + px, py = hw - P["INSET"], hd - P["INSET"] # post centres + top = P["DECK_H"] + P["POST_H"] # post top z + beam_z = top + P["BEAM_H"] / 2 # beam centre z + + # --- the deck: joists + boards. Identical in the wreck — it was never the + # weak part; it is the footing the rating is ABOUT. + deck = [add_box(f"{name}_deck_slab", (P["DECK_W"], P["DECK_D"], 0.045), + (0, 0, P["DECK_H"] - 0.0225), deck_m)] + for i in range(4): + y = -hd + 0.12 + i * (P["DECK_D"] - 0.24) / 3 + deck.append(add_box(f"{name}_joist_{i}", (P["DECK_W"] - 0.1, 0.045, P["DECK_H"] - 0.05), + (0, y, (P["DECK_H"] - 0.05) / 2), dark_m)) + # Board grooves read as lines; three thin gaps of darker timber. + for i in range(5): + x = -hw + 0.3 + i * (P["DECK_W"] - 0.6) / 4 + deck.append(add_box(f"{name}_board_gap_{i}", (0.015, P["DECK_D"] - 0.02, 0.008), + (x, 0, P["DECK_H"] + 0.004), dark_m)) + join_group(deck, "deck", root) + + # --- posts. Front pair (−y, the yard side, where the anchors live) snaps + # in the wreck; back pair always stands. + posts = [] + for sx in (-1, 1): + posts.append(add_box(f"{name}_post_b_{sx}", (P["POST"], P["POST"], P["POST_H"]), + (sx * px, py, P["DECK_H"] + P["POST_H"] / 2), timber_m)) + if wrecked: + # Snapped at 55% height: the bolted stub stands plumb, the top + # half lies on the deck with its beam end, split face up. + stub = P["POST_H"] * 0.55 + posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], stub), + (sx * px, -py, P["DECK_H"] + stub / 2), timber_m)) + posts.append(add_box(f"{name}_post_top_{sx}", + (P["POST"], P["POST"], P["POST_H"] - stub), + (sx * (px - 0.35), -py + 0.42, + P["DECK_H"] + P["POST"] / 2), timber_m, + rot=(math.radians(88), 0, math.radians(20 * sx)))) + else: + posts.append(add_box(f"{name}_post_f_{sx}", (P["POST"], P["POST"], P["POST_H"]), + (sx * px, -py, P["DECK_H"] + P["POST_H"] / 2), timber_m)) + join_group(posts, "posts", root) + + # --- beams. Back beam always up; front beam is the one that tears off. + beams = [add_box(f"{name}_beam_b", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0, py, beam_z), timber_m)] + if not wrecked: + beams.append(add_box(f"{name}_beam_f", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0, -py, beam_z), timber_m)) + join_group(beams, "beams", root) + if wrecked: + # The torn beam, flat on the deck along the front edge, slightly askew. + # Rolled 90° about its own long axis so the 140 mm face lies on the + # boards (a beam at rest lies on its side; on edge it would balance). + join_group([add_box(f"{name}_beam_down", (P["DECK_W"] - 0.1, 0.045, P["BEAM_H"]), + (0.18, -py + 0.15, P["DECK_H"] + 0.03), + timber_m, rot=(math.radians(90), 0, math.radians(6)))], + "beam_down", root) + + # --- rafters: up in the intact roof; hinged down at the back beam in the + # wreck, front ends resting on the deck — the lean-to collapse. + rafters = [] + for i in range(P["N_RAFTERS"]): + x = -hw + 0.35 + i * (P["DECK_W"] - 0.7) / (P["N_RAFTERS"] - 1) + if wrecked: + drop = math.atan2(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3) + L = math.hypot(top + P["BEAM_H"] - (P["DECK_H"] + 0.05), P["DECK_D"] - 0.3) + rafters.append(add_box(f"{name}_rafter_{i}", (0.035, L, 0.07), + (x, py - L / 2 * math.cos(drop), + (top + P["BEAM_H"] + P["DECK_H"] + 0.05) / 2), + timber_m, rot=(-drop, 0, 0))) + else: + rafters.append(add_box(f"{name}_rafter_{i}", (0.035, P["DECK_D"] - 0.15, 0.07), + (x, 0, top + P["BEAM_H"] + 0.035), timber_m)) + join_group(rafters, "rafters_down" if wrecked else "rafters", root) + + # Anchor points: where the front beam meets the post tops — the bolted + # corner an author would genuinely strap to. + return [(-px, -py, top + P["BEAM_H"] * 0.6), (px, -py, top + P["BEAM_H"] * 0.6)] + + +def build_pergola_01(name): + """A timber pergola on a bolted deck — the ladder's missing middle rung. + + SPRINT16 gate 5, proposed in S15 and carried on D's playtester endorsement: + "the ladder jumps 0.45-on-grass straight to 1.00-in-concrete; a 0.65 + bolted-to-deck mid-rung turns fascia-or-nothing into a real decision." + + WHY 0.65, and why one rating for the whole type (the manifest rule my own + S15 entry proposed: new types ship ONE rating per type, so a node-less + JSON anchor can resolve it unambiguously): the post-beam corner is real + 90x90 timber with real M12 bolts through a deck — genuinely better than a + welded frame standing loose on grass (swing_frame 0.45), because the load + path reaches something fixed. And genuinely worse than 300 mm of concrete + (post 1.00), because what it reaches is a DECK: the deck flexes, the + timber cracks before the bolts let go, and 0.65 is that sentence as a + number. Typed `pergola` in its own enum word — calling it a post would + promise concrete the deck cannot deliver (the swing_frame lesson, kept). + """ + root = add_empty(name) + deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8) + timber_m = get_material("Mat_Timber", PAL["timber"], 0.75) + dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8) + + anchors = _pergola(name, root, deck_m, timber_m, dark_m, wrecked=False) + for i, ap in enumerate(anchors): + e = add_empty(f"pergola_anchor_{i + 1:02d}", ap, root, size=0.18) + e["anchor_type"] = "pergola" + e["rating_hint"] = 0.65 + e["collateral"] = "pergola" + e["why"] = ("bolted post-beam corner: the bolts are real, the deck is " + "the footing — better than loose-on-grass, worse than " + "concrete, and the number argues exactly that") + + stamp(root, name, "structure") + root["collateral_key"] = "pergola" + root["collateral_value"] = PERGOLA_COLLATERAL + root["collateral_label"] = "the pergola" + root["breakable"] = True + return root + + +def build_pergola_01_wrecked(name): + """The pergola after a loaded corner won: front beam torn off, front posts + snapped above their bolts, rafters hinged down onto the deck like a + lean-to. The back half — and the deck, and every bolt — still standing, + because a bolted structure fails PARTIALLY, which is the whole 0.65 story. + Same origin, same PERGOLA numbers, one flag different, so intact and + wrecked cannot drift (the _swing_frame pattern).""" + root = add_empty(name) + deck_m = get_material("Mat_Deck", PAL["deck_board"], 0.8) + timber_m = get_material("Mat_Timber", PAL["timber"], 0.75) + dark_m = get_material("Mat_TimberDark", PAL["timber_dark"], 0.8) + + _pergola(name, root, deck_m, timber_m, dark_m, wrecked=True) + # No pergola_anchor_* survives — you cannot re-bolt to a snapped post from + # inside a storm, and an anchor that outlives its structure is the + # free-failure bug in a costume (fascia rule, third application). + stamp(root, name, "structure") + root["broken_variant_of"] = "pergola_01" + root["collateral_key"] = "pergola" + root["collateral_value"] = PERGOLA_COLLATERAL + root["collateral_label"] = "the pergola" + return root + + +# --------------------------------------------------------------------------- +# THE GLASSHOUSE (SPRINT16 gate 5 — the collateral apex) +# --------------------------------------------------------------------------- +GLASSHOUSE = dict( + W=2.6, # x — ridge runs along x + D=1.9, # y + WALL_H=1.70, + RIDGE_H=2.28, + BAR=0.045, # aluminium extrusion section +) + +# The apex, priced as one. Proposal — Lane A owns the number: +# · it must top the carport (180) by a distance, or "apex" is a word: the +# carport is one sheet of Colorbond; this is ~14 panes of horticultural +# glass, and glass does not bend, it all goes at once. 320 is the panes, +# the reglaze labour, and a racked door that never closes right again; +# · it must stay under a six-night week's gross (~$540 in fees alone), so +# one catastrophe is a week worked for nothing, not an automatic BROKE — +# the carport ruling ("the worst thing on the bill and still a week you +# can dig out of"), scaled to the thing that costs a week; +# · unlike the bike (unpriced, nothing can break it) this ships priced ON +# THE SPEC'S OWN ORDER: arc 2's glasshouse yard is the event's home, and +# the price chain has to exist before that yard can be authored against +# it. Until an event lands, nothing bills it — the value is inert data, +# not a lie on an invoice. The tripwire is written in THREADS: the arc-2 +# yard must wire the wreck event before this number reaches a bill. +GLASSHOUSE_COLLATERAL = 320 + + +def _glasshouse_frame(name, root, alu): + """The aluminium skeleton, shared by intact and wreck — the frame is the + part that SURVIVES (aluminium racks, glass shatters), which is why the + wreck stands at full height and reads as emptied, not felled.""" + G = GLASSHOUSE + hw, hd, B = G["W"] / 2, G["D"] / 2, G["BAR"] + parts = [] + # Corner + mid posts. + for x in (-hw, 0, hw): + for sy in (-1, 1): + parts.append(add_box(f"{name}_post_{x:.1f}_{sy}", (B, B, G["WALL_H"]), + (x, sy * hd, G["WALL_H"] / 2), alu)) + # Eave beams (long sides) + end sills. + for sy in (-1, 1): + parts.append(add_box(f"{name}_eave_{sy}", (G["W"], B, B), + (0, sy * hd, G["WALL_H"]), alu)) + for sx in (-1, 1): + parts.append(add_box(f"{name}_end_{sx}", (B, G["D"], B), + (sx * hw, 0, G["WALL_H"]), alu)) + # Gable rafters up to the ridge. + for sy in (-1, 1): + p0 = (sx * hw, sy * hd, G["WALL_H"]) + p1 = (sx * hw, 0, G["RIDGE_H"]) + parts.append(add_tube_between(f"{name}_gable_{sx}_{sy}", p0, p1, + B * 0.45, alu, verts=6)) + # The ridge — the straightest, most anchor-looking bar in any yard, which + # is exactly why it gets a named node and a denial (below, on the empty). + parts.append(add_box(f"{name}_ridge", (G["W"], B, B), (0, 0, G["RIDGE_H"]), alu)) + join_group(parts, "frame", root) + + # Door frame on the -x gable end. + join_group([ + add_box(f"{name}_door_l", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, -0.36, 0.775), alu), + add_box(f"{name}_door_r", (B * 0.7, B * 0.7, 1.55), (-hw - 0.001, 0.36, 0.775), alu), + add_box(f"{name}_door_t", (B * 0.7, 0.76, B * 0.7), (-hw - 0.001, 0, 1.55), alu), + ], "door", root) + + +def build_glasshouse_01(name): + """A backyard glasshouse — the collateral apex, and NOT an anchor. + + SPRINT16 gate 5. The palette's other structures are temptations because + they offer bad steel; this one is a temptation because it offers NO steel + while being ringed in members that look like it — an aluminium ridge bar + at 2.28 m is the most tie-off-shaped object in the game. Every reading of + it must come back "don't": the ridge carries an explicit tie_off denial, + and NO node anywhere on either variant carries a rating_hint, which + e.test pins — nothing about a glasshouse may adopt as an anchor, because + 30 kg of glass in a storm is not a thing you add load to, it is a thing + you keep everything away from. + + The panes read fragile at a glance because they are TRANSLUCENT — the only + see-through walls in the palette — and that read survives export as a + material fact (transparent + alpha), which e.test also pins: a repaint + that turns the glass opaque has silently deleted the tell. + """ + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30) + G = GLASSHOUSE + hw, hd = G["W"] / 2, G["D"] / 2 + + _glasshouse_frame(name, root, alu) + + panes = [] + # Wall panes: long sides + gable ends (leaving the door opening). + for sy in (-1, 1): + panes.append(add_box(f"{name}_wall_{sy}", (G["W"] - 0.06, 0.012, G["WALL_H"] - 0.08), + (0, sy * (hd - 0.012), G["WALL_H"] / 2), glass)) + panes.append(add_box(f"{name}_endwall_e", (0.012, G["D"] - 0.06, G["WALL_H"] - 0.08), + (hw - 0.012, 0, G["WALL_H"] / 2), glass)) + for sy in (-1, 1): # west end: two strips beside the door + panes.append(add_box(f"{name}_endwall_w_{sy}", (0.012, (G["D"] - 0.8) / 2 - 0.05, G["WALL_H"] - 0.08), + (-hw + 0.012, sy * (0.4 + (G["D"] - 0.8) / 4), G["WALL_H"] / 2), glass)) + # Roof panes: two slopes from eave to ridge. + slope = math.atan2(G["RIDGE_H"] - G["WALL_H"], hd) + run = math.hypot(G["RIDGE_H"] - G["WALL_H"], hd) + for sy in (-1, 1): + panes.append(add_box(f"{name}_roof_{sy}", (G["W"] - 0.04, run - 0.03, 0.012), + (0, sy * hd / 2, (G["WALL_H"] + G["RIDGE_H"]) / 2 + 0.01), + glass, rot=(sy * slope, 0, 0))) + # Gable triangles, approximated as narrow strips under the rafters. + for sx in (-1, 1): + panes.append(add_box(f"{name}_gable_pane_{sx}", (0.012, G["D"] * 0.5, (G["RIDGE_H"] - G["WALL_H"]) * 0.8), + (sx * (hw - 0.012), 0, G["WALL_H"] + (G["RIDGE_H"] - G["WALL_H"]) * 0.42), glass)) + join_group(panes, "panes", root) + + # The temptation, denied ON the data. Same mechanism as the swing set's + # crossbar: the most anchor-looking member carries the explicit no. + not_a_tie_off( + add_empty("ridge_anchor", (0, 0, G["RIDGE_H"]), root, size=0.2), + "the ridge bar — the straightest steel-looking line in the yard", + "a 45 mm aluminium extrusion whose whole job is holding glass; load a " + "sail corner on it and you are not testing the bar, you are testing " + "fourteen panes at once") + + stamp(root, name, "structure") + root["collateral_key"] = "glasshouse" + root["collateral_value"] = GLASSHOUSE_COLLATERAL + root["collateral_label"] = "the glasshouse" + root["fragile"] = True + root["pane_count"] = 14 + return root + + +def build_glasshouse_01_wrecked(name): + """The glasshouse after the storm found it. The frame is aluminium — it + stays up, near enough at full height — but the glass is GONE: two cracked + panes still hanging, the rest a glitter field of shards on the ground, + thrown wider than the footprint ever was. A glasshouse doesn't fall, it + empties; a wreck that read as a collapsed building would be lying about + what glass does. Same frame helper as the intact one, so the two cannot + drift.""" + rng = rng_for(name) + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + glass = get_material("Mat_Glass", PAL["glass"], 0.05, metallic=0.0, opacity=0.30) + G = GLASSHOUSE + hw, hd = G["W"] / 2, G["D"] / 2 + + _glasshouse_frame(name, root, alu) + + # Two survivors, one visibly askew in its frame. + join_group([ + add_box(f"{name}_pane_left_a", (0.012, G["D"] * 0.4, G["WALL_H"] * 0.5), + (hw - 0.012, -0.3, G["WALL_H"] * 0.35), glass), + add_box(f"{name}_pane_left_b", (G["W"] * 0.3, 0.012, G["WALL_H"] * 0.45), + (0.4, hd - 0.012, G["WALL_H"] * 0.3), glass, + rot=(0, math.radians(8), 0)), + ], "panes_left", root) + + # The shard field. Deterministic scatter, thrown mostly downwind (+x, the + # storm convention every other wreck in the palette follows) and WIDER + # than the standing footprint — the measured claim e.test pins. + shards = [] + for i in range(26): + a = rng.uniform(0, math.tau) + r = rng.uniform(0.2, 1.0) + x = math.cos(a) * (hw + r * 0.9) * (1.25 if math.cos(a) > 0 else 0.8) + y = math.sin(a) * (hd + r * 0.6) + s = rng.uniform(0.06, 0.22) + shards.append(add_box(f"{name}_shard_{i}", (s, s * rng.uniform(0.4, 0.9), 0.008), + (x, y, 0.006), glass, + rot=(0, 0, rng.uniform(0, math.pi)))) + # A few inside on the beds' footprint too — the glass fell in as well as out. + for i in range(8): + x, y = rng.uniform(-hw * 0.8, hw * 0.8), rng.uniform(-hd * 0.7, hd * 0.7) + s = rng.uniform(0.05, 0.16) + shards.append(add_box(f"{name}_shard_in_{i}", (s, s * 0.7, 0.008), + (x, y, 0.006), glass, rot=(0, 0, rng.uniform(0, math.pi)))) + join_group(shards, "shards", root) + + stamp(root, name, "structure") + root["broken_variant_of"] = "glasshouse_01" + root["collateral_key"] = "glasshouse" + root["collateral_value"] = GLASSHOUSE_COLLATERAL + root["collateral_label"] = "the glasshouse" + return root + + +# --------------------------------------------------------------------------- +# THE POOL KIT (SPRINT16 gate 5 — the compliant ring whose steel all says no) +# --------------------------------------------------------------------------- +POOL_KIT = dict( + RING_W=5.6, # fence ring, x + RING_D=4.2, # fence ring, y + FENCE_H=1.22, # AS 1926.1 minimum is 1.2 m — compliance is the flavour + POOL_W=3.6, + POOL_D=2.2, + COPING_H=0.18, # raised fibreglass lip; keeps every vertex above grade + POST=0.045, # 40 mm aluminium tube posts +) + + +def _pool_fence_posts(): + """Post centres round the ring, deterministic order. Module-level so the + builder and the ASSETS node list can never disagree about the count.""" + P = POOL_KIT + hx, hy = P["RING_W"] / 2, P["RING_D"] / 2 + pts = [] + for x in (-hx, -hx / 2, 0, hx / 2, hx): # long sides, 5 each + for sy in (-1, 1): + pts.append((x, sy * hy)) + for y in (-hy / 2, hy / 2): # short sides, 2 each + for sx in (-1, 1): + pts.append((sx * hx, y)) + return pts # 14 posts + + +def build_pool_kit_01(name): + """A backyard pool inside its compliant fence ring — the yard the palette + could not dress, and the puzzle is the fence. + + SPRINT16 gate 5. Fourteen 40 mm aluminium posts stand in a perfect ring + at exactly the height a low tie line wants, core-mounted, evenly spaced — + they LOOK like the most generous anchor field in the game. Every single + one carries an explicit `tie_off: false`, because silence is not neutral: + `adoptAnchor` reads `rating_hint ?? 1`, so an unrated node a site names + becomes the best steel in the game by omission (the S14 audit lesson, and + the reason the spec says "honest on every one"). A pool fence holds a + child OUT; it does not hold a sail DOWN — it is 1.2 m of child-proofing + on core-drilled tube, and a loaded corner would have the whole panel out + of the ground before the shackle noticed. + + That denial field is the level design: the pool yard's whole puzzle (arc + 2) is that the legal tie-offs are all in the wrong places, and the ring + of honest nos in the middle of the yard is what makes it true. + + UNPRICED, and that is a statement (the bike/jacaranda ruling, not the + glasshouse's spec order): nothing in the sim can bend a pool fence or + crack a coping yet, and billing an event the player cannot see is the lie + the invoice exists to kill. `priced: False` + the reason ride in extras; + price it the day debris can take a panel out. + """ + root = add_empty(name) + alu = get_material("Mat_AluFrame", PAL["alu_frame"], 0.35, metallic=0.7) + conc = get_material("Mat_Concrete", PAL["concrete"], 0.95) + water = get_material("Mat_PoolWater", PAL["pool_water"], 0.08, opacity=0.88) + P = POOL_KIT + hx, hy = P["RING_W"] / 2, P["RING_D"] / 2 + pw, pd = P["POOL_W"] / 2, P["POOL_D"] / 2 + + # --- the pool: raised coping ring + water just below the lip ----------- + join_group([ + add_box(f"{name}_coping_n", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]), + (0, pd + 0.075, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_s", (P["POOL_W"] + 0.3, 0.15, P["COPING_H"]), + (0, -pd - 0.075, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_e", (0.15, P["POOL_D"], P["COPING_H"]), + (pw + 0.075, 0, P["COPING_H"] / 2), conc), + add_box(f"{name}_coping_w", (0.15, P["POOL_D"], P["COPING_H"]), + (-pw - 0.075, 0, P["COPING_H"] / 2), conc), + ], "pool_shell", root) + join_group([add_box(f"{name}_water", (P["POOL_W"], P["POOL_D"], 0.02), + (0, 0, P["COPING_H"] - 0.045), water)], "pool_water", root) + + # --- the fence: rails + pickets, joined; POSTS as individual named nodes + # so a site (or the editor's node list, if it ever offers per-node + # anchors) meets the denial ON the node it would name. RULE 1's regex + # only guards *_anchor names; these are guarded by their own pin in + # e.test — same rule, wider net. + gate_span = (0.25, 1.15) # gate opening on the south run, x range + rails = [] + for sy in (-1, 1): + for z in (0.12, P["FENCE_H"] - 0.03): + if sy < 0: # south run has the gate opening + rails.append(add_box(f"{name}_rail_s{z:.2f}_a", (gate_span[0] + hx, 0.04, 0.04), + ((gate_span[0] - hx) / 2, -hy, z), alu)) + rails.append(add_box(f"{name}_rail_s{z:.2f}_b", (hx - gate_span[1], 0.04, 0.04), + ((gate_span[1] + hx) / 2, -hy, z), alu)) + else: + rails.append(add_box(f"{name}_rail_n{z:.2f}", (P["RING_W"], 0.04, 0.04), + (0, hy, z), alu)) + for sx in (-1, 1): + for z in (0.12, P["FENCE_H"] - 0.03): + rails.append(add_box(f"{name}_rail_{'e' if sx > 0 else 'w'}{z:.2f}", + (0.04, P["RING_D"], 0.04), (sx * hx, 0, z), alu)) + # Pickets every ~0.1 m — the non-climbable read. + for sy in (-1, 1): + n = int(P["RING_W"] / 0.105) + for i in range(1, n): + x = -hx + i * P["RING_W"] / n + if sy < 0 and gate_span[0] < x < gate_span[1]: + continue + rails.append(add_box(f"{name}_pk_y{sy}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14), + (x, sy * hy, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu)) + for sx in (-1, 1): + n = int(P["RING_D"] / 0.105) + for i in range(1, n): + y = -hy + i * P["RING_D"] / n + rails.append(add_box(f"{name}_pk_x{sx}_{i}", (0.014, 0.014, P["FENCE_H"] - 0.14), + (sx * hx, y, (P["FENCE_H"] - 0.14) / 2 + 0.1), alu)) + join_group(rails, "fence_rails", root) + + # The posts — fourteen honest nos, each its own node. + for i, (x, y) in enumerate(_pool_fence_posts()): + post = join_group([add_cyl(f"{name}_postmesh_{i}", P["POST"] / 2, P["FENCE_H"], + (x, y, P["FENCE_H"] / 2), alu, verts=8)], + f"fence_post_{i + 1:02d}", root) + not_a_tie_off( + post, "pool fence post — compliance steel, not rigging steel", + "40 mm core-mounted aluminium tube holding a child-proof panel; " + "it resists a climbing toddler, not a loaded sail corner — the " + "ring LOOKS like fourteen perfect anchors and every one says no") + + # The gate: self-closing, opens outward, latch high — the compliance + # tells, in geometry and in data. + gx0, gx1 = gate_span + gate = join_group([ + add_box(f"{name}_gate_frame_l", (0.03, 0.03, P["FENCE_H"] - 0.1), + (gx0 + 0.03, -hy - 0.06, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu, + rot=(0, 0, math.radians(-14))), + add_box(f"{name}_gate_frame_r", (0.03, 0.03, P["FENCE_H"] - 0.1), + (gx1 - 0.15, -hy - 0.28, (P["FENCE_H"] - 0.1) / 2 + 0.05), alu, + rot=(0, 0, math.radians(-14))), + add_box(f"{name}_gate_top", (gx1 - gx0 - 0.1, 0.03, 0.03), + ((gx0 + gx1) / 2 - 0.06, -hy - 0.17, P["FENCE_H"] - 0.08), alu, + rot=(0, 0, math.radians(-14))), + *[add_box(f"{name}_gate_pk_{i}", (0.014, 0.014, P["FENCE_H"] - 0.22), + (gx0 + 0.1 + i * (gx1 - gx0 - 0.2) / 7, + -hy - 0.08 - i * 0.028, (P["FENCE_H"] - 0.22) / 2 + 0.08), alu) + for i in range(8)], + ], "pool_gate", root) + gate["self_closing"] = True + gate["opens"] = "outward, ajar — someone left it swinging, which is the tell" + gate["latch_height_m"] = 1.5 + + stamp(root, name, "structure") + root["priced"] = False + root["unpriced_why"] = ("nothing in the sim can bend a pool fence or crack " + "a coping yet — billing an unseen event is the lie " + "the invoice exists to kill (the bike ruling); " + "price it when debris can take a panel out") + root["compliance"] = "AS 1926.1 flavour: 1.2 m, non-climbable, self-closing outward gate" + return root + + def build_shed_01(name): """Colorbond garden shed, skillion roof. Spare hardware lives in here.""" root = add_empty(name) @@ -2969,6 +3512,38 @@ ASSETS = [ dict(name="fence_panel_snapped", fn=build_fence_panel_snapped, dims=((2.38, 2.60), (0.03, 1.05), (1.70, 1.90)), nodes=["palings", "rails", "debris_palings"]), + # SPRINT16 gate 5 — the next temptations. The pergola is the ladder's + # honest middle rung (D-endorsed, S15 THREADS): TALLER than the swing set + # it out-rates, and its z floor is above the swing's 2.15 ceiling on + # purpose — if it ever measures swing-height, someone shrank the rung. + dict(name="pergola_01", fn=build_pergola_01, + dims=((3.1, 3.4), (2.4, 2.8), (2.55, 2.85)), + nodes=["deck", "posts", "beams", "rafters", + "pergola_anchor_01", "pergola_anchor_02"]), + # The wreck keeps the deck footprint but spills the torn beam past the + # edge (x floor ABOVE the intact deck's 3.2) and its roof line is the + # rafters hinged down — near intact height at the surviving back beam. + dict(name="pergola_01_wrecked", fn=build_pergola_01_wrecked, + dims=((3.2, 3.6), (2.4, 2.8), (2.55, 2.85)), + nodes=["deck", "posts", "beams", "beam_down", "rafters_down"]), + # The collateral apex. Narrow bands on z because the ridge IS the claim: + # a glasshouse that grew would stop reading as the small fragile thing. + dict(name="glasshouse_01", fn=build_glasshouse_01, + dims=((2.5, 2.8), (1.8, 2.1), (2.15, 2.40)), + nodes=["frame", "panes", "door", "ridge_anchor"]), + # Emptied, not felled: SAME height band as the intact one (the frame is + # aluminium and stands), but the shard field throws the plan dims WIDE — + # the x/y floors sit above the intact ceilings, which is the "it emptied + # outward" claim as a number. + dict(name="glasshouse_01_wrecked", fn=build_glasshouse_01_wrecked, + dims=((3.4, 5.4), (2.6, 3.8), (2.15, 2.40)), + nodes=["frame", "door", "panes_left", "shards"]), + # The compliance ring. z is pinned tight around 1.2 m because the height + # IS the flavour (AS 1926.1); y reaches past the ring for the ajar gate. + dict(name="pool_kit_01", fn=build_pool_kit_01, + dims=((5.4, 5.9), (4.1, 4.8), (1.12, 1.32)), + nodes=["pool_shell", "pool_water", "fence_rails", "pool_gate", + "fence_post_01", "fence_post_14"]), ] diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 3f17747..5e12d2a 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/web/world/js/anchor_ratings.gen.js b/web/world/js/anchor_ratings.gen.js index bcaec8a..cc9f21f 100644 --- a/web/world/js/anchor_ratings.gen.js +++ b/web/world/js/anchor_ratings.gen.js @@ -82,6 +82,14 @@ export const FACTORY_ANCHOR_RATINGS = Object.freeze({ "house_yardside/fascia_anchor_03" ] }, + "pergola": { + "collateral": "pergola", + "rating_hint": 0.65, + "sources": [ + "pergola_01/pergola_anchor_01", + "pergola_01/pergola_anchor_02" + ] + }, "post": { "rating_hint": 1.0, "sources": [ diff --git a/web/world/models/glasshouse_01_v1.glb b/web/world/models/glasshouse_01_v1.glb new file mode 100644 index 0000000..706149d Binary files /dev/null and b/web/world/models/glasshouse_01_v1.glb differ diff --git a/web/world/models/glasshouse_01_wrecked_v1.glb b/web/world/models/glasshouse_01_wrecked_v1.glb new file mode 100644 index 0000000..9306a62 Binary files /dev/null and b/web/world/models/glasshouse_01_wrecked_v1.glb differ diff --git a/web/world/models/pergola_01_v1.glb b/web/world/models/pergola_01_v1.glb new file mode 100644 index 0000000..1e6d29d Binary files /dev/null and b/web/world/models/pergola_01_v1.glb differ diff --git a/web/world/models/pergola_01_wrecked_v1.glb b/web/world/models/pergola_01_wrecked_v1.glb new file mode 100644 index 0000000..eed4ff6 Binary files /dev/null and b/web/world/models/pergola_01_wrecked_v1.glb differ diff --git a/web/world/models/pool_kit_01_v1.glb b/web/world/models/pool_kit_01_v1.glb new file mode 100644 index 0000000..1d08869 Binary files /dev/null and b/web/world/models/pool_kit_01_v1.glb differ