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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user