clone

an endless marble-race generator in the browser; the url is the seed, so every track can be shared

Pillar vocabulary: relational composition of kit support pieces

Dami committed d49e512
USER rule: pillars are PIECES of different widths, heights and patterns
— compose them, don't repeat one column. gen/supports.ts is the new
shared vocabulary (finite assembly, endless viaducts, spur decks):

- Measured table: all segments 0.5 tall (tops 0.7, cradle at 0.5);
  footprints four 0.9x0.9 / two 0.9x0.2 / single 0.4x0.4 (bottoms
  1.0/1.0x0.3/0.6 skirts).
- RELATIONAL LAW, containment upward: a piece stands only on a piece
  whose footprint contains it — four carries everything, two carries
  two, single never stands on the thin two frame. 'bottom' segments are
  feet at contact AND mid-stack transition collars where their skirt
  fits; else the kind change is a plain middle.
- Dynamic families over the law: uniform (seed's style kind), off-kind
  uniform, tapered towers (four base run -> collar -> slender upper).
  Drawn per stack from a position hash — the structural greedy stays
  rng-free (standing user rule). New kinds = table rows.
- tests/supports_test.ts pins the law (200-stack sweep), exact heights
  (cradle at channel base, plain flush, feet at contact), determinism,
  and cross-stack variety (all kinds + tapered towers appear).

BREAKING (knowingly): support placements change in both modes —
golden_tracks.json regenerated (genVersion g535114c0fc9e ->
g491fc4c7a685), endless goldens re-pinned. 157 tests, check clean.
commit
d49e512270684922fe9a5ed5de88814ac94a1535
parent
4ea8636d1842a0e14613a28154f58e2ca108c4cb
author
Dami <dami.etoile@gmail.com>
6 files changed, +262 -63
Msrc/gen/assemble.ts45+---------
Asrc/gen/supports.ts148++++++++++++++++++++++++++++++
Msrc/gen/takeable.ts18+++-
Mtests/golden_endless.json24++--
Mtests/golden_tracks.json8+-
Atests/supports_test.ts82+++++++++++++++++
M src/gen/assemble.ts +3 -42
@@ -23,6 +23,7 @@ import {
2323 xformSeg,2424 } from "./geom.ts";2525 import { windowCells } from "./registry.ts";26+import { emitSupportStack } from "./supports.ts";2627 2728 export interface AssembledTrack {2829 placements: Placement[];
@@ -130,8 +131,6 @@ export function assemble(
130131 };131132 132133 const supportPrefix = style.supportColor ? "support-color" : "support";133- const supportId = (segment: string) =>134- `${supportPrefix}-${style.supportKind}-${segment}`;135134 const supportCandidates: {136135 x: number;137136 z: number;
@@ -357,7 +356,8 @@ export function assemble(
357356 last = cand;358357 emitSupportStack(359358 placements,360- supportId,359+ supportPrefix,360+ style.supportKind,361361 cand.x,362362 cand.z,363363 cand.height,
@@ -397,45 +397,6 @@ function nearestOnCurve(
397397 return best;398398 }399399 400-export function emitSupportStack(401- placements: Placement[],402- supportId: (segment: string) => string,403- x: number,404- z: number,405- height: number,406- dir: Dir,407- cradle: boolean,408- /** Stack base elevation (default ground; viaduct piers stand on a409- * deck — every piece lifts by this). */410- baseAt = 0,411-) {412- // Stack grid: 0.5 per segment; top piece is 0.7 tall with its cradle at413- // 0.5, so it always sits at height − 0.5. Plain columns (no cradle) end414- // flush at the deck base, touching the channel underside.415- const rotation = ((1 - dir + 4) % 4) as Dir;416- const pieces: { seg: string; y: number }[] = [];417- if (cradle) {418- pieces.push({ seg: "top", y: height - 0.5 });419- if (height >= 1.0) pieces.push({ seg: "bottom", y: 0 });420- for (let y = 0.5; y < height - 0.5 - 1e-9; y += 0.5) {421- pieces.push({ seg: "middle", y });422- }423- } else {424- pieces.push({ seg: "bottom", y: 0 });425- for (let y = 0.5; y < height - 1e-9; y += 0.5) {426- pieces.push({ seg: "middle", y });427- }428- }429- for (const p of pieces) {430- placements.push({431- pieceId: supportId(p.seg),432- pos: [x, p.y + baseAt, z],433- rotation,434- role: "support",435- });436- }437-}438-439400 /** Constant-speed sampling along the loop (s in world units, wraps). */440401 export function sampleLoop(441402 loop: LoopPath,
A src/gen/supports.ts +148 -0
@@ -0,0 +1,148 @@
1+/**2+ * Pillar vocabulary (USER rule: pillars are PIECES of different widths,3+ * heights and patterns — compose them relationally, don't repeat one4+ * column). Dynamic families over one measured law, shared by finite5+ * assembly, endless viaducts, and takeable spur decks.6+ *7+ * Measured kit facts (2026-08-09): every support segment is 0.5 tall8+ * (tops 0.7 with the cradle at 0.5); footprints (w×d):9+ * four bottom 1.0×1.0 middle 0.9×0.9 (4-leg tower)10+ * two bottom 1.0×0.3 middle 0.9×0.2 (flat 2-leg frame)11+ * single bottom 0.6×0.6 middle 0.4×0.4 (slender post)12+ *13+ * RELATIONAL LAW — containment upward: a piece may stand on the piece14+ * below only if its footprint fits inside it (ε 0.05). four carries15+ * everything, two carries two, single carries single; single NEVER16+ * stands on two (a 0.4-deep post on a 0.2-deep frame floats). `bottom`17+ * segments are feet at ground contact AND transition collars mid-stack18+ * — a collar only appears where its skirt obeys the same containment,19+ * otherwise the kind change is a plain middle.20+ *21+ * Patterns are families over that law, drawn deterministically per22+ * stack (position hash — the structural greedy stays rng-free, the23+ * standing user rule): uniform in the seed's style kind, uniform in an24+ * off kind, or tapered (a wide `four` base run, then a slender upper25+ * run with a collar). New kinds = new table rows; the law is data.26+ */27+import type { Dir, Placement } from "../shared/types.ts";28+29+export type SupportKind = "single" | "two" | "four";30+31+interface Footprint {32+ w: number;33+ d: number;34+}35+36+const MIDDLE_FOOT: Record<SupportKind, Footprint> = {37+ four: { w: 0.9, d: 0.9 },38+ two: { w: 0.9, d: 0.2 },39+ single: { w: 0.4, d: 0.4 },40+};41+const BOTTOM_FOOT: Record<SupportKind, Footprint> = {42+ four: { w: 1.0, d: 1.0 },43+ two: { w: 1.0, d: 0.3 },44+ single: { w: 0.6, d: 0.6 },45+};46+47+/** Containment with authoring tolerance. */48+export function fitsOn(above: Footprint, below: Footprint): boolean {49+ return above.w <= below.w + 0.05 && above.d <= below.d + 0.05;50+}51+52+/** May a run of `upper` begin on top of a `lower` middle segment? */53+export function mayStandOn(upper: SupportKind, lower: SupportKind): boolean {54+ return fitsOn(MIDDLE_FOOT[upper], MIDDLE_FOOT[lower]);55+}56+57+/** Deterministic per-stack hash stream (no rng — position is the seed,58+ * matching the tunnel-variant / island-jitter recipe). */59+function stackHash(x: number, z: number, base: number): () => number {60+ let h = Math.imul(Math.round(x * 8) | 0, 374761393) ^61+ Math.imul(Math.round(z * 8) | 0, 668265263) ^62+ Math.imul(Math.round(base * 8) | 0, 1274126177);63+ return () => {64+ h = Math.imul(h ^ (h >>> 13), 1103515245) + 12345;65+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;66+ };67+}68+69+/** Shaft plan: the kind occupying each 0.5 slot, bottom → top. */70+export function planStack(71+ bias: SupportKind,72+ slots: number,73+ draw: () => number,74+): SupportKind[] {75+ if (slots <= 0) return [];76+ const r = draw();77+ // Tapered tower: a wide `four` base run carrying a slender upper run.78+ if (slots >= 3 && r < 0.3) {79+ const upper: SupportKind = bias === "four"80+ ? (draw() < 0.5 ? "two" : "single")81+ : bias;82+ const lowerLen = Math.max(83+ 1,84+ Math.min(slots - 1, Math.round(slots * (0.35 + draw() * 0.3))),85+ );86+ return [87+ ...Array<SupportKind>(lowerLen).fill("four"),88+ ...Array<SupportKind>(slots - lowerLen).fill(upper),89+ ];90+ }91+ // Uniform in an off kind — variety between stacks of one stretch.92+ if (r < 0.5) {93+ const others: SupportKind[] = (["four", "two", "single"] as const)94+ .filter((k) => k !== bias);95+ return Array(slots).fill(others[draw() < 0.5 ? 0 : 1]);96+ }97+ // Uniform in the seed's style kind (the dominant silhouette).98+ return Array(slots).fill(bias);99+}100+101+/**102+ * Compose one pillar into `placements`. `height` is the channel-base103+ * height above `baseAt` (multiples of 0.5); cradle stacks put the104+ * 0.7-tall top piece at height − 0.5 (its cradle lands exactly at the105+ * channel base), plain columns end flush. The top piece takes the106+ * uppermost shaft's kind so the arms grow from the right silhouette.107+ */108+export function emitSupportStack(109+ placements: Placement[],110+ prefix: string,111+ bias: SupportKind,112+ x: number,113+ z: number,114+ height: number,115+ dir: Dir,116+ cradle: boolean,117+ baseAt = 0,118+) {119+ const rotation = ((1 - dir + 4) % 4) as Dir;120+ const draw = stackHash(x, z, baseAt);121+ const slots = Math.max(0, Math.round((height - (cradle ? 0.5 : 0)) / 0.5));122+ const plan = planStack(bias, slots, draw);123+ const piece = (kind: SupportKind, segment: string, y: number) => {124+ placements.push({125+ pieceId: `${prefix}-${kind}-${segment}`,126+ pos: [x, y + baseAt, z],127+ rotation,128+ role: "support",129+ });130+ };131+ for (let i = 0; i < plan.length; i++) {132+ const kind = plan[i];133+ const below = i > 0 ? plan[i - 1] : null;134+ // Feet at contact; collars at kind changes when the skirt fits the135+ // shaft below (the containment law) — else a plain middle.136+ const collar = below !== null && below !== kind &&137+ fitsOn(BOTTOM_FOOT[kind], MIDDLE_FOOT[below]);138+ piece(139+ kind,140+ below === null ? "bottom" : collar ? "bottom" : "middle",141+ i * 0.5,142+ );143+ }144+ if (cradle && height >= 0.5 - 1e-9) {145+ const topKind = plan.length > 0 ? plan[plan.length - 1] : bias;146+ piece(topKind, "top", height - 0.5);147+ }148+}
M src/gen/takeable.ts +13 -5
M tests/golden_endless.json +12 -12
M tests/golden_tracks.json +4 -4
A tests/supports_test.ts +82 -0
@@ -0,0 +1,82 @@
1+/**2+ * Pillar vocabulary (composition law) — pure, no wasm. The measured3+ * table + containment rule live in src/gen/supports.ts; these pin the4+ * contracts: stacks always legal (containment upward), heights exact,5+ * feet at contact, deterministic, and genuinely varied.6+ */7+import { assert, assertEquals } from "@std/assert";8+import type { Placement } from "../src/shared/types.ts";9+import {10+ emitSupportStack,11+ mayStandOn,12+ type SupportKind,13+} from "../src/gen/supports.ts";14+15+const kindOf = (p: Placement): SupportKind =>16+ p.pieceId.split("-")[1] as SupportKind;17+const segOf = (p: Placement) => p.pieceId.split("-")[2];18+19+function stack(20+ height: number,21+ cradle: boolean,22+ x = 3,23+ z = 7,24+ bias: SupportKind = "two",25+ baseAt = 0,26+): Placement[] {27+ const out: Placement[] = [];28+ emitSupportStack(out, "support", bias, x, z, height, 0, cradle, baseAt);29+ return out;30+}31+32+Deno.test("containment law: single never stands on two; four carries all", () => {33+ assert(mayStandOn("single", "four"));34+ assert(mayStandOn("two", "four"));35+ assert(!mayStandOn("single", "two"));36+ assert(!mayStandOn("four", "two"));37+ assert(!mayStandOn("four", "single"));38+ // Sweep many stacks: every emitted neighbor pair obeys the law.39+ for (let i = 0; i < 200; i++) {40+ const h = 0.5 + (i % 8) * 0.5;41+ const ps = stack(h, i % 2 === 0, i * 1.5, -i * 2.5).sort(42+ (a, b) => a.pos[1] - b.pos[1],43+ );44+ for (let j = 1; j < ps.length; j++) {45+ if (segOf(ps[j]) === "top") continue; // cradle arms overhang by design46+ assert(47+ mayStandOn(kindOf(ps[j]), kindOf(ps[j - 1])),48+ `illegal ${ps[j].pieceId} on ${ps[j - 1].pieceId} (stack ${i})`,49+ );50+ }51+ }52+});53+54+Deno.test("heights exact: cradle at channel base, plain flush, feet at contact", () => {55+ for (const h of [0.5, 1, 1.5, 2, 3.5]) {56+ const c = stack(h, true, 11, 13, "four", 2);57+ const top = c.find((p) => segOf(p) === "top")!;58+ assertEquals(top.pos[1], 2 + h - 0.5); // cradle lands at base + h59+ const foot = c.reduce((a, b) => (a.pos[1] < b.pos[1] ? a : b));60+ assertEquals(foot.pos[1], 2);61+ assertEquals(segOf(foot), h === 0.5 ? "top" : "bottom");62+ const plain = stack(h, false, 11, 13, "four", 2);63+ const highest = plain.reduce((a, b) => (a.pos[1] > b.pos[1] ? a : b));64+ assertEquals(highest.pos[1] + 0.5, 2 + h); // flush at channel underside65+ }66+});67+68+Deno.test("deterministic per position; varied across positions", () => {69+ assertEquals(stack(3, true), stack(3, true));70+ const kinds = new Set<string>();71+ let tapered = 0;72+ for (let i = 0; i < 60; i++) {73+ const ps = stack(3, true, i * 2, 40 - i * 3);74+ const shaftKinds = new Set(75+ ps.filter((p) => segOf(p) !== "top").map(kindOf),76+ );77+ for (const k of shaftKinds) kinds.add(k);78+ if (shaftKinds.size > 1) tapered++;79+ }80+ assertEquals(kinds.size, 3, "all three kinds should appear across stacks");81+ assert(tapered >= 5, `tapered towers should appear (got ${tapered})`);82+});