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 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<Rung[]>(() => {
const out: Rung[] = [];
@@ -163,74 +193,42 @@
<animate attributeName="offset" values="0.3;1.9" dur="22s" repeatCount="indefinite" />
</stop>
</linearGradient>
<filter id="vhelix-glow" x="-10%" y="-2%" width="120%" height="104%">
<feGaussianBlur stdDeviation="4" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
<!-- Glow applied as a separate blurred copy underneath the sharp paths,
so the sharp paths stay crisp. stdDeviation tuned for soft halo
without losing the underlying line. -->
<filter id="vhelix-glow" x="-15%" y="-2%" width="130%" height="104%">
<feGaussianBlur stdDeviation="3.5" />
</filter>
<filter id="vnode-glow" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="6" />
</filter>
</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}
{#if !r.aFront}
<line
x1={r.x1}
y1={r.y}
x2={r.x2}
y2={r.y}
stroke="#6fc3ec"
stroke-opacity={0.08 + 0.30 * r.depth}
stroke-width={0.6 + 1.0 * r.depth}
stroke-linecap="round"
/>
{/if}
<line
x1={r.x1}
y1={r.y}
x2={r.x2}
y2={r.y}
stroke={r.aFront ? '#b8dff5' : '#6fc3ec'}
stroke-opacity={(r.aFront ? 0.18 : 0.08) + (r.aFront ? 0.45 : 0.28) * r.depth}
stroke-width={(r.aFront ? 1.0 : 0.6) + (r.aFront ? 1.5 : 0.9) * r.depth}
stroke-linecap="round"
/>
{/each}
<!-- Strand B (back) -->
<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. -->
<!-- Per-project base-pair rungs in strand colour -->
{#each slots as s}
<line
x1={s.strandAx}
@@ -238,13 +236,14 @@
x2={s.strandBx}
y2={s.y}
stroke={s.project.strand === 'A' ? '#1fa0db' : '#bed137'}
stroke-opacity="0.75"
stroke-opacity="0.78"
stroke-width="2.4"
stroke-linecap="round"
shape-rendering="geometricPrecision"
/>
{/each}
<!-- Connector lines: from the node (rung centre) outward to the card edge -->
<!-- Connector dashes from node outward to card -->
{#each slots as s}
<line
x1={s.nodeX}
@@ -255,9 +254,23 @@
stroke-opacity="0.35"
stroke-width="1"
stroke-dasharray="2 4"
shape-rendering="geometricPrecision"
/>
{/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) -->
{#each slots as s}
<g class="vnode" data-strand={s.project.strand}>