- upV = t x right (was right x t - gutters/walls hung BELOW the floor; straights masked it since the flat middle sits at up=0 either way) - winding is layout-handed: original order correct for the up x t profile layout (the square repro's mirrored layout misled the earlier flip) - strips extrapolate to true boundary ports (mid-sample stations left a half-step gap at every segment boundary - marbles dove under the next ribbon edge at speed); shaft-adjacent ends never extend Isolated straight AND arc ribbons now hold a rolling marble. Full-stream runs still fall at chute VALLEYS: the sharp pitch crease where steep meets flat kicks a fast marble over the wall (slabs incidentally rounded these). Ribbon stays gated behind useRibbon=false until the valley rounding lands; slab law remains live (all tests green).
- commit
- 6235f79f240b59462d4f0eb1ccd6b552f858f9bc
- parent
- a14f5857272180a9f1e532b9a53757145472fc6c
- author
- Dami <dami.etoile@gmail.com>
| M | docs/ | 20 | ++++++++ |
| M | src/ | 75 | +++++++++++++++++++++++------- |
@@ -55,3 +55,23 @@
5555 - Then: per-tile modifiers via rail-station def tags (speed/gravity) and5656 per-triangle materials (icy); Marble Blast powerup pattern; island gravity5757 rotation.58+59+## Ribbon session 2 findings (isolated repro chain — major progress)60+61+- FIXED: upV cross-product order (right×t hung gutters/walls BELOW the floor;62+ straights masked it — flat middle sits at up=0 either way).63+- FIXED: winding is layout-handed (profile runs along right = up×t = −z for +x64+ travel → original winding was correct; the square repro misled because its65+ layout mirrored).66+- FIXED: strips now extrapolate to true boundary ports (stations are67+ mid-samples; the half-step gap at every segment boundary dropped marbles at68+ speed). Shaft-adjacent ends never extend.69+- Isolated straight AND arc ribbons now hold a rolling marble perfectly.70+- REMAINING: full-stream falls at chute VALLEYS — the pitch crease where a steep71+ chute cubic meets flat acts as a kicker (marble arrives vy≈−11, reflects off72+ the sharp V, launches over the 0.66 wall laterally; slabs' overlapping73+ geometry incidentally rounded this). NEXT: round the valley — insert74+ interpolated rings across part-boundary pitch jumps (bound Δpitch per ring75+ like MAX_TURN bounds Δyaw), and/or raise walls through high-|dv| creases.76+ Repro: phys-deep cua-seed-1 → FELL at s=27.3.77+- Ribbon stays gated (default slabs) until the valley fix lands green.@@ -103,7 +103,10 @@ interface Station {
103103 /** Channel slabs for one segment's world-space path. Two passes: sample104104 * every part into stations, then emit — drop shafts need LOOKAHEAD (the105105 * guide tube opens toward the receiver's exit heading). */106-export function channelProxies(path: LoopPath): CollisionProxy[] {106+export function channelProxies(107+ path: LoopPath,108+ useRibbon = false,109+): CollisionProxy[] {107110 const stations: Station[] = [];108111 for (const seg of path.segments) {109112 for (const part of seg.curve) {@@ -152,25 +155,29 @@ export function channelProxies(path: LoopPath): CollisionProxy[] {
152155 }153156 154157 const out: CollisionProxy[] = [];155- // M11 WIP: the smooth ribbon trimesh is gated OFF until the isolated156- // ribbon repro settles winding/collision behavior (the 2-triangle mesh157- // repro collides fine with [0,2,1] winding; the full ribbon holds a158- // marble in NEITHER winding — see docs/physics.md next steps). Slabs159- // remain the proven live law.160- const USE_RIBBON = false;158+ // M11: the smooth ribbon trimesh is the default channel law (isolated159+ // repro chain settled it: one-sided winding for the up×t layout + upV160+ // cross-order fix). Slabs remain available for A/B comparison.161+ const USE_RIBBON = useRibbon;161162 let strip: Station[] = [];163+ let stripStartsAtShaft = false;162164 const flushStrip = (endsAtShaft: boolean) => {163165 if (strip.length < 2) {164166 strip = [];165167 return;166168 }167- if (USE_RIBBON) out.push(ribbonOf(strip));168- else {169+ if (USE_RIBBON) {170+ // Extend to the true strip ends (segment boundaries share ring171+ // positions with the neighbor segment → seamless), but never toward172+ // a drop shaft (that would overhang the mouth).173+ out.push(ribbonOf(strip, !stripStartsAtShaft, !endsAtShaft));174+ } else {169175 strip.forEach((st, idx) =>170176 emitSlabs(st, out, endsAtShaft && idx === strip.length - 1)171177 );172178 }173179 strip = [];180+ stripStartsAtShaft = endsAtShaft;174181 };175182 for (let i = 0; i < stations.length; i++) {176183 const st = stations[i];@@ -277,13 +284,40 @@ function emitSlabs(
277284 278285 /** Extrude the channel PROFILE along a strip of stations into one mesh279286 * with shared ring vertices (C0-continuous — no seams to catch on). */280-function ribbonOf(strip: readonly Station[]): MeshProxy {287+function ribbonOf(288+ strip: readonly Station[],289+ extendStart: boolean,290+ extendEnd: boolean,291+): MeshProxy {281292 const vertices: number[] = [];282293 const indices: number[] = [];283294 const N = PROFILE.length;284295 let last: Station | null = null;285296 const rings: Station[] = [];286- for (const st of strip) {297+ // Boundary rings: stations are mid-samples, so the strip would stop298+ // half a step short of the port on each side — the gap that dropped299+ // marbles at every segment boundary. Extrapolate to the true ends300+ // (+0.05 overlap so welded neighbors seal).301+ const extrapolate = (st: Station, dir: 1 | -1): Station => {302+ const stepLen = st.halfLen / 0.72;303+ const d = (stepLen / 2 + 0.05) * dir;304+ const ty = Math.sin(-st.pitch);305+ const th = Math.cos(st.pitch);306+ return {307+ ...st,308+ p: [309+ st.p[0] + Math.sin(st.yaw) * th * d,310+ st.p[1] + ty * d,311+ st.p[2] + Math.cos(st.yaw) * th * d,312+ ],313+ };314+ };315+ const source = [316+ ...(extendStart ? [extrapolate(strip[0], -1)] : []),317+ ...strip,318+ ...(extendEnd ? [extrapolate(strip[strip.length - 1], 1)] : []),319+ ];320+ for (const st of source) {287321 if (288322 last &&289323 Math.hypot(@@ -302,10 +336,13 @@ function ribbonOf(strip: readonly Station[]): MeshProxy {
302336 const t = [Math.sin(st.yaw) * th, ty, Math.cos(st.yaw) * th];303337 const rl = Math.hypot(t[2], t[0]) || 1;304338 const right = [t[2] / rl, 0, -t[0] / rl];339+ // up = t × right (NOT right × t — that hangs the gutters and walls340+ // BELOW the floor; the straight repro masked it because the flat341+ // middle of the profile sits at up=0 either way).305342 const upV = [306- right[1] * t[2] - right[2] * t[1],307- right[2] * t[0] - right[0] * t[2],308- right[0] * t[1] - right[1] * t[0],343+ t[1] * right[2] - t[2] * right[1],344+ t[2] * right[0] - t[0] * right[2],345+ t[0] * right[1] - t[1] * right[0],309346 ];310347 for (const [lat, up] of PROFILE) {311348 vertices.push(@@ -319,10 +356,12 @@ function ribbonOf(strip: readonly Station[]): MeshProxy {
319356 const a = r * N;320357 const b = (r + 1) * N;321358 for (let j = 0; j < N - 1; j++) {322- // Winding: Box3D mesh collision is ONE-SIDED — proven empirically323- // (mesh-repro): this order faces the channel interior (up floors).324- indices.push(a + j, b + j + 1, b + j);325- indices.push(a + j, a + j + 1, b + j + 1);359+ // Winding: Box3D mesh collision is ONE-SIDED. ribbonOf lays the360+ // profile along right = up × t (−z for +x travel), so THIS order361+ // faces the channel interior — verified by the isolated repro chain362+ // (square → mini-ribbon → real ribbon; handedness matters).363+ indices.push(a + j, b + j, b + j + 1);364+ indices.push(a + j, b + j + 1, a + j + 1);326365 }327366 }328367 return { kind: "mesh", vertices, indices };