Lane E, Sprint 11: client juice — job sheet, invoice, and the kid's bike
The letterhead is the point: one business, two documents, same masthead, so the job sheet and the invoice read as a pair of papers off the same outfit's printer rather than two game screens. DESIGN.md has opened with "you run a small landscaping outfit" since day one and it has never been on the glass. tools/jobsheet/ — paste-ready for Lane A between CUT markers, purely additive (.card/.row/.ledger untouched), rendered to four PNGs before handover, each state addressable as ?v=. The ledger already WAS an invoice; it just never said so. The line worth keeping is .void: a bonus that was on the job sheet and didn't survive the night, struck through in the same column as the money that did — a bonus you never watch fail is one you never learn to protect. Two design calls left as A's with the reasoning beside them, same as the carport's $180: the business name (proposal: HARD YARDS) and the ABN (sequential digits on purpose — a plausible one could be a real business's). bike_kid_01.glb — 1.15 x 0.33 x 0.74 m, 700 tris, lean baked in because a bike doesn't stand up on its own. Two bugs caught by LOOKING at the render, neither catchable by the existing suite: a comment claiming a step-through above a diamond frame, and a fork with negative rake that drew head tube and fork as one pole through the front wheel. Both passed the Blender verify — a bounding box can't answer "is this the right shape". The lean is an export-time axis fact (+Y in Blender is -Z in three.js), so e.test.js now pins it in browser coords. Proved the assert fails: rebuilt at lean=0 and Blender still said PASS while the selftest went red. selftest 299/0/0. 32/32 GLBs byte-identical across three full factory runs.
This commit is contained in:
parent
b38311021c
commit
8ec01028f9
93
THREADS.md
93
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.
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.3 MiB |
408
tools/jobsheet/index.html
Normal file
408
tools/jobsheet/index.html
Normal file
@ -0,0 +1,408 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SHADES — job sheet + invoice preview (Lane E → Lane A)</title>
|
||||
<style>
|
||||
/* ==========================================================================
|
||||
LANE A — SPRINT 11 GATE 2. Everything between the CUT markers is yours to
|
||||
paste into hud.js. Same deal as the end cards last sprint: rendered and
|
||||
screenshotted before it was handed over, so it's a paste rather than a
|
||||
design job. Take it, edit it, or bin it.
|
||||
|
||||
It is deliberately ADDITIVE. I have not touched .card / .row / .ledger —
|
||||
every rule below is a new class, so the cards keep working exactly as they
|
||||
do today if you paste this and wire nothing. The job sheet is the forecast
|
||||
card wearing a letterhead; the invoice is your .ledger wearing one too.
|
||||
Your existing markup slots in underneath both.
|
||||
|
||||
THE TWO DESIGN CALLS IN HERE ARE YOURS, NOT MINE — see BUSINESS and ABN
|
||||
at the top of the script block. My proposals are there with the reasoning
|
||||
beside them so you can argue with them, same as the carport's $180.
|
||||
====================================================================== */
|
||||
|
||||
/* ---- CUT HERE ---------------------------------------------------------- */
|
||||
|
||||
/* --- the letterhead ----------------------------------------------------
|
||||
One business, two documents. This is the whole trick of the sprint: the
|
||||
job sheet and the invoice carry the SAME letterhead, so they read as a
|
||||
pair of papers off the same outfit's printer rather than as two game
|
||||
screens. The player's business is the constant; the client changes every
|
||||
night. That's the framing DESIGN.md opens with — "you run a small
|
||||
landscaping outfit" — finally showing up on the glass.
|
||||
|
||||
Sits above the existing .card content, inside the same .card box. */
|
||||
#hud-card .letterhead {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: 16px; margin: -4px 0 14px; padding-bottom: 10px;
|
||||
border-bottom: 2px solid #2f3f4a;
|
||||
}
|
||||
#hud-card .letterhead .mark {
|
||||
font-weight: 800; font-size: 15px; letter-spacing: .22em; color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The trade line does the work a logo would. Lowercase and spaced out so it
|
||||
reads as printed rule, not as a heading competing with the mark. */
|
||||
#hud-card .letterhead .trade {
|
||||
font-size: 10px; letter-spacing: .13em; color: #6d818e; margin-top: 3px;
|
||||
}
|
||||
#hud-card .letterhead .docket { text-align: right; white-space: nowrap; }
|
||||
#hud-card .letterhead .docket .kind {
|
||||
font-weight: 700; font-size: 11px; letter-spacing: .18em; color: #8ba0ad;
|
||||
}
|
||||
#hud-card .letterhead .docket .no { font-size: 10px; color: #55677a; letter-spacing: .06em; }
|
||||
|
||||
/* --- bill-to / job-for block -------------------------------------------
|
||||
Deliberately the same component on both documents. On the job sheet it's
|
||||
who you're working for; on the invoice it's who's paying. Same box, and
|
||||
the fact that it doesn't move between the two is the joke landing. */
|
||||
#hud-card .billto { margin: 0 0 12px; }
|
||||
#hud-card .billto .who { font-weight: 700; color: #fff; letter-spacing: .04em; }
|
||||
#hud-card .billto .addr { font-size: 11px; color: #6d818e; }
|
||||
|
||||
/* --- the brief ---------------------------------------------------------
|
||||
The client's own words. Italic and rule-marked because it is the one bit
|
||||
of text on the card that isn't the game talking to the player — it's a
|
||||
person who wants their seedlings alive. It should feel quoted, and it
|
||||
should be the thing the player's eye lands on after the letterhead,
|
||||
because it is the only place the stakes are ever said out loud. */
|
||||
#hud-card .brief {
|
||||
margin: 0 0 14px; padding: 9px 0 9px 12px;
|
||||
border-left: 2px solid #3f5561;
|
||||
color: #c3d0d9; font-style: italic; line-height: 1.6;
|
||||
}
|
||||
#hud-card .brief .attrib {
|
||||
display: block; margin-top: 5px; font-style: normal;
|
||||
font-size: 10px; letter-spacing: .1em; color: #55677a;
|
||||
}
|
||||
|
||||
/* --- section rules ------------------------------------------------------ */
|
||||
#hud-card .sect {
|
||||
margin: 16px 0 7px; font-size: 10px; font-weight: 700; letter-spacing: .18em;
|
||||
color: #55677a; border-bottom: 1px solid #1c262d; padding-bottom: 5px;
|
||||
}
|
||||
|
||||
/* --- the pay schedule (job sheet) --------------------------------------
|
||||
This is a QUOTE, not a receipt: it's what the night is worth if you do it
|
||||
right. It reuses your .row shape so it lines up with everything else, but
|
||||
the money is dimmed — these are amounts you have NOT earned yet, and they
|
||||
should not read with the same confidence as the bank line. The conditions
|
||||
are the point; the numbers are just what the conditions are worth. */
|
||||
#hud-card .sched .row b { color: #9fb3bf; font-weight: 700; }
|
||||
#hud-card .sched .cond {
|
||||
font-size: 10px; color: #55677a; letter-spacing: .02em;
|
||||
padding: 0 0 5px; border-bottom: 1px solid #1c262d; margin-top: -2px;
|
||||
}
|
||||
#hud-card .sched .row.headline b { color: #fff; }
|
||||
#hud-card .sched .row.max { border-bottom: 0; margin-top: 6px; padding-top: 7px;
|
||||
border-top: 1px solid #24343d; }
|
||||
#hud-card .sched .row.max span { color: #8ba0ad; }
|
||||
#hud-card .sched .row.max b { color: #ffd9a3; }
|
||||
|
||||
/* --- the invoice -------------------------------------------------------
|
||||
Your .ledger already IS an invoice; it just never said so. These rules sit
|
||||
ON TOP of it — paste them and .ledger keeps every behaviour it has, it
|
||||
just gains a rule above the total and a due line under it.
|
||||
|
||||
.earned/.bad you already colour. The one thing I've added is .void: a
|
||||
bonus that was on the job sheet and did NOT survive the night. Struck
|
||||
through, not hidden. The player must see the money they didn't get sitting
|
||||
in the same column as the money they did — a bonus you never see fail is a
|
||||
bonus you never learn to protect. This is the single highest-value line in
|
||||
the whole handover, and it's four CSS declarations. */
|
||||
#hud-card .ledger .row.void span { color: #55677a; }
|
||||
#hud-card .ledger .row.void b {
|
||||
color: #55677a; font-weight: 400; text-decoration: line-through;
|
||||
text-decoration-color: #7d2b2b; text-decoration-thickness: 1px;
|
||||
}
|
||||
/* The amount due. An invoice's whole rhetorical shape is that everything
|
||||
above is argument and this is the conclusion. Give it the weight. */
|
||||
#hud-card .ledger .row.due {
|
||||
margin-top: 8px; padding-top: 9px; border-top: 2px solid #2f3f4a; border-bottom: 0;
|
||||
align-items: baseline;
|
||||
}
|
||||
#hud-card .ledger .row.due span { color: #8ba0ad; letter-spacing: .12em; font-weight: 700;
|
||||
font-size: 11px; }
|
||||
#hud-card .ledger .row.due b { color: #fff; font-size: 20px; letter-spacing: .02em; }
|
||||
#hud-card .ledger .row.due.neg b { color: #ff8f86; }
|
||||
|
||||
/* The terms line. Pure flavour, and the cheapest joke on the card: a real
|
||||
invoice's most boring sentence, printed under a night that nearly killed
|
||||
you. Left in as its own class so you can delete it in one line if it
|
||||
doesn't land. */
|
||||
#hud-card .terms {
|
||||
margin-top: 10px; font-size: 10px; color: #45566a; letter-spacing: .04em;
|
||||
}
|
||||
/* ---- CUT HERE ---------------------------------------------------------- */
|
||||
|
||||
/* ---- preview chrome below; NOT part of the snippet --------------------- */
|
||||
body { margin: 0; background: #060a0d; }
|
||||
/* padding-top clears the toggle bar so a headless screenshot of any state is
|
||||
a clean handover artifact rather than one with buttons through the
|
||||
letterhead. Learned that the hard way on the first render. */
|
||||
#hud-card { position: static !important; display: grid !important; place-items: start center;
|
||||
padding: 74px 0 26px; }
|
||||
.bar { position: fixed; z-index: 60; left: 50%; transform: translateX(-50%); top: 14px;
|
||||
display: flex; gap: 8px; }
|
||||
.bar button { font: 12px ui-monospace, Menlo, monospace; padding: 8px 12px; cursor: pointer; }
|
||||
.lbl { position: fixed; left: 14px; bottom: 12px; z-index: 60;
|
||||
font: 11px ui-monospace, Menlo, monospace; color: #55677a; letter-spacing: .1em; }
|
||||
</style>
|
||||
<style id="hudcss">
|
||||
/* Lifted verbatim from hud.js so the preview renders in the real card's
|
||||
skin. Do NOT paste this block — you already have it. It's here so that
|
||||
what I screenshotted is what you'll actually see. */
|
||||
#hud-card { inset: 0; z-index: 30; place-items: center;
|
||||
font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #dde5ea; }
|
||||
#hud-card .card { background: #0d1418; border: 1px solid #2f3f4a; border-radius: 9px;
|
||||
padding: 22px 26px; min-width: 440px; max-width: 620px; pointer-events: auto; }
|
||||
#hud-card h1 { margin: 0 0 2px; font-size: 19px; letter-spacing: .16em; color: #fff; }
|
||||
#hud-card h2 { margin: 0 0 16px; font-size: 12px; font-weight: 400; color: #8ba0ad;
|
||||
letter-spacing: .04em; }
|
||||
#hud-card .row { display: flex; justify-content: space-between; gap: 18px; padding: 3px 0;
|
||||
border-bottom: 1px solid #1c262d; }
|
||||
#hud-card .row b { font-weight: 700; color: #fff; }
|
||||
#hud-card .stat { color: #8ba0ad; }
|
||||
#hud-card .go { margin-top: 16px; padding: 9px 18px; background: #1d3d2a; border: 1px solid #3f7a52;
|
||||
border-radius: 6px; color: #a8f0b8; cursor: pointer; font: inherit; font-weight: 700;
|
||||
letter-spacing: .1em; }
|
||||
#hud-card .verdict { margin: 14px 0 0; padding: 10px 12px; border-radius: 6px; font-weight: 700;
|
||||
letter-spacing: .05em; }
|
||||
#hud-card .verdict.win { background: #12321c; border: 1px solid #2c6b3c; color: #7fce6a; }
|
||||
#hud-card .verdict.lose { background: #3a1618; border: 1px solid #7d2b2b; color: #ff8f86; }
|
||||
#hud-card .pips { letter-spacing: .5em; font-size: 15px; margin: -6px 0 10px; color: #3f5561; }
|
||||
#hud-card .pip.now { color: #7ee0ff; }
|
||||
#hud-card .pip.held { color: #7fce6a; }
|
||||
#hud-card .pip.lost { color: #ff8f86; }
|
||||
#hud-card .ledger { margin-top: 14px; padding-top: 10px; border-top: 1px solid #24343d; }
|
||||
#hud-card .ledger .row b { color: #a8f0b8; }
|
||||
#hud-card .ledger .row.bad b { color: #ff8f86; }
|
||||
#hud-card .ledger .row.total { margin-top: 6px; padding-top: 8px; border-top: 1px solid #24343d; }
|
||||
#hud-card .ledger .row.total b { color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="lbl" id="lbl">JOB SHEET · night 1</div>
|
||||
<div class="bar">
|
||||
<button onclick="show('sheet1')">job sheet n1</button>
|
||||
<button onclick="show('sheet3')">job sheet n3 (corner block)</button>
|
||||
<button onclick="show('inv_good')">invoice · clean</button>
|
||||
<button onclick="show('inv_bad')">invoice · carport</button>
|
||||
</div>
|
||||
|
||||
<div id="hud-card" class="on"><div id="slot"></div></div>
|
||||
|
||||
<script>
|
||||
/* ==========================================================================
|
||||
LANE A — THE TWO DESIGN CALLS. Both are yours. Reasoning is here so you can
|
||||
overrule it from an informed position rather than just accepting my taste.
|
||||
====================================================================== */
|
||||
|
||||
/* CALL 1 — THE BUSINESS NAME. A letterhead needs an outfit on it, and we've
|
||||
never named the player's business. My proposal is HARD YARDS.
|
||||
|
||||
Reasoning: it's a real Australian idiom ("doing the hard yards" = the
|
||||
unglamorous grind that nobody thanks you for), it is literally about yards,
|
||||
and it's the joke the game is ALREADY telling — DESIGN.md says you never
|
||||
build the right thing, you build the least wrong thing, and THREADS' own
|
||||
line for the win card is "nobody thanks you for the storm that did nothing.
|
||||
That's the job." That is the hard yards. It's one word at letterhead size,
|
||||
which is what a mark needs to be, and it stays funny on an invoice for $180
|
||||
of someone else's carport.
|
||||
|
||||
What I deliberately did NOT do: name it after the player. A surname mark
|
||||
("<NAME> LANDSCAPING") is the most authentic thing a one-person outfit
|
||||
would actually 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's not mine to make in a juice pass. If Sprint 12 names them, this
|
||||
constant is the only thing that changes. */
|
||||
const BUSINESS = { mark: 'HARD YARDS', trade: 'landscaping · shade · drainage' };
|
||||
|
||||
/* CALL 2 — THE ABN. An Australian invoice without an ABN is not an invoice;
|
||||
it's the single detail that makes the paper read as real to anyone who's
|
||||
ever received one. But any plausible 11-digit number is potentially a REAL
|
||||
business's ABN, and I'm not printing a stranger's tax identity on a game
|
||||
card. So: sequential digits, which every Australian reader parses instantly
|
||||
as "example" — the same convention as 555 phone numbers. Deliberate, not
|
||||
lazy. Please don't "fix" it into something realistic. */
|
||||
const ABN = 'ABN 12 345 678 901';
|
||||
|
||||
/* ==========================================================================
|
||||
Lane A: swap this for your real night entries. The markup doesn't care what
|
||||
the rows are — it lays out label/value pairs and a brief. Shape below is
|
||||
what SPRINT11 gate 2 asks for: client + brief + pay schedule, as data.
|
||||
|
||||
Note the pay schedule is a LIST, not three named fields. base/garden/
|
||||
no-collateral is what Sprint 11 specifies, but a list costs nothing now and
|
||||
means a site that wants a fourth condition ("nothing tied to the carport")
|
||||
doesn't need a card change — which, per SPRINT10, is the whole point of
|
||||
sites being data.
|
||||
====================================================================== */
|
||||
const NIGHTS = {
|
||||
sheet1: {
|
||||
kind: 'sheet', night: 1, nights: 5, docket: 'JOB 1041',
|
||||
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
|
||||
brief: 'The seedlings have to be alive when we get back. We fly out Friday and the beds are three weeks in.',
|
||||
attrib: '— R. Henderson, on the phone, Monday',
|
||||
storm: 'SOUTHERLY BUSTER', night_flag: false,
|
||||
lines: ['gusting to 21 m/s, building after 9', 'rain: steady, 12 mm', null],
|
||||
note: 'Nothing tonight can hurt the garden. Learn the anchors.',
|
||||
bank: 95,
|
||||
sched: [
|
||||
['callout fee', '$80', 'the night, rigged and stood up', true],
|
||||
['garden bonus', '$40', 'bed above 80% at dawn'],
|
||||
['clean site', '$25', 'nothing of theirs broken'],
|
||||
],
|
||||
max: 145,
|
||||
},
|
||||
sheet3: {
|
||||
kind: 'sheet', night: 3, nights: 5, docket: 'JOB 1043',
|
||||
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
|
||||
site: 'the corner block — two streets, no shelter, and a carport that wants to be an anchor',
|
||||
brief: "Whatever you tie to, don't tie to the carport. It's older than it looks and I'm not paying to put it back up.",
|
||||
attrib: '— D. Okafor, written on the job docket',
|
||||
storm: 'EARLY BUSTER', night_flag: true,
|
||||
lines: ['gusting to 32 m/s, funnelled', 'rain: heavy · hail forecast', 'confidence: ±4 m/s (lead 0)'],
|
||||
note: 'Hail is what kills a garden, and cloth stops hail. Get the sail over the bed.',
|
||||
bank: 210,
|
||||
sched: [
|
||||
['callout fee', '$80', 'the night, rigged and stood up', true],
|
||||
['garden bonus', '$40', 'bed above 80% at dawn'],
|
||||
['clean site', '$25', 'nothing of theirs broken'],
|
||||
],
|
||||
max: 145,
|
||||
},
|
||||
inv_good: {
|
||||
kind: 'invoice', night: 1, docket: 'INV 1041',
|
||||
client: 'R. & M. Henderson', addr: '14 Kurrajong St — backyard',
|
||||
subtitle: 'The bed came through it. So did the rig.',
|
||||
rows: [['garden', '94%'], ['corners intact', '4/4'], ['what got through', 'nothing'],
|
||||
['hardware lost', 'none'], ['collateral', 'none']],
|
||||
ledger: [
|
||||
['callout fee', '+$80'],
|
||||
['garden bonus', '+$40'],
|
||||
['clean site', '+$25'],
|
||||
['gear recovered', '+$18'],
|
||||
['spent on the rig', '−$62', 'bad'],
|
||||
],
|
||||
due: '+$101', bank: '$95 → $196',
|
||||
verdict: "Nobody thanks you for the storm that did nothing. That's the job.",
|
||||
win: true,
|
||||
},
|
||||
inv_bad: {
|
||||
kind: 'invoice', night: 3, docket: 'INV 1043',
|
||||
client: 'D. Okafor', addr: '2 Bight Rd — corner block',
|
||||
subtitle: 'The garden made it. The carport went to the neighbours.',
|
||||
rows: [['garden', '86%'], ['corners intact', '3/4'], ['what got through', 'some hail'],
|
||||
['hardware lost', '$34'], ['collateral', 'the carport ($180)']],
|
||||
ledger: [
|
||||
['callout fee', '+$80'],
|
||||
['garden bonus', '+$40'],
|
||||
['clean site', '$25', 'void'],
|
||||
['gear recovered', '+$6'],
|
||||
['the carport', '−$180', 'bad'],
|
||||
['spent on the rig', '−$71', 'bad'],
|
||||
],
|
||||
due: '−$125', neg: true, bank: '$210 → $85',
|
||||
verdict: 'You took the carport with you.',
|
||||
win: false,
|
||||
terms_extra: "They'll want that back up by Thursday.",
|
||||
},
|
||||
};
|
||||
|
||||
/* --- renderers. Lane A: these are throwaway — your hud.js already builds the
|
||||
rows; all you need from here is the MARKUP SHAPE and the class names. ---- */
|
||||
|
||||
function letterhead(n) {
|
||||
return `<div class="letterhead">
|
||||
<div>
|
||||
<div class="mark">${BUSINESS.mark}</div>
|
||||
<div class="trade">${BUSINESS.trade}</div>
|
||||
</div>
|
||||
<div class="docket">
|
||||
<div class="kind">${n.kind === 'sheet' ? 'JOB SHEET' : 'TAX INVOICE'}</div>
|
||||
<div class="no">${n.docket} · ${ABN}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function billto(n) {
|
||||
return `<div class="billto">
|
||||
<div class="who">${n.client}</div>
|
||||
<div class="addr">${n.addr}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function brief(n) {
|
||||
return `<div class="brief">${n.brief}<span class="attrib">${n.attrib}</span></div>`;
|
||||
}
|
||||
|
||||
function pips(night, nights) {
|
||||
return Array.from({ length: nights }, (_, i) => {
|
||||
const done = i < night - 1, now = i === night - 1;
|
||||
return `<span class="pip ${now ? 'now' : done ? 'held' : ''}">${now ? '◆' : done ? '●' : '·'}</span>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSheet(n) {
|
||||
const site = n.site ? `<div class="stat" style="color:#7ee0ff">${n.site}</div>` : '';
|
||||
const sched = n.sched.map(([k, v, cond, head]) =>
|
||||
`<div class="row ${head ? 'headline' : ''}"><span>${k}</span><b>${v}</b></div>
|
||||
<div class="cond">${cond}</div>`).join('');
|
||||
return `<div class="card">
|
||||
${letterhead(n)}
|
||||
${billto(n)}
|
||||
${brief(n)}
|
||||
<h1>NIGHT ${n.night} OF ${n.nights}</h1>
|
||||
<div class="pips">${pips(n.night, n.nights)}</div>
|
||||
<h2>${n.storm}${n.night_flag ? ' · NIGHT' : ''}</h2>
|
||||
${site}
|
||||
${n.lines.filter(Boolean).map((l) => `<div class="stat">${l}</div>`).join('')}
|
||||
<div class="sect">WHAT IT PAYS</div>
|
||||
<div class="sched">
|
||||
${sched}
|
||||
<div class="row max"><span>the night, done right</span><b>$${n.max}</b></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:14px"><span>in the bank</span><b>$${n.bank}</b></div>
|
||||
<div class="stat" style="color:#8ba0ad;margin-top:10px">${n.note}</div>
|
||||
<button class="go">RIG IT</button>
|
||||
<div class="terms">Quoted on the forecast at time of callout. Weather is not a variation.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderInvoice(n) {
|
||||
const rows = n.rows.map(([k, v]) => `<div class="row"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
const ledger = n.ledger.map(([k, v, cls]) =>
|
||||
`<div class="row ${cls || ''}"><span>${k}</span><b>${v}</b></div>`).join('');
|
||||
return `<div class="card">
|
||||
${letterhead(n)}
|
||||
${billto(n)}
|
||||
<h1>MORNING · NIGHT ${n.night} OF 5</h1>
|
||||
<h2>${n.subtitle}</h2>
|
||||
${rows}
|
||||
<div class="ledger">
|
||||
${ledger}
|
||||
<div class="row due ${n.neg ? 'neg' : ''}"><span>AMOUNT DUE</span><b>${n.due}</b></div>
|
||||
<div class="row total"><span>in the bank</span><b>${n.bank}</b></div>
|
||||
</div>
|
||||
<div class="verdict ${n.win ? 'win' : 'lose'}">${n.verdict}</div>
|
||||
<button class="go">NIGHT ${n.night + 1} →</button>
|
||||
<div class="terms">Payable 14 days. ${n.terms_extra || 'Thank you for your business.'}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function show(which) {
|
||||
const n = NIGHTS[which];
|
||||
document.getElementById('slot').innerHTML =
|
||||
n.kind === 'sheet' ? renderSheet(n) : renderInvoice(n);
|
||||
document.getElementById('lbl').textContent =
|
||||
(n.kind === 'sheet' ? 'JOB SHEET · night ' : 'INVOICE · night ') + n.night;
|
||||
}
|
||||
/* ?v=sheet3 etc. — so a state is a URL. That's how the screenshots in
|
||||
tools/jobsheet/*.png were taken (headless), and it means you can link me a
|
||||
broken one rather than describe it. */
|
||||
show(new URLSearchParams(location.search).get('v') || 'sheet1');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
tools/jobsheet/preview_inv_bad.png
Normal file
BIN
tools/jobsheet/preview_inv_bad.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
BIN
tools/jobsheet/preview_inv_good.png
Normal file
BIN
tools/jobsheet/preview_inv_good.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
BIN
tools/jobsheet/preview_sheet1.png
Normal file
BIN
tools/jobsheet/preview_sheet1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
tools/jobsheet/preview_sheet3.png
Normal file
BIN
tools/jobsheet/preview_sheet3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
@ -85,6 +85,8 @@ const ASSETS = [
|
||||
'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
|
||||
{ name: 'carport_01_wrecked', h: [1.9, 2.45],
|
||||
nodes: ['footings', 'posts', 'beams', 'roof_down'] },
|
||||
{ name: 'bike_kid_01', h: [0.60, 0.84],
|
||||
nodes: ['wheel_rear', 'wheel_front', 'frame', 'bars'] },
|
||||
];
|
||||
|
||||
function sizeOf(gltf) {
|
||||
@ -185,6 +187,37 @@ export default async function run(t) {
|
||||
assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
|
||||
});
|
||||
|
||||
// A bike does not stand up on its own, so the lean is baked into the GLB
|
||||
// rather than left as a rotation for Lane A to remember. That makes the lean
|
||||
// DIRECTION an export-time fact, and export-time facts about axes are exactly
|
||||
// what this file exists to catch (see the header): Blender is Z-up and the
|
||||
// bike leans +Y there, but the exporter maps (x,y,z) -> (x,z,-y), so by the
|
||||
// time A places it in three.js it leans -Z.
|
||||
//
|
||||
// Nothing else can catch this. The Blender-side verify re-imports through the
|
||||
// same mapping and round-trips green, and the bounding box is the identical
|
||||
// size whichever way the thing tips. If someone flips the export convention or
|
||||
// quietly un-tilts the bike, the yard still loads, the dims still pass, and the
|
||||
// only symptom is a bike leaning into open air next to a fence — which reads as
|
||||
// a physics bug and would get chased in the wrong lane entirely.
|
||||
t.test('the kid\'s bike leans, and it leans toward -Z (fence side)', () => {
|
||||
const g = loaded.get('bike_kid_01');
|
||||
assert(g, 'bike_kid_01 did not load');
|
||||
const bars = g.scene.getObjectByName('bars');
|
||||
assert(bars, 'bike_kid_01 has no bars group to measure the lean by');
|
||||
|
||||
// The bars are the top of the bike, so they travel furthest when it tips.
|
||||
const b = new THREE.Box3().setFromObject(bars);
|
||||
const z = (b.max.z + b.min.z) / 2;
|
||||
assert(z < -0.05, `bars sit at z=${z.toFixed(3)} — the bike is upright or leaning the wrong way`);
|
||||
|
||||
// ...and it must still be ON the ground: the tyre contact points are the
|
||||
// pivot, so an un-pivoted lean would push the wheels through or above it.
|
||||
const all = new THREE.Box3().setFromObject(g.scene);
|
||||
assert(Math.abs(all.min.y) < 0.03,
|
||||
`bike sits at y=${all.min.y.toFixed(3)} — wheels through the ground or floating`);
|
||||
});
|
||||
|
||||
t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
|
||||
const g = loaded.get('carport_01');
|
||||
assert(g, 'carport_01 did not load');
|
||||
|
||||
BIN
web/world/models/bike_kid_01_v1.glb
Normal file
BIN
web/world/models/bike_kid_01_v1.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user