diff --git a/THREADS.md b/THREADS.md index 3de2be4..4c0a324 100644 --- a/THREADS.md +++ b/THREADS.md @@ -3228,3 +3228,96 @@ anchors are your GLB), but the tooling is now waiting, not TODO. · D's cold playthrough of site_02 — unblocked at last; · A decides the carport collateral number and the Anchor.type enum widening D flagged; · gate 3 (John plays the week) — FOUR sprints standing. + +[E] 2026-07-19 — 🧾 **LANE A — the job sheet and the invoice are drawn, rendered, and waiting for you + in `tools/jobsheet/`.** Same handover as the end cards: everything between the CUT markers pastes + into hud.js, and four PNGs sit beside it so you can judge it before you paste it + (`preview_sheet1`, `preview_sheet3`, `preview_inv_good`, `preview_inv_bad`; each state is also a + URL — `index.html?v=inv_bad`). It is **purely additive** — I did not touch `.card`, `.row` or + `.ledger`, every rule is a new class, so pasting it and wiring nothing changes nothing. + · **The letterhead is the trick.** One business, two documents, same masthead on both: the job + sheet and the invoice read as a pair of papers off the same outfit's printer instead of two + game screens. DESIGN.md opens with "you run a small landscaping outfit" and that has never once + been on the glass. The bill-to block is deliberately the SAME component on both — on the sheet + it's who you're working for, on the invoice it's who's paying, and it not moving is the joke. + · **Your `.ledger` already IS an invoice, it just never said so.** It gains a rule, an AMOUNT DUE, + and a terms line. That's it. + · **The one line I'd fight for: `.void`.** A bonus that was on the job sheet and did NOT survive + the night, struck through, still sitting in the same column as the money you did get. Four CSS + declarations. A bonus the player never watches fail is a bonus they never learn to protect — + see `preview_inv_bad`, where the $25 clean-site is dead and the carport is −$180 under it. + · Pay schedule is a **list, not three named fields**. base/garden/no-collateral is what SPRINT11 + specifies, but a list costs nothing today and means a site wanting a fourth condition doesn't + need a card change — which is the whole point of sites being data. + +[E] 2026-07-19 — 💰 **LANE A — two design calls in that tool are YOURS, and both have reasoning in the + code beside them so you can argue rather than just accept.** Same deal as the carport's $180. + · **The business name.** A letterhead needs an outfit on it and we've never named the player's. + My proposal is **HARD YARDS** — a real idiom for the unglamorous grind nobody thanks you for, + literally about yards, one word at masthead size, and it's the joke the game already tells: + THREADS' own win-card line is "nobody thanks you for the storm that did nothing. That's the + job." That IS the hard yards. What I deliberately did NOT do is name it after the player — a + surname mark is the more authentic thing a one-person outfit would have, and it's the better + answer the moment the player has a name, but naming the player is a much bigger call than a + letterhead and it isn't mine to make in a juice pass. One constant changes if Sprint 12 names them. + · **The ABN.** An Australian invoice without one isn't an invoice — it's the detail that makes the + paper read as real. But any plausible 11-digit number is potentially a real business's tax + identity, so it's sequential digits, which reads as "example" the way 555 numbers do. Deliberate, + not lazy — please don't "fix" it into something realistic. + +[E] 2026-07-19 — 🚲 **`bike_kid_01.glb`** — the per-client prop. 1.15 × 0.33 × 0.74 m, 700 tris, and the + Hendersons now have a kid. It exists so the brief on the job sheet ("the seedlings have to be alive + when we get back") lands on a yard that visibly belongs to somebody. + **THE LEAN IS BAKED IN AND IT LEANS TOWARD −Z. Read this before you place it.** A bike doesn't stand + up on its own, so an upright GLB would read as a bug the moment it's placed; the tilt is applied to + every point at author time, about the X axis through the origin, so the tyre contacts map to + themselves and it sits ON the ground. In the Blender source it leans +Y. **You don't work in + Blender.** The exporter maps (x,y,z) → (x,z,−y), so in three.js it leans **−Z**, and −Z is where the + fence goes. Put the fence on +Z and it leans away into thin air — a bug that looks exactly like a + physics bug and isn't one. I wrote "+Y is the fence side" in the docstring first; it would have been + a lie by the time it reached you. Stand it ~0.10 m off the palings — bars touch a fence, not tyres. + **What it needs from you:** a loader line and a site-JSON key. There's no generic `props: []` — every + prop is a named top-level key (`gnome`, `shed`, `shedTable`), so the bike is dead weight until you + wire it, exactly like the carport was. Shipped with `mass_hint: 8.0` and `breakable: false`. + **Not priced, and that's a question for you, not a decision by me.** It's juice, not a trap. But it's + a bike in a 32 m/s funnelled southerly, and a bike that ignores that gust is a worse lie than no + bike. If you want it collateral my number is **$60** — a real kid's bike, well under the gnome-to- + carport span so it never competes with the site's actual trap, and the sting is that it's the one + piece of collateral the client's kid will notice is gone. Ignore that number happily; the prop + stands up without it. + +[E] 2026-07-19 — 🐞 **two bugs, both caught by LOOKING at the render, neither catchable by the suite I + already had** — and that's the interesting part. + (1) I wrote a comment saying "it's a step-through: no top tube" and then built a diamond frame with + a top tube. The comment lied about the geometry directly under it. (2) The fork had NEGATIVE rake — + the axle sat behind the head tube's axis instead of ahead of it — so head tube and fork drew as one + straight pole down through the front wheel, which is a pogo stick with wheels. + **Both passed the Blender verify.** Dims, tri budget, node names, GLB round-trip: all green, both + times. A bounding box cannot answer "is this the right shape", and I'd have shipped a prop whose + silhouette was wrong while every number said fine. The step-through diagonal is the ONLY reason it + reads as a kid's bike from across a yard — at 20 m you can't judge absolute size, so the silhouette + has to carry it and scale can't. + +[E] 2026-07-19 — 🔒 **the lean now has an assert, and I proved it fails.** `e.test.js` pins the bars' + centre to z < −0.05 in browser coords plus the wheels to the ground. Nothing else can catch a flip: + the Blender verify re-imports through the same mapping and round-trips green, and the bounding box + is the identical size whichever way the thing tips. I rebuilt the bike at `lean = 0°` as a negative + control — **Blender said `[PASS] 1.15 × 0.31 × 0.71`, the selftest went red, 1 failed / 298 passed.** + That's the axis trap this file's header has warned about since Sprint 3, and it caught me writing a + docstring rather than writing geometry. + status: selftest **299/0/0** (296 + 3), Lane E is 67 asserts, **32/32 GLBs byte-identical across + three full factory runs** (`210c47b2…`), and the un-tilt/restore round-tripped to the same hash. + +[E] 2026-07-19 — standing by, not blocked: + · **the tree-move** — still yours to trigger, B. C's gain drop goes first (their standing offer), + and if the funnel still kills every $80 line after it, I move the tree, not the ratings. The + ratings are the site. + · **A — I built the cards before yours exist, on purpose.** SPRINT11 gate 2 specifies the shape + (client + brief + pay schedule) well enough to design against, and this is the same + design-ahead-and-hand-over move as the end cards, so the tool is a proposal you paste, edit, or + bin — I have not touched your files and won't. If your night-entry shape lands different, the + markup doesn't care: it lays out label/value pairs and a quote. + · **integrator, small one:** `.claude/launch.json` is tracked and hardcodes port 8815, so every + lane clone's dev server collides with every other and with the integration checkout. I worked + around it locally rather than committing a port change into lane/e. Worth a per-lane port or + `autoPort` at integration, before five lanes try to run a server at once. diff --git a/tools/blender/asset_report.json b/tools/blender/asset_report.json index 3dc7157..1d555e5 100644 --- a/tools/blender/asset_report.json +++ b/tools/blender/asset_report.json @@ -380,6 +380,24 @@ "status": "PASS", "problems": [] }, + { + "name": "bike_kid_01", + "dims": [ + 1.148, + 0.328, + 0.7269 + ], + "tris": 700, + "nodes": [ + "bars", + "bike_kid_01", + "frame", + "wheel_front", + "wheel_rear" + ], + "status": "PASS", + "problems": [] + }, { "name": "garden_gnome_01_broken", "dims": [ diff --git a/tools/blender/build_yard_assets.py b/tools/blender/build_yard_assets.py index 3a211af..9354f43 100644 --- a/tools/blender/build_yard_assets.py +++ b/tools/blender/build_yard_assets.py @@ -121,6 +121,8 @@ PAL = { "bristle": "#C9A659", # broom straw "hail_ice": "#DCEAF2", # hailstone "window_warm": "#FFC98A", # someone is home + "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 } @@ -1446,6 +1448,137 @@ def build_garden_gnome_01_broken(name): return root +def build_bike_kid_01(name): + """A kid's 16-inch bike, dropped against the fence (SPRINT11 §Lane E — the + per-client prop). The Hendersons have a kid; the kid has a bike; the bike is + against the fence because that is where bikes live. It exists to make the + job sheet's brief — "the seedlings have to be alive when we get back" — land + on a yard that visibly belongs to somebody. + + LEAN IS BAKED IN, and that's deliberate. A bike does not stand up on its own, + so an upright GLB would be a prop that reads as a bug the moment it's placed. + The whole thing is built through `_tilt()`, a rotation about X applied to + every point at author time. + + The origin is the ground line, and the tilt is about the X axis THROUGH that + origin — so the tyre contact points (z = 0) map to themselves and the bike + sits on the ground, not through it or above it. Drop it at ground level like + the gnome and rotate about the up axis to aim it. + + ** WHICH WAY IT LEANS — read this before you place it, Lane A. ** + In THIS file the bike leans toward +Y, because Blender is Z-up. You do not + work in Blender. The exporter maps Blender (x, y, z) -> glTF (x, z, -y), so + in three.js the bike leans toward **-Z**, and -Z is the side the fence goes. + Put the fence on +Z and the bike will lean away from it into thin air, which + is a bug that looks exactly like a physics bug and isn't one. + + I wrote "+Y is the fence side" here first and it would have been a lie by the + time it reached you — this is the axis trap the header of e.test.js exists to + catch, and it caught me writing the docstring, not the geometry. There's now + an assert in that file pinning the lean to -Z in browser coords, so if anyone + ever un-tilts this or flips the export, the suite says so instead of you + finding out by eye. Stand it ~0.10 m off the palings — the bars, not the + tyres, are what touch a fence. + + Not breakable and not priced: it's juice, not a trap. See my THREADS note if + you want it to be collateral — that's your call and it has a number in it. + """ + root = add_empty(name) + frame_m = get_material("Mat_BikeFrame", PAL["bike_kid"], 0.55) + tyre_m = get_material("Mat_BikeTyre", PAL["mat_black"], 0.9) + steel = get_material("Mat_SteelDark", PAL["steel_dark"], 0.5, metallic=0.6) + grip_m = get_material("Mat_BikeGrip", PAL["bike_grip"], 0.85) + + # The lean. −11° about X tips the bike toward +Y. Shallow on purpose: past + # about 15° it reads as "knocked over" rather than "parked", and this bike + # is meant to be resting, not already a casualty of the storm. + lean = math.radians(-11.0) + cl, sl = math.cos(lean), math.sin(lean) + + def _tilt(p): + x, y, z = p + return (x, y * cl - z * sl, y * sl + z * cl) + + R = 0.203 # 16" wheel: 406 mm diameter, the real kid-bike size + AX = 0.355 # axle from centre — 0.71 m wheelbase + + # --- wheels. arc_points gives me the rim in the upright XZ plane; every + # point then goes through _tilt, which is the same thing add_arc_tube does + # internally minus the lean. Ten segments reads round at yard distance and + # keeps the pair under ~450 tris. + for side, cx in (("rear", -AX), ("front", AX)): + pts = arc_points(R, 0, math.tau, 10, center=(cx, 0, R), plane='XZ') + segs = [add_tube_between(f"{name}_{side}_tyre_s{i}", _tilt(pts[i]), + _tilt(pts[(i + 1) % len(pts)]), 0.019, tyre_m, verts=6) + for i in range(len(pts))] + segs.append(add_tube_between(f"{name}_{side}_hub", _tilt((cx, -0.028, R)), + _tilt((cx, 0.028, R)), 0.016, steel, verts=6)) + join_group(segs, f"wheel_{side}", root) + + # --- frame. Upright coordinates, tilted on the way in. + # + # It's a STEP-THROUGH: the tube from the head drops to the MIDDLE of the seat + # tube instead of running level to the top of it. That dropped diagonal is + # the entire reason this reads as a kid's bike from across the yard rather + # than as a small adult one — at 20 m you cannot judge absolute size, so the + # silhouette has to carry it. Scale alone would not. + # + # First pass built a diamond frame while the comment above it claimed a + # step-through, and I only caught it by looking at the render — the dims + # passed either way, because "is it the right shape" is not something a + # bounding box can answer. Same lesson as the Sprint 10 phantom post. + bb = (-0.05, 0, 0.175) # bottom bracket + seat_top = (-0.165, 0, 0.600) + head_top = (0.245, 0, 0.615) + head_bot = (0.285, 0, 0.430) + # 45% up the seat tube — where the step-through's diagonal lands. + step_join = (-0.102, 0, 0.366) + + tubes = [ + ("seat_tube", bb, seat_top, 0.017), + ("down_tube", bb, head_bot, 0.018), + ("step_tube", head_top, step_join, 0.015), # the step-through drop + ("chain_stay", bb, (-AX, 0, R), 0.012), + ("seat_stay", seat_top, (-AX, 0, R), 0.011), + # The fork is RAKED — the axle sits ~21 mm FORWARD of the head tube's + # axis extended to axle height. Sign matters and I got it backwards + # first: with the axle behind that line, head tube and fork drew as one + # straight pole down through the front wheel, which is a pogo stick with + # wheels. Rake is what puts the bend in the silhouette. + ("fork", head_bot, (AX, 0, R), 0.013), + ("head_tube", head_bot, head_top, 0.016), + ] + parts = [add_tube_between(f"{name}_{n}", _tilt(a), _tilt(b), r, frame_m, verts=6) + for (n, a, b, r) in tubes] + join_group(parts, "frame", root) + + # --- the bits a kid actually touches. Bars sit across Y; the grips are the + # only two things on this bike that are worn, and they're the reason it + # reads as USED rather than as a showroom asset dropped in a yard. + bars = [ + add_tube_between(f"{name}_stem", _tilt(head_top), _tilt((0.225, 0, 0.685)), + 0.013, steel, verts=6), + add_tube_between(f"{name}_bar", _tilt((0.225, -0.155, 0.685)), + _tilt((0.225, 0.155, 0.685)), 0.012, steel, verts=6), + add_tube_between(f"{name}_grip_l", _tilt((0.225, -0.155, 0.685)), + _tilt((0.225, -0.095, 0.685)), 0.016, grip_m, verts=6), + add_tube_between(f"{name}_grip_r", _tilt((0.225, 0.095, 0.685)), + _tilt((0.225, 0.155, 0.685)), 0.016, grip_m, verts=6), + add_tube_between(f"{name}_saddle", _tilt((-0.200, 0, 0.615)), + _tilt((-0.130, 0, 0.622)), 0.030, grip_m, verts=6), + add_tube_between(f"{name}_crank", _tilt((-0.05, -0.075, 0.175)), + _tilt((-0.05, 0.075, 0.175)), 0.010, steel, verts=6), + ] + join_group(bars, "bars", root) + + stamp(root, name, "prop") + # A 16" kid's bike is ~8 kg. Lane A/C: this is here so the wind CAN have it + # later — a bike that ignores a 32 m/s gust is a worse lie than no bike. + root["mass_hint"] = 8.0 + root["breakable"] = False + return root + + def build_hail_stone_01(name): """One hailstone, ~22 mm (SPRINT5 §Lane E-1, "stone mesh if C wants geometry over sprites"). @@ -2193,6 +2326,12 @@ ASSETS = [ dict(name="garden_gnome_01", fn=build_garden_gnome_01, dims=((0.10, 0.20), (0.10, 0.20), (0.33, 0.42)), nodes=["gnome"]), + # The per-client prop. Leaning, so the Y span is wider and the Z shorter than + # an upright bike's — that asymmetry IS the lean, and if it ever measures + # square again someone has quietly un-tilted it. + dict(name="bike_kid_01", fn=build_bike_kid_01, + dims=((1.00, 1.25), (0.24, 0.52), (0.60, 0.84)), + nodes=["wheel_rear", "wheel_front", "frame", "bars"]), # Aftermath wreckage (SPRINT3 §Lane E-2). Each keeps its intact twin's origin # and footprint so Lane A swaps in place. dict(name="garden_gnome_01_broken", fn=build_garden_gnome_01_broken, diff --git a/tools/blender/contact_sheet.png b/tools/blender/contact_sheet.png index 79d6b91..3199653 100644 Binary files a/tools/blender/contact_sheet.png and b/tools/blender/contact_sheet.png differ diff --git a/tools/jobsheet/index.html b/tools/jobsheet/index.html new file mode 100644 index 0000000..8508d29 --- /dev/null +++ b/tools/jobsheet/index.html @@ -0,0 +1,408 @@ + + +
+ +