clone

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

M13: hill-interval constraint — crossing tracks host hills; pairs never split (lifts the M3 flat-crossing compromise)

Dami committed f8b6d94
commit
f8b6d94b904d43cdf095b6a6e914592a1f5d18dd
parent
c859266c26433dfe4a5209fb21d050848509798c
author
Dami <dami.etoile@gmail.com>
2 files changed, +109 -7
Msrc/gen/elevation.ts43+++++++++++++++---
Mtests/track_test.ts73++++++++++++++++++++++++++++++
M src/gen/elevation.ts +36 -7
@@ -311,10 +311,20 @@ export function planElevation(
311311 // corner window as a spiral climb. Ring order is free: the baseY profile312312 // is normalized to min 0 after integration, so descent-before-climb313313 // simply means the elevated stretch wraps the seam.314- const flatOnly = skeleton.crossings.length > 0 || Boolean(helixLoop);314+ // Helix-loop tracks stay flat elsewhere (the spiral IS the hill). Grade315+ // crossings no longer force a flat track (M13 lifts the M3 compromise):316+ // plain hills are admitted under the HILL-INTERVAL CONSTRAINT below;317+ // corner set-pieces stay off crossing tracks (multi-window ensembles318+ // and the pierce splice both pin ring geometry the constraint can't319+ // reason about).320+ const flatOnly = Boolean(helixLoop);321+ const crossingPairs = skeleton.crossings;315322 const corner = rng.child("corner");316323 const usedRuns = new Set<Run>();317- if (!flatOnly && corner.chance(CORNER_DESCENT_AFFINITY)) {324+ if (325+ !flatOnly && crossingPairs.length === 0 &&326+ corner.chance(CORNER_DESCENT_AFFINITY)327+ ) {318328 const turnWindows = findTurnWindows(skeleton, fullBlocked, registry);319329 if (turnWindows.length > 0) {320330 const pickTurn = (opts: TurnWindow[]): TurnWindow =>
@@ -373,20 +383,35 @@ export function planElevation(
373383 for (const r of reservations) {374384 for (let j = 0; j < r.len; j++) cornerIdx.add((r.start + j) % n);375385 }386+ // HILL-INTERVAL CONSTRAINT (M13, lifts the M3 "crossings stay flat"387+ // compromise): both visits of a crossing pair must agree on their height388+ // RELATIONSHIP — baseY[b] − baseY[a] is the sum of window deltas in the389+ // ring interval (a, b], so a hill is safe iff its climb and descent land390+ // on the SAME SIDE of every pair's interval (net contribution zero).391+ // Runs touching a crossing cell are excluded outright (the cross piece392+ // is one flat 4-way — a slope window may not cover it), and any run393+ // free of the crossing cells lies entirely on one side of each interval.394+ const crossingCells = new Set<number>(395+ crossingPairs.flatMap((c) => [c.a, c.b]),396+ );376397 const eligible = runs.filter((r) => {377398 if (usedRuns.has(r)) return false;378399 for (let j = 0; j < r.len; j++) {379400 if (cornerIdx.has(r.start + j)) return false;401+ if (crossingCells.has((r.start + j) % n)) return false;380402 }381403 return r.len >= 2;382404 });405+ const sameSide = (r1: Run, r2: Run): boolean =>406+ crossingPairs.every(({ a, b }) => {407+ const s1 = r1.start > a && r1.start <= b;408+ const s2 = r2.start > a && r2.start <= b;409+ return s1 === s2;410+ });383411 const maxHills = Math.min(3, Math.floor(eligible.length / 2));384412 // At least one hill whenever the ring can host one — flat tracks still385- // occur (small rings), but stay the exception. Tracks with grade386- // crossings stay flat: both visits of a crossing cell must agree on387- // height, and keeping the whole ring at base 0 is the only rule that388- // can't separate a pair (M3 compromise — a hill-interval constraint389- // could lift this later). Helix-loop tracks: the spiral IS the hill.413+ // occur (small rings), but stay the exception. Helix-loop tracks: the414+ // spiral IS the hill.390415 const hills = maxHills > 0 && !flatOnly ? rng.int(1, maxHills + 1) : 0;391416 392417 if (hills > 0) {
@@ -395,6 +420,10 @@ export function planElevation(
395420 for (let h = 0; h < hills; h++) {396421 const climbRun = chosen[h * 2];397422 const descentRun = chosen[h * 2 + 1];423+ // A pair split by a crossing interval would tilt the pair's height424+ // relationship — skip that hill (the pairing is seeded; another425+ // seed pairs differently).426+ if (!sameSide(climbRun, descentRun)) continue;398427 // Only deltas both runs can physically host (0.5 always fits).399428 const choices = ([[0.5, 3], [1.0, 2], [1.5, 2]] as const).filter(([d]) =>400429 CLIMB_VOCAB[String(d)].some((o) => o.totalLen <= climbRun.len) &&
M tests/track_test.ts +73 -0
@@ -85,6 +85,79 @@ Deno.test("track fuzz: 200 seeds validate clean", () => {
8585 }8686 });8787 88+Deno.test("hill-interval constraint: crossing tracks host hills, pairs never split", () => {89+ // M13 lifts the M3 "crossings stay flat" compromise: hills are admitted90+ // on crossing tracks when the climb+descent pair sits on ONE side of91+ // every crossing pair's ring interval. Rules: (a) pinned seeds keep a92+ // crossing AND elevation (the lift emerged), (b) at every flat-crossing93+ // cell, ALL loop-centerline passes agree on height — the pair never94+ // splits. (Bridge decks differ by exactly 1.0 and are excluded via the95+ // cross piece filter — flat crossings realize `cross`.)96+ const seeds = ["xh-62", "xh-129", "xh-260", "xh-442"];97+ let elevatedCrossings = 0;98+ for (const seed of seeds) {99+ const track = generateTrack(seed, { packs: [] }, { skipDecor: true });100+ const crosses = track.placements.filter((p) => p.pieceId === "cross");101+ assert(crosses.length > 0, `${seed} lost its crossing`);102+ if (track.stats.maxDeckY > 0) elevatedCrossings++;103+ for (const cross of crosses) {104+ const ys: number[] = [];105+ for (const seg of track.loop.segments) {106+ for (const part of seg.curve) {107+ for (let t = 0; t <= 8; t++) {108+ const u = t / 8;109+ // Cheap curve sampling: lines/cubics/arcs all expose enough110+ // via their control data; use segment endpoints + midpoints111+ // through the shared helper below.112+ const pt = samplePart(part, u);113+ const dx = pt[0] - cross.pos[0];114+ const dz = pt[2] - cross.pos[2];115+ if (dx * dx + dz * dz < 0.09) ys.push(pt[1]);116+ }117+ }118+ }119+ assert(ys.length > 0, `${seed}: no centerline passes at the crossing`);120+ const min = Math.min(...ys);121+ const max = Math.max(...ys);122+ assert(123+ max - min < 0.06,124+ `${seed}: crossing passes split heights (${min}..${max})`,125+ );126+ }127+ }128+ assert(129+ elevatedCrossings > 0,130+ "no pinned crossing track is elevated — the lift regressed",131+ );132+});133+134+function samplePart(135+ part: import("../src/shared/types.ts").CurveSeg,136+ u: number,137+): readonly [number, number, number] {138+ if (part.kind === "line") {139+ return [140+ part.a[0] + (part.b[0] - part.a[0]) * u,141+ part.a[1] + (part.b[1] - part.a[1]) * u,142+ part.a[2] + (part.b[2] - part.a[2]) * u,143+ ];144+ }145+ if (part.kind === "arc") {146+ const a = part.a0 + (part.a1 - part.a0) * u;147+ return [148+ part.center[0] + part.radius * Math.cos(a),149+ part.y0 + (part.y1 - part.y0) * u,150+ part.center[2] + part.radius * Math.sin(a),151+ ];152+ }153+ const [p0, p1, p2, p3] = part.p;154+ const v = 1 - u;155+ return [0, 1, 2].map((i) =>156+ v * v * v * p0[i] + 3 * v * v * u * p1[i] + 3 * v * u * u * p2[i] +157+ u * u * u * p3[i]158+ ) as unknown as readonly [number, number, number];159+}160+88161 Deno.test("determinism: same seed → deep-equal track", () => {89162 const a = generateTrack("determinism-check");90163 const b = generateTrack("determinism-check");