polish(helix): 3D occlusion + layered render for image quality

The biggest visual deficit was that both strands were drawn fully on
top of each other with a Gaussian-blur filter applied directly to the
sharp lines — so they read flat and slightly fuzzy.

What changed
- buildStrandSplit() carves each strand into front/back segments
  partitioned at every sin(ky)=0 crossing. Crossings are snapped to
  the exact y_c = n * PERIOD/2 so consecutive front/back segments
  meet seamlessly at (CX, y_c) with no visible gap.
- Render order is now strictly back → mid (rungs, per-project rungs,
  card connectors) → front, so front-strand portions occlude back-
  strand portions where they cross. Real 3D illusion.
- Glow is a separate blurred copy *underneath* the sharp path
  (stdDeviation 3.5, stroke-width 6-7). Sharp lines now stay crisp.
- Back portions render dimmed (opacity 0.62) for depth.
- Subtle white highlight stroke (stroke-width 0.9, alpha .55) is
  added on top of the front sharp strands — wet-ribbon specular feel.
- Path resolution doubled (steps ≈ H/1.8, was H/4). Coords use
  .toFixed(3) instead of .2. shape-rendering="geometricPrecision"
  on every load-bearing path.
- Decorative DNA-texture rungs were folded into a single pass that
  varies stroke-width and opacity by sin-depth, brighter for the
  "front side" rungs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-20 14:50:49 +02:00
parent 7501e1b7a4
commit a9f7434445

View File

@@ -70,27 +70,57 @@
const H = $derived(TOP_PAD + Math.max(1, projects.length) * SLOT_HEIGHT + BOTTOM_PAD); 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)); * Splits a strand into "front" or "back" segments for true 3D occlusion.
const parts: string[] = []; *
for (let i = 0; i <= steps; i++) { * A strand is in front of the other where its absolute distance from CX
const y = (H / steps) * i; * has the same sign as the convention: strand A (sign=+1) is in front when
const x = CX + AMP * Math.sin(k * y); * sin(ky) > 0; strand B (sign=-1) is in front when sin(ky) < 0. In both
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`); * cases the strand is in front when sign * sin(ky) > 0.
} *
return parts.join(' '); * 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++) { for (let i = 0; i <= steps; i++) {
const y = (H / steps) * i; const y = dy * i;
const x = CX - AMP * Math.sin(k * y); const s = Math.sin(k * y);
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`); 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<Rung[]>(() => { const rungs = $derived.by<Rung[]>(() => {
const out: Rung[] = []; const out: Rung[] = [];
@@ -163,74 +193,42 @@
<animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" /> <animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" />
</stop> </stop>
</linearGradient> </linearGradient>
<filter id="vhelix-glow" x="-10%" y="-2%" width="120%" height="104%"> <!-- Glow applied as a separate blurred copy underneath the sharp paths,
<feGaussianBlur stdDeviation="4" result="b" /> so the sharp paths stay crisp. stdDeviation tuned for soft halo
<feMerge> without losing the underlying line. -->
<feMergeNode in="b" /> <filter id="vhelix-glow" x="-15%" y="-2%" width="130%" height="104%">
<feMergeNode in="SourceGraphic" /> <feGaussianBlur stdDeviation="3.5" />
</feMerge>
</filter> </filter>
<filter id="vnode-glow" x="-200%" y="-200%" width="500%" height="500%"> <filter id="vnode-glow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="6" /> <feGaussianBlur stdDeviation="6" />
</filter> </filter>
</defs> </defs>
<!-- Back rungs --> <!-- ========== BACK LAYER ========== -->
<!-- Glow halos behind back portions -->
<path d={strandABack} fill="none" stroke="url(#vstrand-a)" stroke-width="6" stroke-linecap="round" opacity="0.45" filter="url(#vhelix-glow)" />
<path d={strandBBack} fill="none" stroke="url(#vstrand-b)" stroke-width="6" stroke-linecap="round" opacity="0.45" filter="url(#vhelix-glow)" />
<!-- Sharp back strands (dimmed slightly to read as "behind") -->
<path d={strandABack} fill="none" stroke="url(#vstrand-a)" stroke-width="3.0" stroke-linecap="round" stroke-opacity="0.62" shape-rendering="geometricPrecision" />
<path d={strandBBack} fill="none" stroke="url(#vstrand-b)" stroke-width="2.7" stroke-linecap="round" stroke-opacity="0.62" shape-rendering="geometricPrecision" />
<!-- ========== MIDLAYER: rungs + per-project anchors + connectors ========== -->
<!-- Decorative back-pair rungs (DNA texture) -->
{#each rungs as r} {#each rungs as r}
{#if !r.aFront} <line
<line x1={r.x1}
x1={r.x1} y1={r.y}
y1={r.y} x2={r.x2}
x2={r.x2} y2={r.y}
y2={r.y} stroke={r.aFront ? '#b8dff5' : '#6fc3ec'}
stroke="#6fc3ec" stroke-opacity={(r.aFront ? 0.18 : 0.08) + (r.aFront ? 0.45 : 0.28) * r.depth}
stroke-opacity={0.08 + 0.30 * r.depth} stroke-width={(r.aFront ? 1.0 : 0.6) + (r.aFront ? 1.5 : 0.9) * r.depth}
stroke-width={0.6 + 1.0 * r.depth} stroke-linecap="round"
stroke-linecap="round" />
/>
{/if}
{/each} {/each}
<!-- Strand B (back) --> <!-- Per-project base-pair rungs in strand colour -->
<path
d={strandB}
fill="none"
stroke="url(#vstrand-b)"
stroke-width="3.2"
stroke-linecap="round"
stroke-opacity="0.85"
filter="url(#vhelix-glow)"
/>
<!-- Front rungs -->
{#each rungs as r}
{#if r.aFront}
<line
x1={r.x1}
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#b8dff5"
stroke-opacity={0.18 + 0.50 * r.depth}
stroke-width={1.0 + 1.6 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Strand A (front) -->
<path
d={strandA}
fill="none"
stroke="url(#vstrand-a)"
stroke-width="3.6"
stroke-linecap="round"
filter="url(#vhelix-glow)"
/>
<!-- Per-project base-pair rungs: strand-to-strand line at the node's Y,
drawn in the project's strand colour. This is the "line in between"
the strands that each node sits on. -->
{#each slots as s} {#each slots as s}
<line <line
x1={s.strandAx} x1={s.strandAx}
@@ -238,13 +236,14 @@
x2={s.strandBx} x2={s.strandBx}
y2={s.y} y2={s.y}
stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'} stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.75" stroke-opacity="0.78"
stroke-width="2.4" stroke-width="2.4"
stroke-linecap="round" stroke-linecap="round"
shape-rendering="geometricPrecision"
/> />
{/each} {/each}
<!-- Connector lines: from the node (rung centre) outward to the card edge --> <!-- Connector dashes from node outward to card -->
{#each slots as s} {#each slots as s}
<line <line
x1={s.nodeX} x1={s.nodeX}
@@ -255,9 +254,23 @@
stroke-opacity="0.35" stroke-opacity="0.35"
stroke-width="1" stroke-width="1"
stroke-dasharray="2 4" stroke-dasharray="2 4"
shape-rendering="geometricPrecision"
/> />
{/each} {/each}
<!-- ========== FRONT LAYER ========== -->
<!-- Glow halos under front portions -->
<path d={strandAFront} fill="none" stroke="url(#vstrand-a)" stroke-width="7" stroke-linecap="round" opacity="0.65" filter="url(#vhelix-glow)" />
<path d={strandBFront} fill="none" stroke="url(#vstrand-b)" stroke-width="6.5" stroke-linecap="round" opacity="0.6" filter="url(#vhelix-glow)" />
<!-- Sharp front strands -->
<path d={strandAFront} fill="none" stroke="url(#vstrand-a)" stroke-width="3.8" stroke-linecap="round" shape-rendering="geometricPrecision" />
<path d={strandBFront} fill="none" stroke="url(#vstrand-b)" stroke-width="3.4" stroke-linecap="round" shape-rendering="geometricPrecision" />
<!-- Highlight stroke (specular "wet ribbon" effect on top of front strands) -->
<path d={strandAFront} fill="none" stroke="rgba(255,255,255,0.55)" stroke-width="0.9" stroke-linecap="round" shape-rendering="geometricPrecision" />
<path d={strandBFront} fill="none" stroke="rgba(255,255,255,0.45)" stroke-width="0.85" stroke-linecap="round" shape-rendering="geometricPrecision" />
<!-- Project nodes — always at rung centre (x = CX) --> <!-- Project nodes — always at rung centre (x = CX) -->
{#each slots as s} {#each slots as s}
<g class="vnode" data-strand={s.project.strand}> <g class="vnode" data-strand={s.project.strand}>