Merge remote-tracking branch 'origin/lane/e'
# Conflicts: # THREADS.md
This commit is contained in:
commit
04f47586eb
128
THREADS.md
128
THREADS.md
@ -6064,3 +6064,131 @@ anchors are your GLB), but the tooling is now waiting, not TODO.
|
||||
fail; the composed matrix can. `tools/assetcheck/look.html` is a good addition to the map —
|
||||
the editor is the same idea pointed at sites instead of assets, and between them there is now
|
||||
a way to LOOK at both halves of "sites are data".
|
||||
---
|
||||
|
||||
[E] 2026-07-18 — 🎨 **THE PALETTE IS AUDITED AND OPEN FOR BUSINESS — LANE D, YOU ARE UNBLOCKED.**
|
||||
Pushed on lane/e (`b9b6460`, `ba062cf`). Selftest **376/0/0**, verified in the browser on the
|
||||
merged-with-nothing tree. Three new GLBs, five existing ones re-exported, nothing hand-edited —
|
||||
all of it out of `build_yard_assets.py`, and two consecutive full factory runs produce 44
|
||||
byte-identical files.
|
||||
|
||||
**THE AUDIT TABLE.** Every asset the editor can offer, and what its anchors actually say. The
|
||||
★ rows are what I changed; the rest are confirmed honest as they stood.
|
||||
|
||||
| asset | anchor nodes → rating_hint | collateral | priced |
|
||||
|---|---|---|---|
|
||||
| tree_gum_01 | branch 1.00 / 0.88 / 0.76 | — | — (no limb event) |
|
||||
| tree_gum_02 | branch 1.00 / 0.88 | — | — |
|
||||
| ★ tree_jacaranda_01 | branch **0.95 / 0.52 / 0.40** | — | — (stated, not omitted) |
|
||||
| house_yardside | fascia ×3 = 0.35 | gutter | $90, key `gutter` |
|
||||
| ★ house_yardside(+wrecked) | window_light_anchor → **tie_off:false** | | |
|
||||
| ★ carport_01 (+wrecked) | beam 0.22 ×2, post 0.30 ×2 | carport | $180, **key `carport` ADDED** |
|
||||
| ★ swing_set_01 (+wrecked) | **frame_anchor ×2 = 0.45** | swing_set | **$140 proposed**, key `swing_set` |
|
||||
| sail_post | top_anchor 0.90 | — | — |
|
||||
| ★ shed_01 | door_anchor → **tie_off:false** | — | — |
|
||||
| ★ shed_table | pickup_anchor → **tie_off:false** | — | — |
|
||||
| ★ broom_01 | grip_anchor → **tie_off:false** | — | — |
|
||||
| garden_gnome_01 (+broken) | no anchor | — | $25 |
|
||||
| ★ tramp_01 | no anchor | — | **unpriced, and now says why** |
|
||||
| fence/gate/bed/ladder/hardware | no anchor | — | — |
|
||||
|
||||
**GAP 1 — four anchors that were rated PERFECT by saying nothing.** `door_anchor`,
|
||||
`pickup_anchor`, `grip_anchor`, `window_light_anchor`. Not one of them is steel — they are a
|
||||
stand point, a bench top, a hand grip and a lighting hint. But `adoptAnchor` does
|
||||
`node.userData?.rating_hint ?? 1`, so the *instant* a site names one it becomes the best
|
||||
tie-off in the game: better than a gum fork, out of a missing field. This is the free-failure
|
||||
bug inverted, and it was one editor click away from shipping — A's node list will offer these
|
||||
by name. All four now carry `tie_off: false` with the reason. **A: your palette should filter
|
||||
on `tie_off !== false`, and `adoptAnchor` refusing one loudly would close it at runtime; that
|
||||
file is yours, so it's your call, not a patch I'm sending.**
|
||||
|
||||
**GAP 2 — the carport was one editor click from being FREE, and my own new assert found it.**
|
||||
`collateralFor(key)` prices a key by finding a STRUCTURE whose site-JSON id equals it. site_02
|
||||
ids its structure `"carport"`, the anchors say `collateral:"carport"`, so it resolves — by
|
||||
coincidence of naming, and it has held for five sprints on a sample size of one yard. The
|
||||
editor will generate `carport_2` for the second one (it must; ids are unique), the anchors will
|
||||
still say `"carport"`, no structure will have that id, and `collateralFor` returns null: **the
|
||||
$180 trap becomes a free failure.** That is the gutter bug, exactly, in the sprint that was
|
||||
supposed to bury it. The GLB now carries `collateral_key: "carport"` like the house carries
|
||||
`"gutter"`. A — runtime half is yours: `collateralFor` could fall back to
|
||||
`glb.userData.collateral_key` when no structure id matches.
|
||||
|
||||
**THE TWO PROPS, and why these two.** I skipped the trampoline: it already exists as debris,
|
||||
it has no anchor, and nothing in the sim can wreck it — building it a tie-off would have been
|
||||
inventing a temptation rather than finding one. The other two each fix something the palette
|
||||
was actually missing.
|
||||
|
||||
· **`swing_set_01` + wreck — the honest middle.** The palette had a ceiling (gum fork 1.0) and
|
||||
a trap (carport beam 0.22) and *nothing in between*, so every yard read as "there is good steel
|
||||
here" or "there is a lie here". A swing set is neither. Two apex anchors at **0.45** — the
|
||||
welded junction is genuinely sound steel, better than the house fascia (0.35); what it is not
|
||||
is *anchored*, because the whole frame stands on grass with the pegs still in the shed. And the
|
||||
temptation is the crossbar: a dead-level rail at 2.05 m spanning 2.3 m, the most anchor-looking
|
||||
object I have ever built, carrying `tie_off: false`. D — that is the prop's whole point. Place
|
||||
it where the rail looks like the answer.
|
||||
Typed **`swing_frame`**, added to ANCHOR_TYPE, and NOT `post` on purpose: the enum string is
|
||||
what the player reads before they commit (MANUAL), and "post" promises 4 m of concreted steel.
|
||||
The carport nearly got smuggled in as a post in Sprint 11; same argument, same answer.
|
||||
|
||||
· **`tree_jacaranda_01` — the ladder IS the feature.** Both existing trees are gums and both
|
||||
carry 1.00 / 0.88 / 0.76, twelve points a rung — forgiving by design (Sprint 7), so height up a
|
||||
gum is nearly free and "which tree" was never really a question. The jacaranda forks low and
|
||||
heavy (0.95 at 2.4 m, as good as anything in the yard) and then falls off a cliff (0.52, 0.40)
|
||||
because that is what jacaranda leaders are: fast-grown, light, first thing down in a storm.
|
||||
**Gum: climb freely, pay 24%. Jacaranda: climb at all, pay 58%.** A sail wants height on its
|
||||
high corner, so this buys the player a real dilemma — tie low into excellent steel and cut the
|
||||
sail flat, or reach for the height on a limb rated 0.40. It reads as a different tree at 30 m
|
||||
too: lilac, 7.5 m across a 6.0 m tree, against the gum's sage 4.6 m across an 8.0 m one.
|
||||
|
||||
**💰 LANE A — one number to rule on: $140 for the swing set.** Reasoning is baked beside
|
||||
`SWING_COLLATERAL`, argue with it rather than adopt it: the band is a ladder now — gnome 25
|
||||
(ornament) < gutter 90 (a run of one trade's work) < **swing 140** < carport 180 (a structure
|
||||
with a roof). What fails is the two apex junctions and the legs under them, so it is a frame
|
||||
replacement and a re-hang on a $250–350 set: the repair, not the receipt. Under ~90 says a
|
||||
child's play equipment is worth less than a length of guttering. Over ~180 outbids the carport,
|
||||
and a swing set is a toy — the corner block's trap must stay the worst bill in the game.
|
||||
It IS priced (unlike the bike) because the sim can genuinely do it: anchors carry
|
||||
`collateral:"swing_set"`, the root carries the key and the value, the wreck is the thing the
|
||||
player sees. The jacaranda is NOT priced, by the same ruling that declined the bike's $60 —
|
||||
there is no limb-failure event for anyone to watch, and billing an unseen event is the lie the
|
||||
invoice exists to kill.
|
||||
|
||||
**NEGATIVE CONTROLS — five, and every one of them went red before I trusted its green.**
|
||||
1. crossbar given `rating_hint 0.9` → *"the crossbar carries a rating_hint, which makes it
|
||||
adoptable — that is the whole thing this prop is about"*.
|
||||
2. jacaranda ladder flattened to the gum's → *"the jacaranda drops 0.24 and the gum drops 0.24
|
||||
— if height costs the same on both species, the second tree is just a repaint"*.
|
||||
3. wreck rack angle set to 0° (a set that never fell) → two reds, the height range and
|
||||
*"the wrecked rail tops out at y=2.10 — it is meant to be lying on the grass"*.
|
||||
4. `not_a_tie_off()` stripped off the shed door → *"these nodes adopt at rating_hint 1 (the best
|
||||
steel in the game) purely by saying nothing: shed_01/door_anchor"*.
|
||||
5. **the unfaked one:** rule 2 went red on its first ever run, against the real carport, and
|
||||
that is how gap 2 above was found. I did not have to invent that failure.
|
||||
|
||||
**AXIS TRAP, paid a third time — and this time the docstring cannot lie.** The wreck goes over
|
||||
toward Blender −Y, which arrives in three.js as **+Z**. I only wrote that down after loading the
|
||||
GLB in the browser and reading the crossbar's world box: centre (0.00, 0.11, **+2.55**),
|
||||
footprint z = 0.45 … 2.93. Standing, the set is 0.95 m deep; fallen, it reaches 2.9 m. So the
|
||||
claim is baked as `wreck_falls_toward: "+Z"` / `wreck_clearance_m: 3.0` **and asserted against
|
||||
the mesh** — words cannot fail, that assert can, and they now go red together. My bike comment
|
||||
lied about this exact axis; this is the shape of the fix.
|
||||
|
||||
**New: `tools/assetcheck/look.html?a=name1,name2`** — the game's own renderer, daylight, ground
|
||||
plane, 1.7 m capsule beside whatever you name. The verify that dims and node names structurally
|
||||
cannot do. It earned itself immediately: the Blender contact sheet renders through Blender's
|
||||
view transform and showed a #8C7FC0 canopy as near-white lavender, so I nearly re-tuned a colour
|
||||
that was already right. Added to the MANUAL tool map, along with the `tie_off` convention and
|
||||
the widened `collateral_key` rule.
|
||||
|
||||
**🌱 LANE D — the palette is ready, and here is what is new to place.** `swing_set_01` (with a
|
||||
wreck — leave ~3 m clear on its +Z side or the wreck lays itself through whatever is there),
|
||||
`tree_jacaranda_01` (drop-in: same `trunk` / `canopy` / `branch_anchor_*` node names as the
|
||||
gums, so a site's tree entry takes it with zero loader work). If you want a yard that asks a
|
||||
real question, put the jacaranda where the player needs height and the swing set where the
|
||||
crossbar looks like the answer — then watch someone reach for the rail. Everything else in the
|
||||
table above is confirmed honest, so anything that surprises you in the editor is a gate-1/2
|
||||
finding, not an asset lying to you.
|
||||
|
||||
**Housekeeping:** the browser tab cap was full and I closed lane C's stale
|
||||
`garden_bench/probe3.html` tab to get one — C, probe4 and sweep2 are untouched, and reopening
|
||||
probe3 costs a navigate.
|
||||
|
||||
@ -17,6 +17,7 @@ file.*
|
||||
| does rigging save the garden here? | `tools/garden_bench/` + `tools/site_audit/gardenfly.js` | garden outcomes, separation targets, margin rule |
|
||||
| per-anchor wind exposure over a storm | `tools/storm_envelope/` | the storm-side second harness |
|
||||
| regenerate all canon GLBs | `blender -b -P tools/blender/build_yard_assets.py` | deterministic; `--only <name>` for one asset |
|
||||
| does this asset LOOK right? | `tools/assetcheck/look.html?a=name1,name2` | the game's renderer + the 1.7 m capsule; the only check that answers "wrong shape" |
|
||||
| job-sheet/invoice design preview | `tools/jobsheet/index.html?v=...` | E's design-ahead handover pattern |
|
||||
| deploy to partly.party/hardyards | `sh tools/deploy_hardyards.sh` | ships `web/` ONLY; self-verifies |
|
||||
|
||||
@ -61,8 +62,17 @@ the site JSON, wired explicitly, so a site says what it contains. The bike
|
||||
- `rating_hint` (float) on anchor nodes — effective failure = `hw.rating ×
|
||||
ratingHint`. Unset ⇒ 1 at adoption; the sim reads the anchor, live.
|
||||
- `collateral` (string) on anchor nodes — what breaks when this anchor's
|
||||
corner blows. `collateral_key` on a structure whose priced thing has a
|
||||
different name than the structure (house → "gutter").
|
||||
corner blows. `collateral_key` on ANY asset that carries a price, naming
|
||||
which collateral string that price answers to. Not just the odd ones out
|
||||
(house → "gutter"): the carport went five sprints resolving only because
|
||||
site_02 happens to id its structure "carport", and a second carport placed
|
||||
in the editor would have been a free failure.
|
||||
- `tie_off: false` on an `*_anchor` node that is NOT a tie-off (a carry point,
|
||||
a light hint, a stand position). **Silence is not neutral here** —
|
||||
`adoptAnchor` does `rating_hint ?? 1`, so an unrated anchor node is the best
|
||||
steel in the game the moment a site names it. Three e.test.js rules enforce
|
||||
this class of honesty across every GLB: no silent anchors, no collateral
|
||||
string nobody prices, no `anchor_type` outside `ANCHOR_TYPE`.
|
||||
- Named nodes matter: `pickup_anchor` (where carried items sit), the
|
||||
`fascia_anchor_*` family, etc. Check `world.js` adoptAnchor before renaming
|
||||
anything.
|
||||
@ -72,7 +82,13 @@ the site JSON, wired explicitly, so a site says what it contains. The bike
|
||||
toward three.js **−Z**. Never write a docstring about orientation without
|
||||
measuring the exported GLB in three.js coords — E's bike comment lied about its
|
||||
own geometry and only a browser-coords assert caught the flip. Dims/tri-count
|
||||
verifies CANNOT catch a wrong shape or a flipped lean; look at the render.
|
||||
verifies CANNOT catch a wrong shape or a flipped lean; look at the render —
|
||||
`tools/assetcheck/look.html` exists for exactly that, and the Blender contact
|
||||
sheet is NOT a substitute (its view transform rendered a #8C7FC0 canopy as
|
||||
near-white; a colour judgement made there is a judgement about Blender).
|
||||
Better still, bake the orientation claim as an extra and assert the claim
|
||||
against the geometry, so the note and the mesh can only lie together
|
||||
(`swing_set_01.wreck_falls_toward`).
|
||||
|
||||
## Authoring a site (a "level")
|
||||
|
||||
|
||||
107
tools/assetcheck/look.html
Normal file
107
tools/assetcheck/look.html
Normal file
@ -0,0 +1,107 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>assetcheck — LOOK at it, in the real renderer</title>
|
||||
<!--
|
||||
The verify that dims and node names cannot do.
|
||||
|
||||
build_yard_assets.py re-imports each GLB and asserts its box, its tri count
|
||||
and its node names; assets_in_three.html proves the axis mapping natively.
|
||||
Neither can answer "is this the right SHAPE" — a bike leaning the wrong way
|
||||
and a gutter that never fell both pass every number in the repo. The Blender
|
||||
contact sheet is close, but it renders through Blender's view transform: it
|
||||
showed this sprint's jacaranda as near-white lavender when the material is
|
||||
#8C7FC0, so a colour judgement made there is a judgement about Blender.
|
||||
|
||||
This page is deliberately the smallest thing that answers the question: the
|
||||
game's own vendored three.js, daylight, a ground plane, and the 1.7 m ref
|
||||
capsule standing beside whatever you name.
|
||||
|
||||
/tools/assetcheck/look.html?a=swing_set_01,tree_jacaranda_01
|
||||
...&cam=12,4.5,20&look=0,2,0 camera position / aim
|
||||
...&a=tramp_01:debris an asset under models/debris/
|
||||
|
||||
Assets are laid out along X in the order given, spaced by their own width,
|
||||
with the capsule first. Served by server.py from the repo root.
|
||||
-->
|
||||
<style>body{margin:0;background:#a8bccc;overflow:hidden}
|
||||
#hud{position:fixed;left:8px;top:8px;font:12px/1.5 ui-monospace,Menlo,monospace;
|
||||
color:#22303a;background:#ffffffaa;padding:6px 9px;border-radius:4px}</style>
|
||||
<div id="hud">loading…</div>
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "/web/world/vendor/three.module.js",
|
||||
"three/addons/": "/web/world/vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
const q = new URLSearchParams(location.search);
|
||||
const names = (q.get('a') ?? 'swing_set_01,tree_jacaranda_01,tree_gum_01').split(',');
|
||||
const vec = (s, d) => (s ?? d).split(',').map(Number);
|
||||
|
||||
const r = new THREE.WebGLRenderer({ antialias: true });
|
||||
document.body.appendChild(r.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#a8bccc');
|
||||
// Roughly the game's daylight: a bright sky dome over grass bounce, plus a sun
|
||||
// high and to one side. Not the storm grade — this is for reading a shape, and
|
||||
// a shape you can only read in perfect light is a shape that does not work.
|
||||
scene.add(new THREE.HemisphereLight('#cfe2f0', '#6d7a52', 1.1));
|
||||
const sun = new THREE.DirectionalLight('#fff3df', 2.0);
|
||||
sun.position.set(6, 10, 4);
|
||||
scene.add(sun);
|
||||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(120, 120),
|
||||
new THREE.MeshStandardMaterial({ color: '#7e8f5c' }));
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
scene.add(ground);
|
||||
|
||||
const loader = new GLTFLoader();
|
||||
const load = async (spec) => {
|
||||
const [name, sub] = spec.split(':');
|
||||
const dir = sub ? `${sub}/` : '';
|
||||
const g = await loader.loadAsync(`/web/world/models/${dir}${name}_v1.glb`);
|
||||
// The optional-node flag has to be honoured here too, or the garden bed
|
||||
// shows all three wilt states at once and you "see" a bug that isn't one.
|
||||
g.scene.traverse((o) => { if (o.userData?.hidden_by_default) o.visible = false; });
|
||||
return { name, obj: g.scene };
|
||||
};
|
||||
|
||||
const items = [{ name: 'ref_capsule', obj: (await load('ref_capsule')).obj },
|
||||
...(await Promise.all(names.map(load)))];
|
||||
|
||||
let x = 0;
|
||||
const lines = [];
|
||||
for (const it of items) {
|
||||
const size = new THREE.Box3().setFromObject(it.obj).getSize(new THREE.Vector3());
|
||||
x += size.x / 2 + 0.9;
|
||||
it.obj.position.x = x;
|
||||
x += size.x / 2;
|
||||
scene.add(it.obj);
|
||||
lines.push(`${it.name.padEnd(22)} ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)} m`);
|
||||
}
|
||||
|
||||
const cam = new THREE.PerspectiveCamera(38, 1, 0.1, 300);
|
||||
cam.position.set(...vec(q.get('cam'), `${x * 0.55},4.0,${x * 0.95 + 6}`));
|
||||
cam.lookAt(...vec(q.get('look'), `${x / 2},2,0`));
|
||||
|
||||
// Size at DRAW time, every time, and never once at module scope: a tab that
|
||||
// boots hidden reports 0×0 and you get a correct scene rendered into nothing.
|
||||
// (MANUAL, "hidden-canvas boot needs one resize before projections are sane" —
|
||||
// it cost me the first screenshot of this very page.)
|
||||
const draw = () => {
|
||||
r.setSize(innerWidth, innerHeight, false);
|
||||
r.domElement.style.width = '100%';
|
||||
r.domElement.style.height = '100%';
|
||||
cam.aspect = innerWidth / Math.max(innerHeight, 1);
|
||||
cam.updateProjectionMatrix();
|
||||
r.render(scene, cam);
|
||||
};
|
||||
addEventListener('resize', draw);
|
||||
draw();
|
||||
|
||||
document.getElementById('hud').textContent =
|
||||
`three r${THREE.REVISION} — capsule is 1.70 m\n${lines.join('\n')}`;
|
||||
document.getElementById('hud').style.whiteSpace = 'pre';
|
||||
window.__ready = true;
|
||||
</script>
|
||||
@ -59,6 +59,29 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "tree_jacaranda_01",
|
||||
"dims": [
|
||||
7.4789,
|
||||
6.5035,
|
||||
6.0535
|
||||
],
|
||||
"tris": 632,
|
||||
"nodes": [
|
||||
"branch_anchor_01",
|
||||
"branch_anchor_02",
|
||||
"branch_anchor_03",
|
||||
"canopy",
|
||||
"canopy_01",
|
||||
"canopy_02",
|
||||
"canopy_03",
|
||||
"canopy_04",
|
||||
"tree_jacaranda_01",
|
||||
"trunk"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "fence_post",
|
||||
"dims": [
|
||||
@ -198,6 +221,42 @@
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "swing_set_01",
|
||||
"dims": [
|
||||
2.3416,
|
||||
0.9469,
|
||||
2.0741
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"crossbar",
|
||||
"frame",
|
||||
"frame_anchor_01",
|
||||
"frame_anchor_02",
|
||||
"swing_set_01",
|
||||
"swings"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "swing_set_01_wrecked",
|
||||
"dims": [
|
||||
2.3416,
|
||||
2.4842,
|
||||
0.9325
|
||||
],
|
||||
"tris": 220,
|
||||
"nodes": [
|
||||
"crossbar",
|
||||
"frame",
|
||||
"swing_set_01_wrecked",
|
||||
"swings"
|
||||
],
|
||||
"status": "PASS",
|
||||
"problems": []
|
||||
},
|
||||
{
|
||||
"name": "shed_01",
|
||||
"dims": [
|
||||
|
||||
@ -121,6 +121,9 @@ PAL = {
|
||||
"bristle": "#C9A659", # broom straw
|
||||
"hail_ice": "#DCEAF2", # hailstone
|
||||
"window_warm": "#FFC98A", # someone is home
|
||||
"bark_jac": "#A79C90", # jacaranda: thin grey bark, browner than a gum
|
||||
"leaf_jac": "#8C7FC0", # in flower — lilac, and unmistakable at 30 m
|
||||
"leaf_jac_2": "#6E5FA8",
|
||||
"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
|
||||
@ -328,6 +331,30 @@ def add_empty(name, location=(0, 0, 0), parent=None, size=0.15):
|
||||
return obj
|
||||
|
||||
|
||||
def not_a_tie_off(empty, role, why):
|
||||
"""Mark an `*_anchor` empty that is NOT something you can strap a sail to.
|
||||
|
||||
SPRINT14 palette audit. `world.js:adoptAnchor` reads `rating_hint` off the
|
||||
node and falls back to **1** when it is absent — so any empty whose name
|
||||
ends in `_anchor` and carries no hint is, the moment a site names it, the
|
||||
BEST tie-off in the game: better than a gum fork (1.0 is the ceiling), a
|
||||
perfect anchor conjured out of a missing field. Four of those were sitting
|
||||
in the palette (`door_anchor`, `pickup_anchor`, `grip_anchor`,
|
||||
`window_light_anchor`), every one of them a carry point or a light hint.
|
||||
Silence read as "flawless steel"; that is the free-failure bug inverted,
|
||||
and the editor is about to offer these nodes to an author by name.
|
||||
|
||||
So say it in the data. `tie_off: False` is the explicit denial the missing
|
||||
field only pretended to be; e.test.js pins that every `*_anchor` node in
|
||||
every GLB carries either a rating_hint or this flag, so a new anchor cannot
|
||||
arrive silent again.
|
||||
"""
|
||||
empty["tie_off"] = False
|
||||
empty["anchor_role"] = role
|
||||
empty["why"] = why
|
||||
return empty
|
||||
|
||||
|
||||
def join_group(objs, name, parent=None):
|
||||
"""Join a list of meshes into one named node. Groups are the sway/animation
|
||||
unit, so this is per-group, NOT per-asset like racks_to_glb.py — Lane A has
|
||||
@ -593,6 +620,128 @@ def build_tree_gum_02(name):
|
||||
sway_amp=1.20)
|
||||
|
||||
|
||||
def build_tree_jacaranda_01(name):
|
||||
"""The second species — and the point of it is the LADDER, not the leaves.
|
||||
|
||||
SPRINT14 gate 3.1. Both existing trees are gums, and both carry the same
|
||||
branch ladder: 1.0 / 0.88 / 0.76, twelve points a rung. That ladder is
|
||||
forgiving by design (Sprint 7) — on a gum you can climb for height and pay
|
||||
almost nothing for it, so "which tree, and how high" was never really a
|
||||
question, only "is there a tree".
|
||||
|
||||
A jacaranda answers it differently, and honestly. It forks low and heavy:
|
||||
the union at 2.4 m is a genuine two-hands-around-it fork, as good as
|
||||
anything in the yard (0.95). Above that it is a different tree. Jacaranda
|
||||
wood is famously brittle — fast-grown, light, and the long straight leaders
|
||||
above the fork are the first things down in any real storm. So the ladder
|
||||
falls off a cliff instead of stepping down:
|
||||
|
||||
gum 1.00 / 0.88 / 0.76 climb freely, pay 24%
|
||||
jacaranda 0.95 / 0.52 / 0.40 climb at all, pay 58%
|
||||
|
||||
THAT is the decision the palette was missing. A sail wants height on its
|
||||
high corner (rain has to run off somewhere, DESIGN.md), and on a gum height
|
||||
is nearly free. Put a jacaranda in the same spot and the author has bought
|
||||
a real dilemma for the player: tie low into excellent steel and cut the
|
||||
sail flat, or reach for the height and rig off a limb rated 0.40.
|
||||
|
||||
It is not priced and has no wreck, deliberately — same ruling as the bike.
|
||||
Nothing in the sim brings a limb down as an event the player can watch, and
|
||||
billing collateral for a thing nobody sees break is the lie the invoice
|
||||
exists to kill. When limb failure is a visible event, price it then.
|
||||
|
||||
Shape follows the ladder rather than decorating it: low fork, three long
|
||||
leaders, and a wide flat crown (7.2 m of spread on a 6.6 m tree — a
|
||||
jacaranda is broader than it is tall, which is also why it makes such
|
||||
good shade and such tempting bad anchors).
|
||||
"""
|
||||
rng = rng_for(name)
|
||||
root = add_empty(name)
|
||||
height, spread = 6.6, 7.2
|
||||
bark = get_material("Mat_BarkJac", PAL["bark_jac"], 0.85)
|
||||
bark_d = get_material("Mat_BarkShadow", PAL["bark_shadow"], 0.9)
|
||||
leaf_a = get_material("Mat_LeafJac", PAL["leaf_jac"], 0.8)
|
||||
leaf_b = get_material("Mat_LeafJac2", PAL["leaf_jac_2"], 0.8)
|
||||
|
||||
# The fork is LOW — 0.36 of the tree's height, where a gum's is 0.62. This
|
||||
# single number is most of why the two species read differently at a
|
||||
# glance, and all of why the good anchor is reachable off a ladder.
|
||||
fork_h = height * 0.36
|
||||
r_base, r_fork = height * 0.046, height * 0.027
|
||||
lean = rng.uniform(-0.03, 0.03)
|
||||
parts = [add_cone(f"{name}_trunk", r_base, r_fork, fork_h,
|
||||
(lean * fork_h * 0.5, 0, fork_h / 2), bark,
|
||||
verts=10, rot=(0, lean, 0))]
|
||||
parts.append(add_cone(f"{name}_flare", r_base * 1.4, r_base,
|
||||
height * 0.045, (0, 0, height * 0.0225), bark_d,
|
||||
verts=10))
|
||||
|
||||
# Three leaders off the fork, sweeping out and up. These are the brittle
|
||||
# part; the anchors on them are what the low fork is being compared to.
|
||||
fork_pt = (lean * fork_h, 0, fork_h)
|
||||
leader_tips = []
|
||||
for i in range(3):
|
||||
ang = math.tau * i / 3 + rng.uniform(-0.25, 0.25)
|
||||
reach = spread * rng.uniform(0.26, 0.34)
|
||||
tip = (fork_pt[0] + math.cos(ang) * reach,
|
||||
fork_pt[1] + math.sin(ang) * reach,
|
||||
fork_h + height * rng.uniform(0.22, 0.34))
|
||||
parts.extend(_limb(f"{name}_leader_{i:02d}", fork_pt, tip,
|
||||
r_fork * 0.72, r_fork * 0.30, bark, segs=4,
|
||||
sweep=0.45))
|
||||
leader_tips.append(tip)
|
||||
join_group(parts, "trunk", root)
|
||||
|
||||
# Canopy: wide, flat, and low-domed. Blobs are squashed hard on Z (0.30–0.42
|
||||
# against a gum's 0.55–0.75) because that flat umbrella IS the silhouette.
|
||||
top = (fork_pt[0], fork_pt[1], height * 0.68)
|
||||
canopy_grp = add_empty("canopy", top, root, size=0.7)
|
||||
canopy_grp["sway_amp"] = 1.05
|
||||
canopy_grp["sway_phase"] = round(rng_for(f"{name}:sway").uniform(0, math.tau), 3)
|
||||
canopy_grp["sway_pivot_y"] = round(height * 0.68, 3)
|
||||
for i in range(4):
|
||||
ang = math.tau * i / 4 + rng.uniform(-0.25, 0.25)
|
||||
off = spread * rng.uniform(0.16, 0.30)
|
||||
cz = height * 0.68 + height * rng.uniform(0.02, 0.12)
|
||||
r = spread * rng.uniform(0.22, 0.30)
|
||||
blob = add_ico(f"canopy_{i + 1:02d}", r,
|
||||
(top[0] + math.cos(ang) * off, top[1] + math.sin(ang) * off, cz),
|
||||
leaf_a if i % 2 == 0 else leaf_b, subdiv=2,
|
||||
scale=(1.0, 1.0, rng.uniform(0.30, 0.42)),
|
||||
jitter=r * 0.10, rng=rng)
|
||||
parent_keep_transform(blob, canopy_grp)
|
||||
blob["sway_amp"] = round(0.6 + 0.4 * (cz / height), 3)
|
||||
|
||||
# The ladder, as data. branch_anchor_01 is the FORK — the one piece of
|
||||
# honest steel this tree has — and 02/03 are out on the leaders.
|
||||
LADDER = [
|
||||
(fork_pt, 0.95,
|
||||
"the main fork at 2.4 m: a two-hands-around-it union, and the best "
|
||||
"thing this tree will ever offer you"),
|
||||
(leader_tips[0], 0.52,
|
||||
"a jacaranda leader — fast-grown, light, brittle; it is holding "
|
||||
"itself up and not much else"),
|
||||
(leader_tips[1], 0.40,
|
||||
"further out on the same kind of limb; this is the rung that ends "
|
||||
"the night"),
|
||||
]
|
||||
for i, (pt, hint, why) in enumerate(LADDER):
|
||||
e = add_empty(f"branch_anchor_{i + 1:02d}", pt, root, size=0.25)
|
||||
e["anchor_type"] = "tree"
|
||||
e["rating_hint"] = hint
|
||||
e["why"] = why
|
||||
|
||||
stamp(root, name, "tree")
|
||||
root["canopy_count"] = 4
|
||||
root["species"] = "jacaranda"
|
||||
root["branch_ladder"] = "0.95/0.52/0.40"
|
||||
root["ladder_note"] = ("steeper than the gums' 1.0/0.88/0.76 on purpose — "
|
||||
"on this tree, height costs you")
|
||||
root["priced"] = False
|
||||
root["unpriced_why"] = "no limb-failure event exists for the player to see"
|
||||
return root
|
||||
|
||||
|
||||
def build_fence_post(name):
|
||||
root = add_empty(name)
|
||||
timber = get_material("Mat_Timber", PAL["timber"], 0.85)
|
||||
@ -759,6 +908,9 @@ def _house_facade(name, root):
|
||||
e = add_empty("window_light_anchor", (win_x, -D / 2 - 0.35, win_z + win_h / 2),
|
||||
root, size=0.2)
|
||||
e["light_hint"] = "warm PointLight ~2700K, spills onto the grass under the eave"
|
||||
not_a_tie_off(e, "where Lane A hangs the warm window light",
|
||||
"a lighting hint at a window pane — the fascia anchors are "
|
||||
"the house's tie-offs, and they rate 0.35 for a reason")
|
||||
|
||||
# The roof and its eave. The eave overhangs 0.55 into the yard (-Y).
|
||||
eave_y = -0.55
|
||||
@ -1023,6 +1175,20 @@ def build_carport_01(name):
|
||||
# but nothing said what a carport COSTS, so Lane A's aftermath had no number
|
||||
# to reach for. main.js already reads world.gnome.collateralValue — same
|
||||
# shape, same place.
|
||||
#
|
||||
# SPRINT14 palette audit — `collateral_key` added, and the new audit assert
|
||||
# is what found it. The price used to resolve only because site_02 happens
|
||||
# to name its structure "carport", matching the string on the anchors:
|
||||
# `collateralFor(key)` looks for a STRUCTURE whose site-JSON id === key.
|
||||
# That held for exactly one yard. The moment the editor places a second one
|
||||
# — and it will generate "carport_2" for uniqueness, because it must — the
|
||||
# anchors still say collateral:"carport", no structure carries that id, and
|
||||
# collateralFor returns null: the carport becomes a FREE failure, which is
|
||||
# the gutter bug reborn in the sprint meant to bury it. The GLB now names
|
||||
# which collateral string its price answers to, exactly as the house does
|
||||
# for "gutter". Lane A: the runtime half is yours — collateralFor could
|
||||
# fall back to `glb.userData.collateral_key` when no structure id matches.
|
||||
root["collateral_key"] = "carport"
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
root["collateral_label"] = "the carport"
|
||||
return root
|
||||
@ -1099,10 +1265,253 @@ def build_carport_01_wrecked(name):
|
||||
|
||||
stamp(root, name, "structure")
|
||||
root["broken_variant_of"] = "carport_01"
|
||||
root["collateral_key"] = "carport" # SPRINT14 audit — see the intact twin
|
||||
root["collateral_value"] = CARPORT_COLLATERAL
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE SWING SET (SPRINT14 gate 3.1 — a temptation prop for D's palette)
|
||||
# ---------------------------------------------------------------------------
|
||||
# One shape, stated once, used by the intact set AND the wreck. The carport and
|
||||
# the house both taught this: a wreck built from re-typed numbers drifts away
|
||||
# from its twin one edit at a time, and the swap starts needing a fudge offset.
|
||||
SWING = dict(
|
||||
SPAN=2.30, # crossbar, apex to apex (a two-seat domestic A-frame)
|
||||
SPLAY=0.45, # each leg foot this far fore/aft of the apex
|
||||
H=2.05, # top rail height — a shade over head height, which is
|
||||
# exactly why it reads as a tie-off
|
||||
R_LEG=0.024, # 48 mm OD galvanised tube
|
||||
R_RAIL=0.019, # 38 mm OD top rail
|
||||
SEAT_Z=0.45,
|
||||
SEAT_X=(-0.55, 0.55),
|
||||
)
|
||||
|
||||
# What it costs to bend the client's swing set. Proposal — Lane A owns the
|
||||
# number, same as the carport's 180 and the gutter's 90:
|
||||
# · the band is a ladder now, and this has to slot into it honestly:
|
||||
# gnome 25 (ornament) < gutter 90 (a run of one trade's work) < SWING 140
|
||||
# < carport 180 (a structure with a roof on it);
|
||||
# · what actually fails is the two apex junctions and the legs under them —
|
||||
# bent tube, not matchwood. That's a frame replacement and a re-hang on a
|
||||
# set that costs $250–350 new, so 140 is the repair, not the receipt;
|
||||
# · under ~90 it's cheaper than the gutter, which would say a whole item of
|
||||
# the kid's play equipment is worth less than a length of guttering —
|
||||
# the client would not agree and neither would the bill;
|
||||
# · over ~180 it outbids the carport, and a swing set is a toy: the carport
|
||||
# must stay the worst thing on any yard's bill or the corner block's
|
||||
# lesson gets quietly outranked by a prop.
|
||||
# It is priced (unlike the bike) because the sim CAN destroy it: the frame
|
||||
# anchors carry collateral="swing_set", so losing that corner bills it through
|
||||
# exactly the chain the carport already proved, and the wreck is the thing the
|
||||
# player sees.
|
||||
SWING_COLLATERAL = 140
|
||||
|
||||
|
||||
def _swing_frame(name, root, steel, seat_m, chain_m, *, wrecked=False):
|
||||
"""Build the set. `wrecked=False` is the standing one; True tips it over.
|
||||
|
||||
Returns the two apex points (Blender coords) so the caller hangs the
|
||||
anchors on the exact points the geometry ended up at, rather than on a
|
||||
second copy of the arithmetic.
|
||||
|
||||
HOW IT FAILS, and why the wreck is a transform rather than a second model:
|
||||
an A-frame swing set is not bolted to anything. The feet sit on the grass
|
||||
(the ground pegs are in the shed; they always are). Load a corner of a
|
||||
25 m² sail onto it and nothing snaps — the frame walks, then it goes over
|
||||
sideways in one piece.
|
||||
|
||||
So the wreck is the same triangle, rotated about the foot line it tips
|
||||
OVER (y = −SPLAY), by 100° — past horizontal. That angle is not a look, it
|
||||
is the resting position: the frame comes to rest on that foot rail and on
|
||||
the crossbar, which puts the far pair of legs in the air at ~0.89 m,
|
||||
pointing up. That is what a fallen A-frame looks like in every yard I have
|
||||
ever seen one in, and it is unmistakable at a glance from across a yard —
|
||||
which is the job, because the player has to read "I did that" instantly.
|
||||
|
||||
Tipping about the far foot instead (the obvious first try) drives the near
|
||||
feet 0.88 m through the lawn — the same class of error as a wreck that
|
||||
stands taller than its twin, and the reason both are asserted.
|
||||
"""
|
||||
S = SWING
|
||||
half = S["SPAN"] / 2
|
||||
apex_l, apex_r = (-half, 0.0, S["H"]), (half, 0.0, S["H"])
|
||||
|
||||
if wrecked:
|
||||
th = math.radians(100.0)
|
||||
c, s = math.cos(th), math.sin(th)
|
||||
y0 = -S["SPLAY"] # the foot line it goes over
|
||||
# It rests ON its tubes, so lift by a leg radius: without this the
|
||||
# capped ends of the now-near-horizontal legs sit ~24 mm under the
|
||||
# grass, and "broken variants sit on the ground" is a real assert.
|
||||
lift = S["R_LEG"] + 0.002
|
||||
|
||||
def R(p):
|
||||
x, y, z = p
|
||||
yr = y - y0
|
||||
return (x, yr * c - z * s + y0, yr * s + z * c + lift)
|
||||
else:
|
||||
def R(p):
|
||||
return p
|
||||
|
||||
legs, feet = [], []
|
||||
for sx in (-1, 1):
|
||||
ax = sx * half
|
||||
for sy in (-1, 1):
|
||||
legs.append(add_tube_between(
|
||||
f"{name}_leg_{sx}_{sy}", R((ax, sy * S["SPLAY"], 0.0)),
|
||||
R((ax, 0.0, S["H"])), S["R_LEG"], steel, verts=6))
|
||||
# The foot rail — the bit that is supposed to be pegged down and is
|
||||
# not. It survives the wreck: it is the part that dragged.
|
||||
feet.append(add_tube_between(
|
||||
f"{name}_footrail_{sx}", R((ax, -S["SPLAY"], 0.03)),
|
||||
R((ax, S["SPLAY"], 0.03)), S["R_LEG"] * 0.8, steel, verts=6))
|
||||
join_group(legs + feet, "frame", root)
|
||||
|
||||
# The crossbar gets its OWN node, and that is a gameplay decision, not a
|
||||
# modelling one: it is the single most tempting-looking thing on the prop
|
||||
# (a straight steel rail at 2.05 m, dead level, right where a sail corner
|
||||
# wants to be) and it is not an anchor. Keeping it separate means the data
|
||||
# can say so on the node itself, and means the wreck can lay it on the
|
||||
# grass as one recognisable piece.
|
||||
rail = join_group([add_tube_between(
|
||||
f"{name}_rail", R(apex_l), R(apex_r), S["R_RAIL"], steel, verts=8)],
|
||||
"crossbar", root)
|
||||
not_a_tie_off(
|
||||
rail, "the top rail — a swing hangs off it, a sail does not",
|
||||
"38 mm tube spanning 2.3 m between two unpegged A-frames: it holds a "
|
||||
"child in bending, and a sail corner pulls it sideways, which is the "
|
||||
"one direction nothing here resists")
|
||||
|
||||
swing_parts = []
|
||||
for i, sx in enumerate(S["SEAT_X"]):
|
||||
if wrecked:
|
||||
# Chains do not stay rigid when the frame they hang from is on the
|
||||
# grass. The rail is down at (y≈−2.55, z≈0.09); the seats ended up
|
||||
# just past it, flat, with the chains slack across the lawn.
|
||||
hang = R((sx, 0.0, S["H"]))
|
||||
seat_at = (sx, hang[1] - 0.28, 0.03)
|
||||
for sy in (-1, 1):
|
||||
swing_parts.append(add_tube_between(
|
||||
f"{name}_chain_{i}_{sy}", (hang[0] + sy * 0.05, hang[1], hang[2]),
|
||||
(seat_at[0] + sy * 0.07, seat_at[1], seat_at[2] + 0.02),
|
||||
0.006, chain_m, verts=4))
|
||||
swing_parts.append(add_box(
|
||||
f"{name}_seat_{i}", (0.44, 0.16, 0.03), seat_at, seat_m,
|
||||
rot=(0, 0, math.radians(7 * (1 if i else -1)))))
|
||||
else:
|
||||
for sy in (-1, 1):
|
||||
swing_parts.append(add_tube_between(
|
||||
f"{name}_chain_{i}_{sy}", (sx, 0.0, S["H"]),
|
||||
(sx, sy * 0.09, S["SEAT_Z"]), 0.006, chain_m, verts=4))
|
||||
swing_parts.append(add_box(
|
||||
f"{name}_seat_{i}", (0.44, 0.16, 0.03), (sx, 0.0, S["SEAT_Z"]),
|
||||
seat_m))
|
||||
join_group(swing_parts, "swings", root)
|
||||
return [R(apex_l), R(apex_r)]
|
||||
|
||||
|
||||
def build_swing_set_01(name):
|
||||
"""A two-seat backyard swing set — the palette's honest middle option.
|
||||
|
||||
SPRINT14 gate 3.1. D needs things worth placing, and "worth placing" means
|
||||
the author has a real decision to make. The palette had a ceiling (a gum
|
||||
fork, 1.0), a floor (the carport beam, 0.22, a pure trap) and almost
|
||||
nothing in between — so every yard was either "there is good steel here" or
|
||||
"there is a lie here". The swing set is the middle: it genuinely holds, and
|
||||
it costs you if you lean on it.
|
||||
|
||||
THE TEMPTATION IS THE CROSSBAR. A dead-level steel rail at 2.05 m, spanning
|
||||
2.3 m, at the exact height a sail corner wants — it is the most anchor-
|
||||
looking object I have built. It is not an anchor, and the data says so
|
||||
(`tie_off: False` on the `crossbar` node). What IS offered is the two apex
|
||||
junctions, where four legs and the rail all meet a welded corner casting:
|
||||
`frame_anchor_01/02`, rating_hint 0.45, typed `swing_frame`.
|
||||
|
||||
WHY 0.45, and why its own enum type. The junction itself is sound steel —
|
||||
better than the house fascia (0.35) and much better than the carport beam
|
||||
(0.22). What it is NOT is anchored: the whole set stands on four feet on
|
||||
grass, with the ground pegs still in the shed. So it holds a moderate pull
|
||||
and then the SET moves, which is a completely different failure from a
|
||||
post pulling out of concrete. That is also why it is not typed `post`: the
|
||||
enum string is what the player reads before they commit (MANUAL, "the enum
|
||||
gives it its pre-rig read"), and calling this a post would promise 4 m of
|
||||
concreted steel. It is a swing frame. It says swing frame.
|
||||
|
||||
Priced at SWING_COLLATERAL, with a wreck, because the sim can actually do
|
||||
it: `collateral="swing_set"` on both anchors, the value keyed on the root,
|
||||
the same chain the carport proved. (Compare the bike, which stays unpriced
|
||||
because nothing can knock it over yet.)
|
||||
"""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
|
||||
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
|
||||
apexes = _swing_frame(name, root, steel, seat_m, chain_m, wrecked=False)
|
||||
|
||||
for i, ap in enumerate(apexes):
|
||||
e = add_empty(f"frame_anchor_{i + 1:02d}", ap, root, size=0.18)
|
||||
e["anchor_type"] = "swing_frame"
|
||||
e["rating_hint"] = 0.45
|
||||
e["collateral"] = "swing_set"
|
||||
e["why"] = ("welded apex casting — sound steel on a frame that is "
|
||||
"standing on grass, not pegged into it")
|
||||
|
||||
stamp(root, name, "prop")
|
||||
root["collateral_key"] = "swing_set"
|
||||
root["collateral_value"] = SWING_COLLATERAL
|
||||
root["collateral_label"] = "the swing set"
|
||||
root["breakable"] = True
|
||||
root["mass_hint"] = 38.0
|
||||
|
||||
# ** WHICH WAY IT FALLS — a placement fact, MEASURED, not reasoned. **
|
||||
# In Blender the wreck goes over toward −Y. You do not work in Blender: the
|
||||
# exporter maps (x, y, z) → (x, z, −y), so in three.js it lands on +Z. I am
|
||||
# only willing to write that down because I loaded the GLB in the browser
|
||||
# and read the crossbar's world box: centre (0.00, 0.11, +2.55), footprint
|
||||
# z = 0.45 … 2.93. My bike docstring lied about exactly this axis and only a
|
||||
# browser-coords assert caught it, so these two extras are pinned by one in
|
||||
# e.test.js as well — the claim and the geometry now go red together.
|
||||
#
|
||||
# Lane A / D: leave ~3 m clear on the prop's +Z side or the wreck lays
|
||||
# itself through whatever is standing there. The intact footprint is only
|
||||
# ~0.95 m deep, so the editor cannot infer this from the standing bounds.
|
||||
root["wreck_falls_toward"] = "+Z"
|
||||
root["wreck_clearance_m"] = 3.0
|
||||
return root
|
||||
|
||||
|
||||
def build_swing_set_01_wrecked(name):
|
||||
"""The swing set after you tied a sail corner to it.
|
||||
|
||||
Same origin, same parts, one number different (`wrecked=True` racks the
|
||||
frame 62° about the ground line) — so intact and wrecked cannot drift, and
|
||||
the swap is mesh-for-mesh like the carport's and the house's.
|
||||
|
||||
It is racked over, not flattened: the rail is on the grass with both seats
|
||||
still hanging off it and the feet have dragged. A swing set that came apart
|
||||
into pieces would read as vandalism; one lying on its side with the swings
|
||||
tangled reads as exactly what it is, which is a thing you pulled over.
|
||||
"""
|
||||
root = add_empty(name)
|
||||
steel = get_material("Mat_Steel", PAL["steel_gal"], 0.4, metallic=0.85)
|
||||
seat_m = get_material("Mat_SwingSeat", PAL["bike_kid"], 0.75)
|
||||
chain_m = get_material("Mat_SteelDark", PAL["steel_dark"], 0.45, metallic=0.8)
|
||||
|
||||
_swing_frame(name, root, steel, seat_m, chain_m, wrecked=True)
|
||||
|
||||
# No frame_anchor_* survives, for the same reason no fascia_anchor survives
|
||||
# the torn eave: you cannot re-tie to a frame lying on the grass, and an
|
||||
# anchor that outlives its structure is the free-failure bug in a costume.
|
||||
stamp(root, name, "prop")
|
||||
root["broken_variant_of"] = "swing_set_01"
|
||||
root["collateral_key"] = "swing_set"
|
||||
root["collateral_value"] = SWING_COLLATERAL
|
||||
root["collateral_label"] = "the swing set"
|
||||
return root
|
||||
|
||||
|
||||
def build_shed_01(name):
|
||||
"""Colorbond garden shed, skillion roof. Spare hardware lives in here."""
|
||||
root = add_empty(name)
|
||||
@ -1134,7 +1543,10 @@ def build_shed_01(name):
|
||||
add_box(f"{name}_door_r", (W / 2 - 0.06, 0.02, H - fall - 0.16),
|
||||
(W / 4, -D / 2 - 0.03, 0.08 + (H - fall - 0.16) / 2), dark),
|
||||
], "doors", root)
|
||||
add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2)
|
||||
not_a_tie_off(add_empty("door_anchor", (0, -D / 2 - 0.6, 0.9), root, size=0.2),
|
||||
"stand point in front of the shed doors",
|
||||
"a Colorbond door skin on a sheet-metal shed; there is no "
|
||||
"steel here to strap to, only 0.5 mm of cladding")
|
||||
stamp(root, name, "structure")
|
||||
return root
|
||||
|
||||
@ -1157,7 +1569,9 @@ def build_shed_table(name):
|
||||
legs.append(add_box(f"{name}_shelf", (W - 0.16, D - 0.12, 0.03),
|
||||
(0, 0, 0.22), timber))
|
||||
join_group(legs, "table_frame", root)
|
||||
add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2)
|
||||
not_a_tie_off(add_empty("pickup_anchor", (0, 0, H + 0.05), root, size=0.2),
|
||||
"where spare hardware sits and where the hold-E prompt lands",
|
||||
"a bench top, not a bollard — the table would come with you")
|
||||
stamp(root, name, "prop")
|
||||
return root
|
||||
|
||||
@ -1416,6 +1830,16 @@ def build_tramp_01(name):
|
||||
join_group(legs, "legs", root)
|
||||
stamp(root, name, "debris")
|
||||
root["mass_hint"] = 45.0
|
||||
# SPRINT14 audit — the trampoline is UNPRICED, and that is a statement, not
|
||||
# an omission. It carries no anchor node (nothing here is a tie-off: a rim
|
||||
# on six unpegged legs is the least trustworthy steel in any yard), and
|
||||
# nothing in the runtime spawns it yet, so no player-visible event can
|
||||
# destroy it. Same ruling as the bike: billing collateral for a thing the
|
||||
# player never sees break is the lie the invoice exists to kill. If Lane C
|
||||
# ever throws one, price it THEN — the number is easy, the event is the
|
||||
# hard part.
|
||||
root["priced"] = False
|
||||
root["unpriced_why"] = "no anchor, and nothing in the sim can wreck it yet"
|
||||
return root
|
||||
|
||||
|
||||
@ -1784,6 +2208,9 @@ def build_broom_01(name):
|
||||
|
||||
g = add_empty("grip_anchor", (0, 0, 0.95), root, size=0.12)
|
||||
g["carry_type"] = "broom"
|
||||
not_a_tie_off(g, "where the player's hand takes the broom",
|
||||
"a carry point on a 1.2 kg tool; it is the thing that blows "
|
||||
"away, not the thing that holds")
|
||||
p = add_empty("poke_tip", (0, 0, 0.02), root, size=0.12)
|
||||
p["use"] = "push the pond up from under the sail; soft end, won't hole the cloth"
|
||||
stamp(root, name, "tool")
|
||||
@ -2407,6 +2834,13 @@ ASSETS = [
|
||||
dims=((2.0, 5.5), (2.0, 5.5), (5.0, 6.5)),
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02",
|
||||
"branch_anchor_01", "branch_anchor_02"]),
|
||||
# The second species (SPRINT14). Broader than it is tall — if x/y ever
|
||||
# measure under the height, someone has quietly turned it back into a gum.
|
||||
dict(name="tree_jacaranda_01", fn=build_tree_jacaranda_01,
|
||||
dims=((7.0, 8.2), (6.1, 7.2), (5.7, 6.5)),
|
||||
nodes=["trunk", "canopy", "canopy_01", "canopy_02", "canopy_03",
|
||||
"canopy_04", "branch_anchor_01", "branch_anchor_02",
|
||||
"branch_anchor_03"]),
|
||||
dict(name="fence_post", fn=build_fence_post,
|
||||
dims=((0.10, 0.16), (0.10, 0.16), (1.95, 2.10)),
|
||||
nodes=["post"]),
|
||||
@ -2441,6 +2875,18 @@ ASSETS = [
|
||||
dict(name="carport_01_wrecked", fn=build_carport_01_wrecked,
|
||||
dims=((4.5, 6.6), (5.2, 5.8), (1.9, 2.45)),
|
||||
nodes=["footings", "posts", "beams", "roof_down"]),
|
||||
# The temptation prop (SPRINT14). Spans the crossbar on x, splays on y,
|
||||
# and stands a shade over head height — the three numbers that make the
|
||||
# rail look like an anchor.
|
||||
dict(name="swing_set_01", fn=build_swing_set_01,
|
||||
dims=((2.30, 2.45), (0.85, 1.05), (2.00, 2.15)),
|
||||
nodes=["frame", "crossbar", "swings",
|
||||
"frame_anchor_01", "frame_anchor_02"]),
|
||||
# Over on its side: it reaches FURTHER on y than it ever stood on z. A
|
||||
# wreck that still measures ~2.05 tall is a wreck that never fell.
|
||||
dict(name="swing_set_01_wrecked", fn=build_swing_set_01_wrecked,
|
||||
dims=((2.30, 2.45), (2.30, 3.20), (0.80, 1.05)),
|
||||
nodes=["frame", "crossbar", "swings"]),
|
||||
dict(name="shed_01", fn=build_shed_01,
|
||||
dims=((2.4, 2.7), (1.8, 2.1), (1.95, 2.25)),
|
||||
nodes=["shell", "roof", "doors", "door_anchor"]),
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 3.9 MiB |
@ -74,9 +74,16 @@ export const PHASES = ['forecast', 'prep', 'storm', 'aftermath'];
|
||||
* (main.js: `type === 'tree'`). Ladder work is `work`; hardware rating is E's
|
||||
* `rating_hint`. Keep it that way — a rule keyed on a type a future site
|
||||
* doesn't fit is the exact shape of the bug this widening fixed.
|
||||
*
|
||||
* `swing_frame` (SPRINT14, E): a swing set's apex junction. It is NOT a `post`
|
||||
* and the difference is the whole reason it got its own word — the type string
|
||||
* is what the player reads before they commit, and "post" promises 4 m of
|
||||
* concreted steel. This is a welded joint on a frame standing on grass: good
|
||||
* steel, no footing, rating_hint 0.45. Same lesson as the carport, which was
|
||||
* also nearly smuggled in as a post.
|
||||
*/
|
||||
export const ANCHOR_TYPE = Object.freeze([
|
||||
'house', 'tree', 'post', 'carport', 'carport_post',
|
||||
'house', 'tree', 'post', 'carport', 'carport_post', 'swing_frame',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
import * as THREE from '../../vendor/three.module.js';
|
||||
import { assert } from '../testkit.js';
|
||||
import { ANCHOR_TYPE } from '../contracts.js';
|
||||
|
||||
// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
|
||||
//
|
||||
@ -90,6 +91,13 @@ const ASSETS = [
|
||||
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
|
||||
{ name: 'bike_kid_01', h: [0.60, 0.84],
|
||||
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
|
||||
{ name: 'tree_jacaranda_01', h: [5.7, 6.5],
|
||||
nodes: ['trunk', 'canopy', 'canopy_01', 'canopy_02', 'canopy_03', 'canopy_04',
|
||||
'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
|
||||
{ name: 'swing_set_01', h: [2.00, 2.15],
|
||||
nodes: ['frame', 'crossbar', 'swings', 'frame_anchor_01', 'frame_anchor_02'] },
|
||||
{ name: 'swing_set_01_wrecked', h: [0.80, 1.05],
|
||||
nodes: ['frame', 'crossbar', 'swings'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -491,6 +499,7 @@ export default async function run(t) {
|
||||
t.test('broken variants sit on the same ground plane as their intact twin', () => {
|
||||
for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
|
||||
['fence_panel', 'fence_panel_snapped'],
|
||||
['swing_set_01', 'swing_set_01_wrecked'],
|
||||
['house_yardside', 'house_yardside_wrecked']]) {
|
||||
for (const n of [intact, broken]) {
|
||||
const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
|
||||
@ -522,6 +531,265 @@ export default async function run(t) {
|
||||
'window_glow must carry hidden_by_default or the house is lit at noon');
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SPRINT14 gate 3.1 — THE PALETTE AUDIT, as three rules that can go red.
|
||||
//
|
||||
// The editor is about to offer these GLBs to an author by name, which turns
|
||||
// every silent field in them into a trap for whoever places one. These three
|
||||
// tests are the audit made permanent: run them, not a spreadsheet.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Every node in every loaded GLB, as [assetName, node] pairs. */
|
||||
const allNodes = () => {
|
||||
const out = [];
|
||||
for (const [name, gltf] of loaded) {
|
||||
gltf.scene.traverse((o) => out.push([name, o]));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// RULE 1 — no silent anchors. `world.js:adoptAnchor` does
|
||||
// `anchor.ratingHint = node.userData?.rating_hint ?? 1`, so a node named
|
||||
// `*_anchor` with no hint is not "unrated", it is RATED PERFECT: better than
|
||||
// a gum fork, conjured out of a missing field. Four were sitting in the
|
||||
// palette before this sprint (door_anchor, pickup_anchor, grip_anchor,
|
||||
// window_light_anchor) — all of them carry points or lighting hints, none of
|
||||
// them steel. They now deny it explicitly (`tie_off: false`).
|
||||
//
|
||||
// The failure this prevents is the nastiest kind: a site author picks
|
||||
// `door_anchor` off the editor's node list, the yard boots, the sail holds,
|
||||
// and a shed door quietly outperforms a concreted post for the rest of the
|
||||
// game. Nothing crashes. Nothing looks wrong. The site is just a lie.
|
||||
t.test('no silent anchors: every *_anchor node rates itself or denies being one', () => {
|
||||
const silent = [];
|
||||
for (const [asset, o] of allNodes()) {
|
||||
if (!/_anchor(_\d+)?$/.test(o.name)) continue;
|
||||
const u = o.userData ?? {};
|
||||
const rated = typeof u.rating_hint === 'number';
|
||||
const denied = u.tie_off === false;
|
||||
if (!rated && !denied) silent.push(`${asset}/${o.name}`);
|
||||
assert(!(rated && denied),
|
||||
`${asset}/${o.name} both rates itself and denies being a tie-off — pick one`);
|
||||
}
|
||||
assert(silent.length === 0,
|
||||
`these nodes adopt at rating_hint 1 (the best steel in the game) purely by ` +
|
||||
`saying nothing: ${silent.join(', ')}. Give them a rating_hint, or ` +
|
||||
`not_a_tie_off() them in build_yard_assets.py`);
|
||||
});
|
||||
|
||||
// RULE 2 — no unpriced collateral. The gutter was a FREE FAILURE for two
|
||||
// sprints: the fascia anchors said collateral:"gutter" from Sprint 6 and
|
||||
// nothing anywhere priced one, so collateralFor('gutter') returned null and
|
||||
// ripping the eave off the client's house cost the player a shackle. Fixing
|
||||
// that one string is not the fix; the fix is that a new asset cannot repeat
|
||||
// it. Every collateral string an anchor names must resolve to a number
|
||||
// somewhere in the palette, by construction, before it can ship.
|
||||
t.test('no unpriced collateral: every collateral string an anchor names has a price', () => {
|
||||
const priced = new Map();
|
||||
for (const [, o] of allNodes()) {
|
||||
const u = o.userData ?? {};
|
||||
if (typeof u.collateral_value !== 'number') continue;
|
||||
priced.set(u.collateral_key ?? u.shades_asset ?? o.name, u.collateral_value);
|
||||
}
|
||||
const orphans = [];
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const key = o.userData?.collateral;
|
||||
if (typeof key !== 'string') continue;
|
||||
if (typeof priced.get(key) !== 'number') orphans.push(`${asset}/${o.name} → "${key}"`);
|
||||
}
|
||||
assert(orphans.length === 0,
|
||||
`anchors name collateral nothing prices, so breaking these is FREE: ` +
|
||||
`${orphans.join(', ')}. Priced keys are [${[...priced.keys()].join(', ')}]`);
|
||||
});
|
||||
|
||||
// RULE 3 — the baked type is the checked type. `anchor_type` in a GLB and
|
||||
// ANCHOR_TYPE in contracts.js have to be the same vocabulary or the site's
|
||||
// enum check is validating a different thing than the asset says. The
|
||||
// carport needed the list widened (SPRINT11); the swing frame needed it
|
||||
// widened again this sprint. Widening it is the intended move — inventing a
|
||||
// word only the GLB knows is not.
|
||||
t.test('baked anchor_type strings are all in the checked ANCHOR_TYPE enum', () => {
|
||||
for (const [asset, o] of allNodes()) {
|
||||
const ty = o.userData?.anchor_type;
|
||||
if (ty === undefined) continue;
|
||||
assert(ANCHOR_TYPE.includes(ty),
|
||||
`${asset}/${o.name} is typed "${ty}", which no site may declare — ` +
|
||||
`add it to ANCHOR_TYPE in contracts.js or use one of [${ANCHOR_TYPE.join(', ')}]`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- the swing set: the temptation is the crossbar ------------------------
|
||||
// The prop exists to put an honest middle rung in a palette that only had a
|
||||
// ceiling (gum fork 1.0) and a trap (carport beam 0.22). Its whole design
|
||||
// rests on the rail NOT being an anchor while looking exactly like one, so
|
||||
// that is the thing pinned hardest.
|
||||
t.test('the swing set offers two anchors and a crossbar that is not one', () => {
|
||||
const g = loaded.get('swing_set_01');
|
||||
assert(g, 'swing_set_01 did not load');
|
||||
|
||||
const rail = g.scene.getObjectByName('crossbar');
|
||||
assert(rail, 'crossbar node missing — it must stay its own node so the data can refuse it');
|
||||
assert(rail.userData?.tie_off === false,
|
||||
'the crossbar must carry tie_off:false — it is the most anchor-looking object in the palette');
|
||||
assert(rail.userData?.rating_hint === undefined,
|
||||
'the crossbar carries a rating_hint, which makes it adoptable — that is the whole thing this prop is about');
|
||||
|
||||
// Exactly two, and no third one hiding on the rail or the seats.
|
||||
const anchors = [];
|
||||
g.scene.traverse((o) => { if (typeof o.userData?.rating_hint === 'number') anchors.push(o); });
|
||||
assert(anchors.length === 2,
|
||||
`swing_set_01 offers ${anchors.length} rated anchors (${anchors.map((a) => a.name).join(', ')}) — want exactly the two frame apexes`);
|
||||
|
||||
const CARPORT_BEAM = 0.22, FASCIA = 0.35, GUM_FORK = 1.0;
|
||||
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
|
||||
const o = g.scene.getObjectByName(n);
|
||||
assert(o, `${n} missing`);
|
||||
assert(o.userData?.anchor_type === 'swing_frame',
|
||||
`${n} is typed "${o.userData?.anchor_type}" — a swing frame is not a post, and the type string is what the player reads before committing`);
|
||||
const r = o.userData?.rating_hint;
|
||||
assert(r > FASCIA && r < GUM_FORK,
|
||||
`${n} rates ${r} — the frame junction is sound steel on an unpegged frame: better than the fascia (${FASCIA}), nowhere near a fork (${GUM_FORK})`);
|
||||
assert(r > CARPORT_BEAM,
|
||||
`${n} rates ${r} — if it is worse than the carport beam it is a second trap, not the palette's honest middle`);
|
||||
assert(o.userData?.collateral === 'swing_set',
|
||||
`${n} must name what it takes with it (collateral="swing_set")`);
|
||||
}
|
||||
|
||||
// Priced, in band, and the wreck agrees — the carport/gutter chain again.
|
||||
const root = g.scene.getObjectByName('swing_set_01');
|
||||
const cost = root?.userData?.collateral_value;
|
||||
const carport = loaded.get('carport_01')?.scene.getObjectByName('carport_01')?.userData?.collateral_value;
|
||||
const gutter = loaded.get('house_yardside')?.scene.getObjectByName('house_yardside')?.userData?.collateral_value;
|
||||
assert(typeof cost === 'number', 'swing_set_01 carries no collateral_value');
|
||||
assert(root.userData?.collateral_key === 'swing_set',
|
||||
'collateral_key must name the string the anchors carry, or the price prices nothing');
|
||||
assert(cost > gutter && cost < carport,
|
||||
`the swing set (${cost}) must sit between the gutter (${gutter}) and the carport (${carport}) — ` +
|
||||
'a toy costs more than a run of guttering and less than a structure with a roof');
|
||||
|
||||
const w = loaded.get('swing_set_01_wrecked')?.scene.getObjectByName('swing_set_01_wrecked');
|
||||
assert(w?.userData?.collateral_value === cost,
|
||||
'the wrecked set must be priced the same as the one it used to be');
|
||||
assert(w?.userData?.broken_variant_of === 'swing_set_01',
|
||||
'the wreck must name its intact twin so A can pair the swap');
|
||||
});
|
||||
|
||||
// Same rule as the torn fascia: you cannot re-tie to a frame lying on the
|
||||
// grass. An anchor that survives its structure is the free-failure bug in a
|
||||
// costume — world.anchors would keep offering the tie-off after the swap.
|
||||
t.test('the wrecked swing set is over, and offers nothing to tie to', () => {
|
||||
const w = loaded.get('swing_set_01_wrecked');
|
||||
assert(w, 'swing_set_01_wrecked did not load');
|
||||
for (const n of ['frame_anchor_01', 'frame_anchor_02']) {
|
||||
assert(!w.scene.getObjectByName(n), `${n} survives the wreck — the frame it was welded to is on the ground`);
|
||||
}
|
||||
|
||||
// It went OVER, not down: measured, because "wrecked" has to mean a
|
||||
// specific pose or the next edit quietly turns it into rubble. The rail
|
||||
// used to be the highest thing on the prop; now it is on the grass, and
|
||||
// the far legs are the highest thing instead.
|
||||
const intact = loaded.get('swing_set_01');
|
||||
const railUp = new THREE.Box3().setFromObject(intact.scene.getObjectByName('crossbar'));
|
||||
assert(railUp.max.y > 2.0,
|
||||
`the intact rail tops out at y=${railUp.max.y.toFixed(2)} — the control is broken, nothing below can mean anything`);
|
||||
const railDown = new THREE.Box3().setFromObject(w.scene.getObjectByName('crossbar'));
|
||||
assert(railDown.max.y < 0.25,
|
||||
`the wrecked rail tops out at y=${railDown.max.y.toFixed(2)} — it is meant to be lying on the grass`);
|
||||
|
||||
// And it reaches further along the ground than it ever stood tall.
|
||||
const box = new THREE.Box3().setFromObject(w.scene);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
assert(size.z > size.y * 2,
|
||||
`the wreck is ${size.z.toFixed(2)} m deep and ${size.y.toFixed(2)} m tall — a set that fell over lies down`);
|
||||
const hi = new THREE.Box3().setFromObject(intact.scene);
|
||||
assert(size.y < hi.getSize(new THREE.Vector3()).y * 0.6,
|
||||
'the wreck stands too close to full height — it did not fall, it sagged');
|
||||
});
|
||||
|
||||
// THE AXIS TRAP, third time paid for. The wreck goes over toward Blender −Y,
|
||||
// which arrives here as +Z, and the placement note baked in the GLB says so
|
||||
// in words. Words cannot fail; this can. It pins the CLAIM against the
|
||||
// GEOMETRY, so the extras and the mesh have to lie in the same direction at
|
||||
// the same time or the suite says which one moved.
|
||||
//
|
||||
// This matters at placement, not just in a docstring: standing, the set is
|
||||
// 0.95 m deep; fallen, it reaches 2.9 m to +Z. Nothing about the intact
|
||||
// bounds tells the editor that, so a swing set backed onto a fence looks
|
||||
// fine until the night it lays itself through the palings.
|
||||
t.test('the swing set falls toward +Z, and the GLB says so in the same direction', () => {
|
||||
const intact = loaded.get('swing_set_01');
|
||||
const root = intact.scene.getObjectByName('swing_set_01');
|
||||
const claim = root?.userData?.wreck_falls_toward;
|
||||
assert(claim === '+Z', `the GLB claims it falls toward ${JSON.stringify(claim)} — this assert only knows how to check +Z, so if the geometry really changed, change both`);
|
||||
|
||||
const rail = new THREE.Box3()
|
||||
.setFromObject(loaded.get('swing_set_01_wrecked').scene.getObjectByName('crossbar'))
|
||||
.getCenter(new THREE.Vector3());
|
||||
assert(rail.z > 2.0,
|
||||
`the wrecked rail centres at z=${rail.z.toFixed(2)} — the claim says +Z and the mesh disagrees`);
|
||||
assert(Math.abs(rail.x) < 0.1,
|
||||
`the wrecked rail centres at x=${rail.x.toFixed(2)} — it should go over sideways, not slide along its own span`);
|
||||
|
||||
// And the clearance number is the reach, not a guess someone typed.
|
||||
const box = new THREE.Box3().setFromObject(loaded.get('swing_set_01_wrecked').scene);
|
||||
const clear = root?.userData?.wreck_clearance_m;
|
||||
assert(typeof clear === 'number' && box.max.z <= clear,
|
||||
`the wreck reaches z=${box.max.z.toFixed(2)} but the GLB tells the editor to leave ${clear} m — the advice must cover the wreckage`);
|
||||
});
|
||||
|
||||
// --- the second species: the ladder IS the feature ------------------------
|
||||
// Both gums carry 1.0/0.88/0.76 — forgiving by design, so height up a gum is
|
||||
// nearly free and "which tree" was never a real question. The jacaranda's
|
||||
// ladder collapses instead of stepping down, which is what makes tree choice
|
||||
// a decision. That claim is a NUMBER, so it gets an assert rather than a
|
||||
// docstring: flatten the ladder and this goes red.
|
||||
t.test('the jacaranda ladder falls away far harder than the gum ladder', () => {
|
||||
const rungs = (asset) => {
|
||||
const g = loaded.get(asset);
|
||||
assert(g, `${asset} did not load`);
|
||||
return ['branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03']
|
||||
.map((n) => g.scene.getObjectByName(n)?.userData?.rating_hint)
|
||||
.filter((v) => typeof v === 'number');
|
||||
};
|
||||
const gum = rungs('tree_gum_01'), jac = rungs('tree_jacaranda_01');
|
||||
assert(gum.length === 3 && jac.length === 3,
|
||||
`need three rungs each; got gum=${gum.length}, jac=${jac.length}`);
|
||||
|
||||
// Rung 1: the jacaranda's low fork is genuinely good steel — that is the
|
||||
// trade, not a consolation. It must stay close to the gum's.
|
||||
assert(jac[0] > 0.9, `jacaranda fork rates ${jac[0]} — the low fork is meant to be the best thing on the tree`);
|
||||
assert(jac[0] <= gum[0], `jacaranda fork (${jac[0]}) must not out-rate a gum fork (${gum[0]})`);
|
||||
|
||||
// ...and then it must actually fall away. Twice the gum's drop is the
|
||||
// design claim, stated as the threshold it has to clear.
|
||||
const dGum = gum[0] - gum[2], dJac = jac[0] - jac[2];
|
||||
assert(dJac > dGum * 2,
|
||||
`the jacaranda drops ${dJac.toFixed(2)} across its ladder and the gum drops ${dGum.toFixed(2)} — ` +
|
||||
'if height costs the same on both species, the second tree is just a repaint');
|
||||
assert(jac[2] < 0.5,
|
||||
`the jacaranda's top rung rates ${jac[2]} — the whole point is that the high anchor is a gamble`);
|
||||
|
||||
// Unpriced BY RULING, same as the bike: no limb-failure event exists for
|
||||
// a player to watch, and billing an unseen event is the lie the invoice
|
||||
// exists to kill. Price it when the limb can come down.
|
||||
const root = loaded.get('tree_jacaranda_01').scene.getObjectByName('tree_jacaranda_01');
|
||||
assert(root?.userData?.collateral_value === undefined,
|
||||
'the jacaranda is unpriced BY RULING — build the limb failure first');
|
||||
});
|
||||
|
||||
// The silhouette carries the species read at 30 m, which is what an author
|
||||
// in the editor actually picks on. A jacaranda is broader than it is tall;
|
||||
// a gum is the other way round. If this flips, the palette has two trees
|
||||
// that look the same and one of them is lying about its ladder.
|
||||
t.test('the jacaranda reads as a different tree: broader than tall, unlike the gums', () => {
|
||||
const size = (n) => new THREE.Box3().setFromObject(loaded.get(n).scene).getSize(new THREE.Vector3());
|
||||
const j = size('tree_jacaranda_01'), g = size('tree_gum_01');
|
||||
assert(Math.max(j.x, j.z) > j.y,
|
||||
`jacaranda is ${Math.max(j.x, j.z).toFixed(2)} m across and ${j.y.toFixed(2)} m tall — it should be broader than tall`);
|
||||
assert(g.y > Math.max(g.x, g.z),
|
||||
`gum is ${g.y.toFixed(2)} m tall and ${Math.max(g.x, g.z).toFixed(2)} m across — the control is broken`);
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
web/world/models/swing_set_01_v1.glb
Normal file
BIN
web/world/models/swing_set_01_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/swing_set_01_wrecked_v1.glb
Normal file
BIN
web/world/models/swing_set_01_wrecked_v1.glb
Normal file
Binary file not shown.
BIN
web/world/models/tree_jacaranda_01_v1.glb
Normal file
BIN
web/world/models/tree_jacaranda_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user