diff --git a/src/lib/components/VerticalHelix.svelte b/src/lib/components/VerticalHelix.svelte index e963fff..0913632 100644 --- a/src/lib/components/VerticalHelix.svelte +++ b/src/lib/components/VerticalHelix.svelte @@ -70,27 +70,57 @@ const H = $derived(TOP_PAD + Math.max(1, projects.length) * SLOT_HEIGHT + BOTTOM_PAD); - const strandA = $derived.by(() => { - const steps = Math.max(80, Math.floor(H / 4)); - const parts: string[] = []; - for (let i = 0; i <= steps; i++) { - const y = (H / steps) * i; - const x = CX + AMP * Math.sin(k * y); - parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`); - } - return parts.join(' '); - }); + /** + * Splits a strand into "front" or "back" segments for true 3D occlusion. + * + * A strand is in front of the other where its absolute distance from CX + * has the same sign as the convention: strand A (sign=+1) is in front when + * sin(ky) > 0; strand B (sign=-1) is in front when sin(ky) < 0. In both + * cases the strand is in front when sign * sin(ky) > 0. + * + * Output is a single SVG path string with multiple `M…L…` runs separated + * at every crossing point so each run lives entirely on one side of the + * "in front" boundary. + */ + function buildStrandSplit(sign: 1 | -1, partition: 'front' | 'back', height: number): string { + const steps = Math.max(240, Math.floor(height / 1.8)); + const dy = height / steps; + const runs: string[] = []; + let buf: string[] = []; + let inSeg = false; - const strandB = $derived.by(() => { - const steps = Math.max(80, Math.floor(H / 4)); - const parts: string[] = []; for (let i = 0; i <= steps; i++) { - const y = (H / steps) * i; - const x = CX - AMP * Math.sin(k * y); - parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`); + const y = dy * i; + const s = Math.sin(k * y); + const x = CX + sign * AMP * s; + const inFront = sign * s > 0; + const want = (partition === 'front') === inFront; + + if (want) { + const cmd = inSeg ? 'L' : 'M'; + buf.push(`${cmd}${x.toFixed(3)} ${y.toFixed(3)}`); + inSeg = true; + } else if (inSeg) { + // Close out the run AT the crossing point so consecutive + // front/back segments meet seamlessly at (CX, y_crossing). + const lastY = parseFloat(buf[buf.length - 1].slice(1).split(' ')[1]); + // Solve sin(k*y_c) = 0 in (lastY, y] → y_c = round(y / (PERIOD/2)) * PERIOD/2 + const yc = Math.round(((lastY + y) / 2) / (PERIOD / 2)) * (PERIOD / 2); + buf.push(`L${CX.toFixed(3)} ${yc.toFixed(3)}`); + runs.push(buf.join(' ')); + buf = []; + inSeg = false; + } } - return parts.join(' '); - }); + + if (inSeg) runs.push(buf.join(' ')); + return runs.join(' '); + } + + const strandAFront = $derived(buildStrandSplit(1, 'front', H)); + const strandABack = $derived(buildStrandSplit(1, 'back', H)); + const strandBFront = $derived(buildStrandSplit(-1, 'front', H)); + const strandBBack = $derived(buildStrandSplit(-1, 'back', H)); const rungs = $derived.by(() => { const out: Rung[] = []; @@ -163,74 +193,42 @@ - - - - - - + + + - + + + + + + + + + + + {#each rungs as r} - {#if !r.aFront} - - {/if} + {/each} - - - - - {#each rungs as r} - {#if r.aFront} - - {/if} - {/each} - - - - - + {#each slots as s} {/each} - + {#each slots as s} {/each} + + + + + + + + + + + + + {#each slots as s}