diff --git a/docs/animation-architecture.md b/docs/animation-architecture.md index 26549cb..05d5d92 100644 --- a/docs/animation-architecture.md +++ b/docs/animation-architecture.md @@ -1,91 +1,143 @@ -# Reference-matched fire demon hero +# Fire-demon face, combustion and torch interaction -## Rendering +## Rendering model -The section is statically rendered by Astro. The heading, description, actions and technology labels are present in the first HTML response. The animated canvas is decorative and does not contain SEO-critical content. +The hero remains statically rendered by Astro. The heading, description, actions and technology labels are present in the first HTML response. The WebGL graph is decorative and contains no SEO-critical content. -The graph is borderless and covers the full hero section. The initial desktop camera places the demon on the right, while the canvas and pointer coordinate system continue across the whole section so a dragged node can stretch the graph through the complete hero field. +The graph is borderless and occupies the complete hero section. Its resting composition is offset to the right on desktop, while the canvas and pointer coordinate system cover the entire section so any node can be dragged far beyond the initial silhouette. -The graph uses two Three.js draw calls: +Three.js still renders the complete character with two draw calls: -- `THREE.Points` with a custom shader for all body, contour, face and detached fire nodes; +- `THREE.Points` for body, contour, face, sparks and embers; - `THREE.LineSegments` for all graph connections. -No post-processing pass is used. The point fragment shader renders a hard luminous core, a softer inner core and a role-controlled halo. This keeps the glow expressive without adding an extra framebuffer or bloom pipeline. +No post-processing framebuffer is used. A custom point shader provides a hard particle core, a soft inner core and feature-controlled glow. -## Reference-derived silhouette +## Reference-derived body -The main body no longer depends only on primitive ellipses. A compact 72 × 72 density field was derived from the supplied warm reference and stored as numeric rows in `referenceMask.ts`. The runtime samples that field with bilinear interpolation. +The principal silhouette is sampled from the compact density field in `referenceMask.ts`. Density controls particle acceptance, concentration and colour. Local density gradients create a stronger contour layer around the rounded lower body, side flames, raised arms and crown tongues. -The density field controls: +The generator overlays explicit structures for: -- the accepted body positions; -- higher concentration around the face and lower core; -- sparse crown tips and side flames; -- the wide rounded lower body; -- the raised side-arm silhouette; -- contour detection through local density gradients. +- body and contour particles; +- raised arm chains and surrounding micro-particles; +- sparse large Obsidian-style hubs; +- detached starburst clusters; +- paired embers drifting around the silhouette. -The generator adds separate graph layers on top of the mask: +## Face feature metadata -- dense body particles; -- a stronger contour/rim layer; -- explicit raised arm chains with surrounding micro-particles; -- two large eye structures with outer rings, inner rings, bright fill and compact black pupils; -- a wide open mouth with upper and lower contours, a dark red interior and a brighter lower tongue arc; -- large hub nodes distributed among much smaller particles; -- detached starburst clusters and paired drifting embers. +`GraphNodeSeed` may carry a `feature` value. The renderer uses this metadata to animate and illuminate facial layers differently without creating additional materials or draw calls. -Body points are excluded from eye and mouth voids so the expression remains readable. Every node is connected, but detached spark groups are allowed to remain separate visual components. +Eye features: -## Fire colour and scale +- `eye-outer-rim`; +- `eye-sclera`; +- `eye-iris`; +- `eye-highlight`; +- `eye-pupil`. -Colour is calculated from height, distance from the hot core and local mask density. The palette moves through: +Mouth features: -1. cream and yellow in the dense lower core; -2. amber and orange; -3. red and crimson; -4. magenta and violet at cooler outer tips; -5. occasional blue on the highest or most distant particles. +- `mouth-outer-rim`; +- `mouth-inner-rim`; +- `mouth-cavity`; +- `mouth-tongue`; +- `mouth-highlight`. -Node diameters vary by role and probability. The graph combines many micro-nodes with sparse large hubs, large black pupil nodes, bright eye particles and medium starburst centres. +## Eye construction -## Physics and idle combustion +The eyes are deliberately asymmetric. The left eye is slightly larger and the two pupils have opposite horizontal offsets, matching the irregular character of the references. -Every node stores its current position, velocity and immutable target position. Each frame applies: +Each eye contains: -1. spring forces along graph edges; -2. a target-anchor force preserving the reference silhouette; -3. velocity damping; -4. role-aware lateral turbulence and upward convection; -5. rendering-time thermal waves, flicker and size pulses. +1. an irregular warm outer ring; +2. a second bright structural ring; +3. hundreds of cream and white micro-particles sampled within an annulus; +4. a compact iris ring; +5. a black pupil ring, central hub and dark fill particles; +6. radial iris-to-pupil spokes; +7. a small upper-left highlight cluster. -Flame, rim and arm nodes use layered slow, medium and fast waves. Starburst and ember nodes have wider orbital and upward drift. Eye, pupil and mouth nodes stay comparatively stable so the face does not dissolve. +The outer, middle, iris and pupil rings use stronger local springs. Selected outer-ring nodes attach the complete eye to the surrounding flame. This keeps the eyes readable while the rest of the graph stretches. -While a node is dragged, target anchors are weakened for connected nodes up to seven graph steps away. Influence decays by graph distance. This permits large full-section deformation while still allowing the demon to recover after release. +At render time, the two pupils share a slow, subtle gaze displacement. Sclera and iris particles twinkle independently, while highlight particles receive stronger glow and size pulses. Pupil particles remain black and do not receive bloom-like halos. -## Torch code reveal +## Mouth construction -Twelve narrow code columns cover the full hero in two DOM layers. Text is deliberately very small and dense. +The mouth is a broad raised-corner smile composed from four structural curves: -- The ambient layer remains almost invisible across the section. -- The lit layer sits behind the WebGL canvas. -- A radial CSS mask follows the dragged node and pointer. -- Pointer speed increases mask radius, code opacity and the warm background haze. -- Releasing the node fades the code and haze back into darkness. +- outer upper lip; +- outer lower lip; +- inner upper lip; +- inner lower lip. -The code remains real DOM text rather than being painted into WebGL, preserving rendering simplicity and keeping it visually behind the graph. +The lower curves are deepened by `createPresentationGraph.ts`, after which all affected edge rest lengths are recalculated. This produces a deeper rounded opening without modifying the reusable body generator. -## Loading and fallbacks +Inside the contours: -The first render displays an inline SVG graph generated from the same deterministic model. Three.js is code-split and loaded after browser idle time or the first pointer interaction. +- only a small number of nearly black cavity particles are used so the opening remains visibly dark; +- a dense red-orange tongue bed occupies the lower third; +- a bright tongue arc defines the upper tongue edge; +- additional lip highlights follow both contours; +- larger corner nodes reinforce the raised smile. + +Mouth cavity particles have almost no glow. Tongue and highlight particles pulse separately from the lips, preserving the reference's dark opening and illuminated lower interior. + +## Idle combustion + +Physics preserves the reference pose through target anchors and graph springs. Rendering adds a second visual combustion layer that does not alter edge rest lengths. + +The animation combines: + +- slow, medium and fast particle flicker; +- two upward travelling thermal bands; +- height-dependent lateral turbulence; +- rising and orbiting detached embers; +- feature-aware size breathing; +- dynamic per-node glow; +- travelling brightness pulses along graph edges. + +Body, contour and arm particles move most like continuous fire. Sparks and embers have wider motion. Facial structures remain strongly anchored, but eye highlights, iris particles and the tongue receive controlled local animation so the face feels alive rather than rigid. + +## Drag interaction + +The selected node follows the pointer directly. Connected nodes react through the spring network, and drag influence propagates up to seven graph steps with exponential decay. Target anchors weaken under this influence, allowing the demon to stretch through the full hero field. + +After release, the original anchors recover the reference silhouette. Eye and mouth structures use stronger internal springs so they deform coherently instead of disintegrating into unrelated particles. + +## Code wall and torch reveal + +The background contains eighteen very narrow code columns. Each column combines two source fragments, producing a dense program-code wall across the full hero. Desktop text ranges from approximately 4.2 to 6 pixels. + +The wall is composed from: + +- a low-contrast texture and scan-line surface; +- an almost invisible ambient code layer; +- a duplicate illuminated code layer behind the WebGL canvas. + +During drag, pointer position updates CSS custom properties on the graph host: + +- `--torch-x` and `--torch-y`; +- `--torch-radius`; +- `--torch-intensity`; +- `--torch-warmth`; +- `--torch-core`. + +Pointer speed increases the light radius, code opacity, colour temperature and central halo. A multi-stop radial mask reveals the code gradually, resembling a torch illuminating writing on a dark wall rather than a flat circular spotlight. Both standard and WebKit mask declarations are included. + +The code remains real DOM text and stays below the WebGL canvas. When the node is released, all light variables return to zero and the wall fades into darkness. + +## Loading, accessibility and performance + +The first render uses an inline SVG produced from the same presentation graph. Three.js is code-split and loaded after browser idle time or initial pointer interaction. The implementation also: +- adapts graph source density for mobile, tablet and desktop; - caps device pixel ratio; -- uses adaptive source density for mobile, tablet and desktop; -- uses a responsive off-centre camera on desktop and a lower camera composition on narrow screens; -- pauses animation when the document is hidden; -- disposes GPU resources on disconnect; -- preserves the SVG fallback after WebGL context failure; -- keeps the static SVG when `prefers-reduced-motion: reduce` is enabled. +- pauses animation in hidden documents; +- disposes geometries, materials and renderer resources on disconnect; +- preserves the SVG fallback after WebGL context loss; +- preserves a static fallback when `prefers-reduced-motion: reduce` is active; +- keeps all meaningful content in static HTML. diff --git a/src/components/CalciferGraph.astro b/src/components/CalciferGraph.astro index 7bcfb86..442bd57 100644 --- a/src/components/CalciferGraph.astro +++ b/src/components/CalciferGraph.astro @@ -1,7 +1,7 @@ --- -import { createCalciferGraph } from '../scripts/graph/generateGraph' +import { createPresentationGraph } from '../scripts/graph/createPresentationGraph' -const fallbackGraph = createCalciferGraph({ nodeCount: 180, seed: 20260710 }) +const fallbackGraph = createPresentationGraph({ nodeCount: 150, seed: 20260710 }) const toColor = (color: readonly [number, number, number]): string => `rgb(${Math.round(color[0] * 255)} ${Math.round(color[1] * 255)} ${Math.round(color[2] * 255)})` @@ -104,7 +104,7 @@ return { verified: review.isSafe, }`, `const release = await prepareRelease({ - branch: "feat/reference-matched-fire-demon", + branch: "feat/face-combustion-torch-refinement", target: "main", checks: [ "types", @@ -121,12 +121,49 @@ const pullRequest = await openPullRequest({ }) export default pullRequest`, + `const route = await inspectRoute("/dashboard") +const mobile = await renderAtViewport(route, { + width: 390, + height: 844, +}) + +const issue = classifyVisualFailure({ + expected: designIntent, + actual: mobile, + consoleErrors, + networkErrors, +}) + +const fix = await implementSmallestCause(issue) +await verifyExistingChecks(fix)`, + `export const evidence = { + changedFiles: diff.files, + commands: [ + "npm test", + "npm run build", + ], + visual: { + desktop: screenshots.desktop, + mobile: screenshots.mobile, + }, + limitations: knownLimitations, +} + +assertNoFakeVerification(evidence) +return evidence`, ] -const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % codeSources.length]) +const codeColumns = Array.from({ length: 18 }, (_, index) => { + const first = codeSources[index % codeSources.length] + const second = codeSources[(index + 3) % codeSources.length] + + return `${first}\n\n${second}` +}) --- + + @@ -143,7 +180,7 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % aria-label="A smiling fire demon formed from connected graph nodes" > - {fallbackGraph.edges.slice(0, 2200).map((edge) => { + {fallbackGraph.edges.slice(0, 2800).map((edge) => { const source = fallbackGraph.nodes[edge.source] const target = fallbackGraph.nodes[edge.target] @@ -151,6 +188,10 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % return null } + const pupilEdge = source.role === 'pupil' || target.role === 'pupil' + const cavityEdge = + source.feature === 'mouth-cavity' || target.feature === 'mouth-cavity' + return ( codeSources[index % x2={target.x} y2={target.y} stroke={toColor(source.color)} - stroke-opacity={source.role === 'pupil' ? '0.035' : '0.28'} - stroke-width="0.0042" + stroke-opacity={pupilEdge ? '0.045' : cavityEdge ? '0.08' : '0.29'} + stroke-width="0.004" /> ) })} @@ -169,7 +210,7 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % cy={node.y} r={node.size / 760} fill={toColor(node.color)} - fill-opacity={node.role === 'pupil' ? '1' : '0.97'} + fill-opacity={node.role === 'pupil' ? '1' : node.feature === 'mouth-cavity' ? '0.78' : '0.97'} /> ))} @@ -178,7 +219,7 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % The graph is decorative. Drag a visible node to stretch the connected fire demon across the section - and reveal the code hidden in the darkness behind it. + and illuminate the wall of program code hidden in the darkness behind it. @@ -266,6 +307,7 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % --torch-radius: 190px; --torch-intensity: 0; --torch-warmth: 0; + --torch-core: 0; position: relative; display: block; width: 100%; @@ -281,12 +323,12 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % .graph::after { position: absolute; inset: -12%; - z-index: 0; content: ''; pointer-events: none; } .graph::before { + z-index: 0; background: radial-gradient(circle at 68% 54%, rgba(255, 78, 12, 0.18), transparent 22%), radial-gradient(circle at 70% 44%, rgba(255, 188, 60, 0.075), transparent 34%), @@ -296,28 +338,51 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % } .graph::after { + z-index: 2; background: radial-gradient( - circle var(--torch-radius) at var(--torch-x) var(--torch-y), - rgba(255, 190, 85, calc(var(--torch-intensity) * 0.15)), - rgba(255, 72, 15, calc(var(--torch-intensity) * 0.075)) 42%, - transparent 78% + circle calc(var(--torch-radius) * 0.34) at var(--torch-x) var(--torch-y), + rgba(255, 224, 154, calc(var(--torch-core) * 0.2)), + rgba(255, 133, 38, calc(var(--torch-core) * 0.12)) 48%, + transparent 100% ), - url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.42'/%3E%3C/svg%3E"); - mix-blend-mode: screen, soft-light; - opacity: calc(0.08 + var(--torch-warmth) * 0.28); + radial-gradient( + circle var(--torch-radius) at var(--torch-x) var(--torch-y), + rgba(255, 174, 72, calc(var(--torch-intensity) * 0.12)), + rgba(255, 65, 12, calc(var(--torch-intensity) * 0.07)) 48%, + transparent 82% + ); + mix-blend-mode: screen; + opacity: calc(var(--torch-warmth) * 0.86); transition: opacity 420ms ease; } .canvas, .fallback, - .code-field { + .code-field, + .code-wall-surface { position: absolute; inset: 0; width: 100%; height: 100%; } + .code-wall-surface { + z-index: 0; + background: + linear-gradient(90deg, rgba(255, 255, 255, 0.012), transparent 8%, transparent 92%, rgba(255, 255, 255, 0.008)), + repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.007) 0, + rgba(255, 255, 255, 0.007) 1px, + transparent 1px, + transparent 4px + ), + url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.82' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.38'/%3E%3C/svg%3E"); + background-blend-mode: screen, normal, soft-light; + opacity: 0.075; + } + .canvas { z-index: 4; display: block; @@ -341,50 +406,87 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % z-index: 1; display: grid; box-sizing: border-box; - grid-template-columns: repeat(12, minmax(92px, 1fr)); - gap: clamp(14px, 1.9vw, 34px); - padding: 1.4vh 1.1vw; + grid-template-columns: repeat(18, minmax(58px, 1fr)); + gap: clamp(7px, 0.95vw, 18px); + padding: 0.8vh 0.6vw; overflow: hidden; - color: rgba(255, 220, 168, 0.72); + color: rgba(255, 224, 178, 0.76); font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; pointer-events: none; - transform: rotate(-0.65deg) scale(1.035); + transform: rotate(-0.42deg) scale(1.025); transform-origin: center; } .code-field pre { + position: relative; min-width: 0; margin: 0; font: inherit; - font-size: clamp(5px, 0.37vw, 7px); - line-height: 1.68; - letter-spacing: -0.035em; + font-size: clamp(4.2px, 0.31vw, 6px); + line-height: 1.54; + letter-spacing: -0.045em; white-space: pre-wrap; word-break: break-word; } + .code-field pre:nth-child(3n + 1) { + transform: translateY(-2.2vh); + } + + .code-field pre:nth-child(3n + 2) { + transform: translateY(1.4vh); + } + .code-field--ambient { - opacity: 0.019; - filter: blur(0.22px); + opacity: 0.012; + filter: blur(0.18px); } .code-field--lit { z-index: 2; opacity: var(--torch-intensity); + text-shadow: + 0 0 5px rgba(255, 194, 105, 0.52), + 0 0 15px rgba(255, 77, 16, 0.22); filter: - drop-shadow(0 0 7px rgba(255, 142, 48, 0.38)) - drop-shadow(0 0 20px rgba(255, 72, 14, 0.16)); + sepia(calc(var(--torch-warmth) * 0.52)) + saturate(calc(1 + var(--torch-warmth) * 0.9)); + -webkit-mask-image: radial-gradient( + circle var(--torch-radius) at var(--torch-x) var(--torch-y), + #000 0, + rgba(0, 0, 0, 0.99) 18%, + rgba(0, 0, 0, 0.78) 46%, + rgba(0, 0, 0, 0.34) 72%, + transparent 100% + ); mask-image: radial-gradient( circle var(--torch-radius) at var(--torch-x) var(--torch-y), #000 0, - rgba(0, 0, 0, 0.98) 24%, - rgba(0, 0, 0, 0.62) 58%, - rgba(0, 0, 0, 0.18) 78%, + rgba(0, 0, 0, 0.99) 18%, + rgba(0, 0, 0, 0.78) 46%, + rgba(0, 0, 0, 0.34) 72%, transparent 100% ); transition: opacity 460ms ease; } + .code-field--lit::before { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: radial-gradient( + circle var(--torch-radius) at var(--torch-x) var(--torch-y), + rgba(255, 169, 70, calc(var(--torch-warmth) * 0.08)), + rgba(85, 24, 7, calc(var(--torch-warmth) * 0.06)) 55%, + transparent 100% + ); + } + + .graph[data-dragging='true'] .code-field--lit { + mix-blend-mode: screen; + } + .graph[data-ready='true'] .canvas { opacity: 1; } @@ -405,9 +507,13 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % } .code-field { - grid-template-columns: repeat(8, minmax(90px, 1fr)); - gap: 18px; - padding: 1.5vh 1.4vw; + grid-template-columns: repeat(12, minmax(62px, 1fr)); + gap: 10px; + padding: 0.9vh 0.8vw; + } + + .code-field pre { + font-size: clamp(4.2px, 0.52vw, 5.4px); } } @@ -422,14 +528,14 @@ const codeColumns = Array.from({ length: 12 }, (_, index) => codeSources[index % } .code-field { - grid-template-columns: repeat(6, minmax(74px, 1fr)); - gap: 12px; - padding: 8px; + grid-template-columns: repeat(8, minmax(58px, 1fr)); + gap: 8px; + padding: 6px; } .code-field pre { - font-size: 5px; - line-height: 1.58; + font-size: 4px; + line-height: 1.5; } } diff --git a/src/scripts/graph/createPresentationGraph.ts b/src/scripts/graph/createPresentationGraph.ts new file mode 100644 index 0000000..2e4714d --- /dev/null +++ b/src/scripts/graph/createPresentationGraph.ts @@ -0,0 +1,57 @@ +import { createCalciferGraph } from './generateGraph' +import type { CalciferGraphData } from './types' + +interface CreatePresentationGraphOptions { + nodeCount: number + seed?: number +} + +const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, value)) + +const deepenMouthGeometry = (graph: CalciferGraphData): void => { + for (const node of graph.nodes) { + if (node.feature === 'mouth-outer-rim' && node.y < -0.56) { + const depth = clamp((-node.y - 0.56) / 0.15, 0, 1) + node.y -= depth * 0.15 + } + + if (node.feature === 'mouth-inner-rim' && node.y < -0.58) { + const depth = clamp((-node.y - 0.58) / 0.12, 0, 1) + node.y -= depth * 0.1 + } + + if (node.feature === 'mouth-highlight' && node.y < -0.61) { + const depth = clamp((-node.y - 0.61) / 0.1, 0, 1) + node.y -= depth * 0.09 + } + } + + for (const edge of graph.edges) { + const source = graph.nodes[edge.source] + const target = graph.nodes[edge.target] + + if (!source || !target) { + continue + } + + edge.restLength = Math.hypot( + source.x - target.x, + source.y - target.y, + source.z - target.z, + ) + } +} + +export const createPresentationGraph = ( + options: CreatePresentationGraphOptions, +): CalciferGraphData => { + const graph = + options.seed === undefined + ? createCalciferGraph({ nodeCount: options.nodeCount }) + : createCalciferGraph({ nodeCount: options.nodeCount, seed: options.seed }) + + deepenMouthGeometry(graph) + + return graph +} diff --git a/src/scripts/graph/generateGraph.ts b/src/scripts/graph/generateGraph.ts index 123ba0e..c9d2ced 100644 --- a/src/scripts/graph/generateGraph.ts +++ b/src/scripts/graph/generateGraph.ts @@ -3,6 +3,7 @@ import { REFERENCE_MASK_BOUNDS, sampleReferenceDensity } from './referenceMask' import type { CalciferGraphData, GraphEdgeSeed, + GraphNodeFeature, GraphNodeRole, GraphNodeSeed, Rgb, @@ -18,6 +19,23 @@ interface Point { y: number } +interface EyeDefinition { + centerX: number + centerY: number + radiusX: number + radiusY: number + pupilOffsetX: number + pupilOffsetY: number + wobblePhase: number +} + +interface FaceGroup { + ids: number[] + anchors: number[] +} + +const TAU = Math.PI * 2 + const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)) @@ -41,9 +59,9 @@ const pointInsideEllipse = ( Math.pow((x - centerX) / radiusX, 2) + Math.pow((y - centerY) / radiusY, 2) <= 1 const pointInsideFaceVoid = (x: number, y: number): boolean => { - const leftEye = pointInsideEllipse(x, y, -0.29, -0.25, 0.235, 0.215) - const rightEye = pointInsideEllipse(x, y, 0.29, -0.25, 0.235, 0.215) - const mouth = pointInsideEllipse(x, y, 0, -0.61, 0.43, 0.255) + const leftEye = pointInsideEllipse(x, y, -0.305, -0.255, 0.252, 0.232) + const rightEye = pointInsideEllipse(x, y, 0.295, -0.248, 0.242, 0.222) + const mouth = pointInsideEllipse(x, y, 0, -0.64, 0.455, 0.275) return leftEye || rightEye || mouth } @@ -140,10 +158,10 @@ const addNode = ( color: Rgb, anchorStrength: number, z = 0, + feature?: GraphNodeFeature, ): number => { const id = nodes.length - - nodes.push({ + const node: GraphNodeSeed = { id, role, x: point.x, @@ -152,8 +170,14 @@ const addNode = ( size, color, anchorStrength, - phase: random() * Math.PI * 2, - }) + phase: random() * TAU, + } + + if (feature !== undefined) { + node.feature = feature + } + + nodes.push(node) return id } @@ -223,6 +247,37 @@ const nearestNode = ( return nearest } +const nearestNodeFromIds = ( + nodes: GraphNodeSeed[], + point: Point, + nodeIds: number[], + excluded = -1, +): number => { + let nearest = -1 + let nearestDistance = Number.POSITIVE_INFINITY + + for (const nodeId of nodeIds) { + if (nodeId === excluded) { + continue + } + + const node = nodes[nodeId] + + if (!node) { + continue + } + + const distance = Math.hypot(node.x - point.x, node.y - point.y) + + if (distance < nearestDistance) { + nearest = nodeId + nearestDistance = distance + } + } + + return nearest +} + const connectNearestNeighbours = ( nodes: GraphNodeSeed[], edges: GraphEdgeSeed[], @@ -300,150 +355,275 @@ const addEye = ( edges: GraphEdgeSeed[], edgeKeys: Set, random: () => number, - centerX: number, -): { eyeIds: number[]; pupilIds: number[] } => { - const centerY = -0.25 - const eyeIds: number[] = [] - const pupilIds: number[] = [] + definition: EyeDefinition, + detailScale: number, +): FaceGroup => { + const ids: number[] = [] + const anchors: number[] = [] const outerRing: number[] = [] - const innerRing: number[] = [] - - for (let index = 0; index < 42; index += 1) { - const angle = (index / 42) * Math.PI * 2 - const wobble = 1 + Math.sin(angle * 5 + centerX * 8) * 0.025 + const middleRing: number[] = [] + const irisRing: number[] = [] + const pupilRing: number[] = [] + const scleraIds: number[] = [] + const outerCount = Math.round(mix(34, 48, detailScale)) + const middleCount = Math.round(mix(28, 40, detailScale)) + const irisCount = Math.round(mix(22, 30, detailScale)) + const scleraCount = Math.round(mix(58, 116, detailScale)) + const highlightCount = Math.round(mix(8, 14, detailScale)) + const pupilCount = Math.round(mix(16, 22, detailScale)) + const pupilFillCount = Math.round(mix(8, 16, detailScale)) + + for (let index = 0; index < outerCount; index += 1) { + const angle = (index / outerCount) * TAU + const wobble = + 1 + + Math.sin(angle * 5 + definition.wobblePhase) * 0.024 + + Math.sin(angle * 9 - definition.wobblePhase) * 0.012 const point = { - x: centerX + Math.cos(angle) * 0.205 * wobble, - y: centerY + Math.sin(angle) * 0.184 * wobble, + x: definition.centerX + Math.cos(angle) * definition.radiusX * wobble, + y: definition.centerY + Math.sin(angle) * definition.radiusY * wobble, } + const hub = random() < 0.12 const id = addNode( nodes, random, 'eye', point, - mix(3.2, 7.6, random()), - mixColor([1, 0.72, 0.18], [1, 0.99, 0.9], 0.62 + random() * 0.38), - 17, - 0.16, + hub ? mix(6.2, 9.4, random()) : mix(2.2, 6.2, random()), + mixColor([1, 0.44, 0.055], [1, 0.98, 0.84], 0.52 + random() * 0.48), + 19, + 0.175, + 'eye-outer-rim', ) outerRing.push(id) - eyeIds.push(id) + ids.push(id) } - for (let index = 0; index < 32; index += 1) { - const angle = (index / 32) * Math.PI * 2 + for (let index = 0; index < middleCount; index += 1) { + const angle = (index / middleCount) * TAU + const wobble = 1 + Math.sin(angle * 7 + definition.wobblePhase * 0.7) * 0.018 const point = { - x: centerX + Math.cos(angle) * 0.132, - y: centerY + Math.sin(angle) * 0.119, + x: definition.centerX + Math.cos(angle) * definition.radiusX * 0.79 * wobble, + y: definition.centerY + Math.sin(angle) * definition.radiusY * 0.79 * wobble, } const id = addNode( nodes, random, 'eye', point, - mix(2.8, 5.8, random()), - [1, 0.96, 0.78], - 17.5, - 0.17, + mix(1.9, 5.2, random()), + mixColor([1, 0.76, 0.23], [1, 1, 0.94], 0.64 + random() * 0.36), + 19.5, + 0.18, + 'eye-sclera', ) - innerRing.push(id) - eyeIds.push(id) + middleRing.push(id) + ids.push(id) } - connectLoop(nodes, edges, edgeKeys, outerRing, 27) - connectLoop(nodes, edges, edgeKeys, innerRing, 27) + connectLoop(nodes, edges, edgeKeys, outerRing, 30) + connectLoop(nodes, edges, edgeKeys, middleRing, 29) for (let index = 0; index < outerRing.length; index += 2) { const outer = outerRing[index] - const inner = innerRing[Math.floor((index / outerRing.length) * innerRing.length)] + const middle = middleRing[Math.floor((index / outerRing.length) * middleRing.length)] - if (outer !== undefined && inner !== undefined) { - addEdge(edges, edgeKeys, nodes, outer, inner, 21) + if (outer !== undefined && middle !== undefined) { + addEdge(edges, edgeKeys, nodes, outer, middle, 23) } } - for (let index = 0; index < 68; index += 1) { - const angle = random() * Math.PI * 2 - const radius = Math.sqrt(random()) + for (let index = 0; index < scleraCount; index += 1) { + const angle = random() * TAU + const minimumRadius = 0.31 + const maximumRadius = 0.93 + const radius = Math.sqrt( + minimumRadius * minimumRadius + + random() * (maximumRadius * maximumRadius - minimumRadius * minimumRadius), + ) + const jitter = mix(0.96, 1.04, random()) const point = { - x: centerX + Math.cos(angle) * 0.176 * radius, - y: centerY + Math.sin(angle) * 0.158 * radius, + x: definition.centerX + Math.cos(angle) * definition.radiusX * radius * jitter, + y: definition.centerY + Math.sin(angle) * definition.radiusY * radius / jitter, } - const normalized = Math.hypot((point.x - centerX) / 0.176, (point.y - centerY) / 0.158) + const hub = random() < 0.035 + const id = addNode( + nodes, + random, + 'eye', + point, + hub ? mix(5.2, 7.8, random()) : mix(1.25, 4.15, Math.pow(random(), 1.3)), + mixColor([1, 0.76, 0.22], [1, 1, 0.96], 0.6 + random() * 0.4), + 19.2, + mix(0.178, 0.195, random()), + 'eye-sclera', + ) + scleraIds.push(id) + ids.push(id) + } - if (normalized < 0.31) { - continue - } + connectNearestNeighbours( + nodes, + edges, + edgeKeys, + [...middleRing, ...scleraIds], + 3, + Math.max(definition.radiusX, definition.radiusY) * 0.43, + 19, + ) + for (let index = 0; index < irisCount; index += 1) { + const angle = (index / irisCount) * TAU + const wobble = 1 + Math.sin(angle * 6 - definition.wobblePhase) * 0.018 + const point = { + x: + definition.centerX + + definition.pupilOffsetX + + Math.cos(angle) * definition.radiusX * 0.34 * wobble, + y: + definition.centerY + + definition.pupilOffsetY + + Math.sin(angle) * definition.radiusY * 0.34 * wobble, + } const id = addNode( nodes, random, 'eye', point, - mix(1.8, 4.4, random()), - mixColor([1, 0.67, 0.13], [1, 1, 0.92], random()), - 17, - 0.17, + mix(2.2, 5.6, random()), + mixColor([1, 0.69, 0.12], [1, 1, 0.9], 0.52 + random() * 0.48), + 20.5, + 0.205, + 'eye-iris', ) - eyeIds.push(id) - const target = nearestNode(nodes, point, new Set(['eye']), id) - addEdge(edges, edgeKeys, nodes, id, target, 18) + irisRing.push(id) + ids.push(id) } - const pupilCenterX = centerX + (centerX < 0 ? 0.012 : -0.012) - const pupilCenterY = centerY - 0.004 - const pupilRing: number[] = [] + connectLoop(nodes, edges, edgeKeys, irisRing, 31) - for (let index = 0; index < 18; index += 1) { - const angle = (index / 18) * Math.PI * 2 + const pupilCenter = { + x: definition.centerX + definition.pupilOffsetX, + y: definition.centerY + definition.pupilOffsetY, + } + + for (let index = 0; index < pupilCount; index += 1) { + const angle = (index / pupilCount) * TAU const point = { - x: pupilCenterX + Math.cos(angle) * 0.058, - y: pupilCenterY + Math.sin(angle) * 0.055, + x: pupilCenter.x + Math.cos(angle) * definition.radiusX * 0.235, + y: pupilCenter.y + Math.sin(angle) * definition.radiusY * 0.235, } - const id = addNode(nodes, random, 'pupil', point, mix(5.8, 8.6, random()), [0.006, 0.005, 0.006], 22, 0.2) + const id = addNode( + nodes, + random, + 'pupil', + point, + mix(4.6, 7.8, random()), + [0.004, 0.004, 0.006], + 24, + 0.24, + 'eye-pupil', + ) pupilRing.push(id) - pupilIds.push(id) + ids.push(id) } - connectLoop(nodes, edges, edgeKeys, pupilRing, 31) + connectLoop(nodes, edges, edgeKeys, pupilRing, 34) const pupilHub = addNode( nodes, random, 'pupil', - { x: pupilCenterX, y: pupilCenterY }, - 12.5, - [0.003, 0.003, 0.004], - 24, - 0.205, + pupilCenter, + mix(11.5, 14.5, random()), + [0.002, 0.002, 0.004], + 26, + 0.245, + 'eye-pupil', ) - pupilIds.push(pupilHub) + ids.push(pupilHub) + + for (let index = 0; index < pupilFillCount; index += 1) { + const angle = random() * TAU + const radius = Math.sqrt(random()) * 0.19 + const point = { + x: pupilCenter.x + Math.cos(angle) * definition.radiusX * radius, + y: pupilCenter.y + Math.sin(angle) * definition.radiusY * radius, + } + const id = addNode( + nodes, + random, + 'pupil', + point, + mix(3.2, 6.4, random()), + [0.003, 0.003, 0.005], + 25, + mix(0.235, 0.248, random()), + 'eye-pupil', + ) + ids.push(id) + addEdge(edges, edgeKeys, nodes, pupilHub, id, 32) + } - for (let index = 0; index < pupilRing.length; index += 3) { - const pupil = pupilRing[index] - const eye = innerRing[Math.floor((index / pupilRing.length) * innerRing.length)] + for (let index = 0; index < irisRing.length; index += 2) { + const iris = irisRing[index] + const pupil = pupilRing[Math.floor((index / irisRing.length) * pupilRing.length)] - if (pupil !== undefined) { - addEdge(edges, edgeKeys, nodes, pupilHub, pupil, 31) + if (iris !== undefined && pupil !== undefined) { + addEdge(edges, edgeKeys, nodes, iris, pupil, 23) } + } + + const highlightCenter = { + x: definition.centerX - definition.radiusX * 0.36, + y: definition.centerY + definition.radiusY * 0.38, + } + const highlightIds: number[] = [] - if (pupil !== undefined && eye !== undefined) { - addEdge(edges, edgeKeys, nodes, pupil, eye, 18) + for (let index = 0; index < highlightCount; index += 1) { + const angle = random() * TAU + const radius = Math.sqrt(random()) + const point = { + x: highlightCenter.x + Math.cos(angle) * definition.radiusX * 0.12 * radius, + y: highlightCenter.y + Math.sin(angle) * definition.radiusY * 0.1 * radius, } + const id = addNode( + nodes, + random, + 'eye', + point, + mix(1.6, 4.2, random()), + [1, 1, 0.98], + 20, + 0.225, + 'eye-highlight', + ) + highlightIds.push(id) + ids.push(id) } - for (const index of [2, 11, 21, 31]) { - const eye = outerRing[index] + connectNearestNeighbours(nodes, edges, edgeKeys, highlightIds, 2, 0.075, 22) + + for (const highlightId of highlightIds) { + const highlight = nodes[highlightId] - if (eye !== undefined) { - const eyeNode = nodes[eye] - const target = eyeNode - ? nearestNode(nodes, eyeNode, new Set(['flame', 'rim']), eye) - : -1 - addEdge(edges, edgeKeys, nodes, eye, target, 12) + if (!highlight) { + continue + } + + const target = nearestNodeFromIds(nodes, highlight, [...middleRing, ...scleraIds], highlightId) + addEdge(edges, edgeKeys, nodes, highlightId, target, 20) + } + + for (const index of [1, Math.floor(outerCount * 0.25), Math.floor(outerCount * 0.5), Math.floor(outerCount * 0.75)]) { + const anchor = outerRing[index] + + if (anchor !== undefined) { + anchors.push(anchor) } } - return { eyeIds, pupilIds } + return { ids, anchors } } const addMouth = ( @@ -451,39 +631,194 @@ const addMouth = ( edges: GraphEdgeSeed[], edgeKeys: Set, random: () => number, -): number[] => { - const mouthIds: number[] = [] - const upperLip: number[] = [] - const lowerLip: number[] = [] + detailScale: number, +): FaceGroup => { + const ids: number[] = [] + const anchors: number[] = [] + const outerUpper: number[] = [] + const outerLower: number[] = [] + const innerUpper: number[] = [] + const innerLower: number[] = [] + const tongueIds: number[] = [] + const upperCount = Math.round(mix(30, 42, detailScale)) + const lowerCount = Math.round(mix(34, 48, detailScale)) + const innerUpperCount = Math.round(mix(26, 36, detailScale)) + const innerLowerCount = Math.round(mix(30, 40, detailScale)) + const cavityCount = Math.round(mix(10, 18, detailScale)) + const tongueCount = Math.round(mix(36, 72, detailScale)) + const tongueCurveCount = Math.round(mix(20, 30, detailScale)) + const highlightCount = Math.round(mix(18, 30, detailScale)) + + const addContour = ( + target: number[], + count: number, + start: Point, + control: Point, + end: Point, + feature: 'mouth-outer-rim' | 'mouth-inner-rim', + outer: boolean, + ): void => { + for (let index = 0; index < count; index += 1) { + const progress = index / (count - 1) + const point = quadraticPoint(start, control, end, progress) + const corner = Math.min(progress, 1 - progress) + const hub = corner < 0.075 || random() < 0.045 + const id = addNode( + nodes, + random, + 'mouth', + point, + hub ? mix(6.2, 9.3, random()) : mix(2.1, outer ? 6.1 : 5.1, random()), + outer + ? mixColor([1, 0.43, 0.045], [0.82, 0.018, 0.032], Math.sin(progress * Math.PI) * 0.68) + : mixColor([1, 0.18, 0.025], [0.42, 0.004, 0.016], Math.sin(progress * Math.PI) * 0.76), + outer ? 20 : 21, + outer ? 0.205 : 0.215, + feature, + ) + target.push(id) + ids.push(id) + } + } - for (let index = 0; index < 34; index += 1) { - const progress = index / 33 - const point = quadraticPoint( - { x: -0.39, y: -0.48 }, - { x: 0, y: -0.59 }, - { x: 0.39, y: -0.48 }, - progress, + addContour( + outerUpper, + upperCount, + { x: -0.42, y: -0.505 }, + { x: 0, y: -0.575 }, + { x: 0.42, y: -0.505 }, + 'mouth-outer-rim', + true, + ) + addContour( + outerLower, + lowerCount, + { x: -0.42, y: -0.515 }, + { x: 0, y: -0.9 }, + { x: 0.42, y: -0.515 }, + 'mouth-outer-rim', + true, + ) + addContour( + innerUpper, + innerUpperCount, + { x: -0.355, y: -0.545 }, + { x: 0, y: -0.605 }, + { x: 0.355, y: -0.545 }, + 'mouth-inner-rim', + false, + ) + addContour( + innerLower, + innerLowerCount, + { x: -0.355, y: -0.555 }, + { x: 0, y: -0.825 }, + { x: 0.355, y: -0.555 }, + 'mouth-inner-rim', + false, + ) + + connectChain(nodes, edges, edgeKeys, outerUpper, 32) + connectChain(nodes, edges, edgeKeys, outerLower, 32) + connectChain(nodes, edges, edgeKeys, innerUpper, 31) + connectChain(nodes, edges, edgeKeys, innerLower, 31) + addEdge(edges, edgeKeys, nodes, outerUpper[0] ?? -1, outerLower[0] ?? -1, 34) + addEdge( + edges, + edgeKeys, + nodes, + outerUpper[outerUpper.length - 1] ?? -1, + outerLower[outerLower.length - 1] ?? -1, + 34, + ) + addEdge(edges, edgeKeys, nodes, innerUpper[0] ?? -1, innerLower[0] ?? -1, 33) + addEdge( + edges, + edgeKeys, + nodes, + innerUpper[innerUpper.length - 1] ?? -1, + innerLower[innerLower.length - 1] ?? -1, + 33, + ) + + for (let index = 0; index < outerUpper.length; index += 3) { + const outer = outerUpper[index] + const inner = innerUpper[Math.floor((index / outerUpper.length) * innerUpper.length)] + + if (outer !== undefined && inner !== undefined) { + addEdge(edges, edgeKeys, nodes, outer, inner, 24) + } + } + + for (let index = 0; index < outerLower.length; index += 3) { + const outer = outerLower[index] + const inner = innerLower[Math.floor((index / outerLower.length) * innerLower.length)] + + if (outer !== undefined && inner !== undefined) { + addEdge(edges, edgeKeys, nodes, outer, inner, 24) + } + } + + for (let index = 0; index < cavityCount; index += 1) { + const angle = random() * TAU + const radius = Math.sqrt(random()) + const point = { + x: Math.cos(angle) * 0.31 * radius, + y: -0.64 + Math.sin(angle) * 0.115 * radius, + } + + if (point.y < -0.69) { + continue + } + + const id = addNode( + nodes, + random, + 'mouth', + point, + mix(1.2, 3.1, random()), + mixColor([0.018, 0.001, 0.006], [0.31, 0.006, 0.018], random() * 0.5), + 21, + 0.22, + 'mouth-cavity', ) + ids.push(id) + const target = nearestNodeFromIds(nodes, point, [...innerUpper, ...innerLower], id) + addEdge(edges, edgeKeys, nodes, id, target, 15) + } + + for (let index = 0; index < tongueCount; index += 1) { + const angle = random() * TAU + const radius = Math.sqrt(random()) + const point = { + x: Math.cos(angle) * 0.275 * radius, + y: -0.745 + Math.sin(angle) * 0.082 * radius, + } const id = addNode( nodes, random, 'mouth', point, - mix(3.2, 6.6, random()), - mixColor([1, 0.22, 0.025], [0.68, 0.012, 0.02], Math.sin(progress * Math.PI) * 0.5), - 18, - 0.18, + random() < 0.045 ? mix(5.2, 7.1, random()) : mix(1.45, 4.25, random()), + mixColor([0.58, 0.004, 0.026], [1, 0.19, 0.035], 0.38 + random() * 0.62), + 21, + mix(0.218, 0.235, random()), + 'mouth-tongue', ) - upperLip.push(id) - mouthIds.push(id) + tongueIds.push(id) + ids.push(id) } - for (let index = 0; index < 38; index += 1) { - const progress = index / 37 + connectNearestNeighbours(nodes, edges, edgeKeys, tongueIds, 3, 0.085, 20) + + const tongueCurve: number[] = [] + + for (let index = 0; index < tongueCurveCount; index += 1) { + const progress = index / (tongueCurveCount - 1) const point = quadraticPoint( - { x: -0.39, y: -0.49 }, - { x: 0, y: -0.86 }, - { x: 0.39, y: -0.49 }, + { x: -0.27, y: -0.69 }, + { x: 0, y: -0.735 }, + { x: 0.27, y: -0.69 }, progress, ) const id = addNode( @@ -491,83 +826,91 @@ const addMouth = ( random, 'mouth', point, - mix(3, 6.2, random()), - mixColor([0.94, 0.045, 0.02], [0.48, 0.006, 0.018], Math.sin(progress * Math.PI)), - 18, - 0.18, + mix(2.1, 4.8, random()), + mixColor([0.75, 0.018, 0.035], [1, 0.35, 0.055], 0.48 + random() * 0.52), + 21.5, + 0.238, + 'mouth-highlight', ) - lowerLip.push(id) - mouthIds.push(id) + tongueCurve.push(id) + ids.push(id) } - connectChain(nodes, edges, edgeKeys, upperLip, 29) - connectChain(nodes, edges, edgeKeys, lowerLip, 29) - addEdge(edges, edgeKeys, nodes, upperLip[0] ?? -1, lowerLip[0] ?? -1, 30) - addEdge( - edges, - edgeKeys, - nodes, - upperLip[upperLip.length - 1] ?? -1, - lowerLip[lowerLip.length - 1] ?? -1, - 30, - ) + connectChain(nodes, edges, edgeKeys, tongueCurve, 27) - for (let index = 0; index < 86; index += 1) { - const angle = random() * Math.PI * 2 - const radius = Math.sqrt(random()) + for (let index = 0; index < tongueCurve.length; index += 3) { + const curve = tongueCurve[index] + const lower = innerLower[Math.floor((index / tongueCurve.length) * innerLower.length)] + + if (curve !== undefined && lower !== undefined) { + addEdge(edges, edgeKeys, nodes, curve, lower, 21) + } + } + + const lipHighlights: number[] = [] + + for (let index = 0; index < highlightCount; index += 1) { + const lower = random() > 0.42 + const progress = random() + const base = lower + ? quadraticPoint( + { x: -0.39, y: -0.52 }, + { x: 0, y: -0.865 }, + { x: 0.39, y: -0.52 }, + progress, + ) + : quadraticPoint( + { x: -0.39, y: -0.51 }, + { x: 0, y: -0.58 }, + { x: 0.39, y: -0.51 }, + progress, + ) const point = { - x: Math.cos(angle) * 0.34 * radius, - y: -0.64 + Math.sin(angle) * 0.17 * radius, + x: base.x + mix(-0.012, 0.012, random()), + y: base.y + mix(-0.012, 0.012, random()), } const id = addNode( nodes, random, 'mouth', point, - mix(1.8, 4.8, random()), - mixColor([0.16, 0.002, 0.008], [1, 0.12, 0.025], Math.pow(random(), 1.6)), - 18.5, - 0.19, + mix(1.35, 3.7, random()), + mixColor([1, 0.45, 0.055], [1, 0.92, 0.48], random() * 0.72), + 20.5, + 0.245, + 'mouth-highlight', ) - mouthIds.push(id) - const target = nearestNode(nodes, point, new Set(['mouth']), id) - addEdge(edges, edgeKeys, nodes, id, target, 20) + lipHighlights.push(id) + ids.push(id) } - for (let index = 0; index < 24; index += 1) { - const progress = index / 23 - const point = quadraticPoint( - { x: -0.25, y: -0.7 }, - { x: 0, y: -0.79 }, - { x: 0.25, y: -0.7 }, - progress, - ) - const id = addNode( + for (const highlightId of lipHighlights) { + const highlight = nodes[highlightId] + + if (!highlight) { + continue + } + + const target = nearestNodeFromIds( nodes, - random, - 'mouth', - point, - mix(2.2, 4.5, random()), - mixColor([0.72, 0.015, 0.025], [1, 0.23, 0.035], 0.45 + random() * 0.55), - 19, - 0.2, + highlight, + [...outerUpper, ...outerLower, ...innerUpper, ...innerLower], + highlightId, ) - mouthIds.push(id) + addEdge(edges, edgeKeys, nodes, highlightId, target, 21) } - for (const index of [0, 9, 18, 27]) { - const mouth = upperLip[index] + for (const index of [0, Math.floor(outerUpper.length * 0.28), Math.floor(outerUpper.length * 0.72), outerUpper.length - 1]) { + const anchor = outerUpper[index] - if (mouth !== undefined) { - const mouthNode = nodes[mouth] - const target = mouthNode - ? nearestNode(nodes, mouthNode, new Set(['flame', 'rim']), mouth) - : -1 - addEdge(edges, edgeKeys, nodes, mouth, target, 12) + if (anchor !== undefined) { + anchors.push(anchor) } } - return mouthIds + anchors.push(outerLower[Math.floor(outerLower.length * 0.5)] ?? -1) + + return { ids, anchors: anchors.filter((id) => id >= 0) } } export const createCalciferGraph = ({ @@ -580,7 +923,8 @@ export const createCalciferGraph = ({ const edgeKeys = new Set() const flameNodeIds: number[] = [] const rimNodeIds: number[] = [] - const desiredFlameCount = Math.max(180, Math.floor(nodeCount * 0.84)) + const detailScale = clamp(nodeCount / 760, 0.42, 1) + const desiredFlameCount = Math.max(140, Math.floor(nodeCount * 0.84)) let attempts = 0 while (flameNodeIds.length < desiredFlameCount && attempts < desiredFlameCount * 180) { @@ -617,7 +961,7 @@ export const createCalciferGraph = ({ flameNodeIds.push(id) } - const desiredRimCount = Math.max(82, Math.floor(nodeCount * 0.17)) + const desiredRimCount = Math.max(60, Math.floor(nodeCount * 0.15)) attempts = 0 while (rimNodeIds.length < desiredRimCount && attempts < desiredRimCount * 260) { @@ -645,6 +989,7 @@ export const createCalciferGraph = ({ } const armNodeIds: number[] = [] + const armCloudCount = Math.round(mix(28, 50, detailScale)) for (const side of [-1, 1]) { const mainArm: number[] = [] @@ -653,8 +998,8 @@ export const createCalciferGraph = ({ const controlB = { x: side * 1.12, y: 0.35 } const end = { x: side * 1.27, y: 0.68 } - for (let index = 0; index < 24; index += 1) { - const progress = index / 23 + for (let index = 0; index < 22; index += 1) { + const progress = index / 21 const point = cubicPoint(start, controlA, controlB, end, progress) point.x += Math.sin(progress * Math.PI * 3.1) * 0.022 * side point.y += Math.sin(progress * Math.PI) * 0.045 @@ -674,7 +1019,7 @@ export const createCalciferGraph = ({ connectChain(nodes, edges, edgeKeys, mainArm, 22) - for (let index = 0; index < 56; index += 1) { + for (let index = 0; index < armCloudCount; index += 1) { const progress = random() const center = cubicPoint(start, controlA, controlB, end, progress) const spread = mix(0.025, 0.14, Math.sin(progress * Math.PI)) @@ -693,7 +1038,7 @@ export const createCalciferGraph = ({ mix(0.02, 0.09, random()), ) armNodeIds.push(id) - const mainTarget = mainArm[Math.min(mainArm.length - 1, Math.round(progress * 23))] + const mainTarget = mainArm[Math.min(mainArm.length - 1, Math.round(progress * 21))] if (mainTarget !== undefined) { addEdge(edges, edgeKeys, nodes, id, mainTarget, 17) @@ -752,12 +1097,54 @@ export const createCalciferGraph = ({ } } - const leftEye = addEye(nodes, edges, edgeKeys, random, -0.29) - const rightEye = addEye(nodes, edges, edgeKeys, random, 0.29) - const mouthNodeIds = addMouth(nodes, edges, edgeKeys, random) + const leftEye = addEye( + nodes, + edges, + edgeKeys, + random, + { + centerX: -0.305, + centerY: -0.255, + radiusX: 0.224, + radiusY: 0.203, + pupilOffsetX: 0.014, + pupilOffsetY: -0.004, + wobblePhase: 0.35, + }, + detailScale, + ) + const rightEye = addEye( + nodes, + edges, + edgeKeys, + random, + { + centerX: 0.295, + centerY: -0.248, + radiusX: 0.213, + radiusY: 0.193, + pupilOffsetX: -0.014, + pupilOffsetY: -0.002, + wobblePhase: 1.2, + }, + detailScale, + ) + const mouth = addMouth(nodes, edges, edgeKeys, random, detailScale) + + for (const faceAnchor of [...leftEye.anchors, ...rightEye.anchors, ...mouth.anchors]) { + const faceNode = nodes[faceAnchor] + + if (!faceNode) { + continue + } + + const target = nearestNode(nodes, faceNode, new Set(['flame', 'rim']), faceAnchor) + addEdge(edges, edgeKeys, nodes, faceAnchor, target, 13.5) + } + const sparkNodeIds: number[] = [] const emberNodeIds: number[] = [] - const clusterCount = Math.max(20, Math.floor(nodeCount / 24)) + const clusterCount = Math.max(14, Math.floor(nodeCount / 30)) for (let clusterIndex = 0; clusterIndex < clusterCount; clusterIndex += 1) { const angle = mix(-0.06 * Math.PI, 1.06 * Math.PI, random()) @@ -778,11 +1165,11 @@ export const createCalciferGraph = ({ 0.11, ) sparkNodeIds.push(hub) - const rays = Math.floor(mix(7, 15, random())) + const rays = Math.floor(mix(6, 13, random())) for (let rayIndex = 0; rayIndex < rays; rayIndex += 1) { - const rayAngle = random() * Math.PI * 2 - const segments = random() < 0.42 ? 2 : 1 + const rayAngle = random() * TAU + const segments = random() < 0.38 ? 2 : 1 let previous = hub for (let segmentIndex = 0; segmentIndex < segments; segmentIndex += 1) { @@ -808,7 +1195,7 @@ export const createCalciferGraph = ({ } } - const emberPairCount = Math.max(34, Math.floor(nodeCount / 8)) + const emberPairCount = Math.max(24, Math.floor(nodeCount / 10)) for (let pairIndex = 0; pairIndex < emberPairCount; pairIndex += 1) { const angle = mix(-0.12 * Math.PI, 1.12 * Math.PI, random()) @@ -875,24 +1262,5 @@ export const createCalciferGraph = ({ addEdge(edges, edgeKeys, nodes, node.id, target, 8) } - const faceIds = [ - ...leftEye.eyeIds, - ...leftEye.pupilIds, - ...rightEye.eyeIds, - ...rightEye.pupilIds, - ...mouthNodeIds, - ] - - for (const faceId of faceIds) { - const node = nodes[faceId] - - if (!node) { - continue - } - - const target = nearestNode(nodes, node, new Set(['flame', 'rim']), faceId) - addEdge(edges, edgeKeys, nodes, faceId, target, 11) - } - return { nodes, edges } } diff --git a/src/scripts/graph/mountCalciferGraph.ts b/src/scripts/graph/mountCalciferGraph.ts index 078a6c2..00f4462 100644 --- a/src/scripts/graph/mountCalciferGraph.ts +++ b/src/scripts/graph/mountCalciferGraph.ts @@ -1,7 +1,7 @@ import * as THREE from 'three' -import { createCalciferGraph } from './generateGraph' +import { createPresentationGraph } from './createPresentationGraph' import { createDragInfluence, createPhysicsState, stepGraphPhysics } from './physics' -import type { CalciferGraphData, GraphNodeRole, Rgb } from './types' +import type { CalciferGraphData, GraphNodeSeed, Rgb } from './types' interface PointerSample { x: number @@ -38,13 +38,13 @@ const fragmentShader = ` float distanceToCenter = distance(gl_PointCoord, vec2(0.5)); if (distanceToCenter > 0.5) discard; - float hardCore = 1.0 - smoothstep(0.045, 0.24, distanceToCenter); - float softCore = 1.0 - smoothstep(0.12, 0.37, distanceToCenter); - float halo = 1.0 - smoothstep(0.22, 0.5, distanceToCenter); + float hardCore = 1.0 - smoothstep(0.04, 0.23, distanceToCenter); + float softCore = 1.0 - smoothstep(0.11, 0.37, distanceToCenter); + float halo = 1.0 - smoothstep(0.21, 0.5, distanceToCenter); float haloStrength = halo * vGlow; - float alpha = clamp(hardCore + softCore * 0.52 + haloStrength * (0.3 + vHeat * 0.36), 0.0, 1.0); - vec3 fireCore = vec3(1.0, 0.93, 0.68); - vec3 glow = mix(vColor, fireCore, (softCore * 0.12 + haloStrength * 0.24 + vHeat * 0.16) * vGlow); + float alpha = clamp(hardCore + softCore * 0.55 + haloStrength * (0.31 + vHeat * 0.39), 0.0, 1.0); + vec3 fireCore = vec3(1.0, 0.94, 0.72); + vec3 glow = mix(vColor, fireCore, (softCore * 0.13 + haloStrength * 0.25 + vHeat * 0.18) * vGlow); gl_FragColor = vec4(glow, alpha); } ` @@ -73,21 +73,49 @@ const writeColor = (target: Float32Array, offset: number, color: Rgb, multiplier target[offset + 2] = color[2] * multiplier } -const getRoleGlow = (role: GraphNodeRole): number => { - if (role === 'pupil') { +const getNodeGlow = (node: GraphNodeSeed): number => { + if (node.role === 'pupil' || node.feature === 'eye-pupil') { return 0 } - if (role === 'eye') { - return 1.18 + if (node.feature === 'mouth-cavity') { + return 0.045 } - if (role === 'spark' || role === 'ember') { + if (node.feature === 'eye-highlight') { + return 1.42 + } + + if (node.feature === 'eye-iris') { + return 1.3 + } + + if (node.feature === 'eye-sclera') { + return 1.22 + } + + if (node.feature === 'eye-outer-rim') { + return 1.14 + } + + if (node.feature === 'mouth-highlight') { return 1.12 } - if (role === 'mouth') { - return 0.76 + if (node.feature === 'mouth-tongue') { + return 0.88 + } + + if (node.feature === 'mouth-inner-rim') { + return 0.74 + } + + if (node.feature === 'mouth-outer-rim') { + return 0.92 + } + + if (node.role === 'spark' || node.role === 'ember') { + return 1.14 } return 1 @@ -108,7 +136,7 @@ const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => writeColor(colors, offset, node.color) sizes[node.id] = node.size heat[node.id] = 0 - glow[node.id] = getRoleGlow(node.role) + glow[node.id] = getNodeGlow(node) } const geometry = new THREE.BufferGeometry() @@ -121,6 +149,36 @@ const createPointGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => return geometry } +const getInitialEdgeMultiplier = (source: GraphNodeSeed, target: GraphNodeSeed): number => { + const pupilEdge = source.role === 'pupil' || target.role === 'pupil' + const irisSpoke = + pupilEdge && (source.feature === 'eye-iris' || target.feature === 'eye-iris') + const cavityEdge = + source.feature === 'mouth-cavity' || target.feature === 'mouth-cavity' + + if (irisSpoke) { + return 0.2 + } + + if (pupilEdge) { + return 0.035 + } + + if (cavityEdge) { + return 0.085 + } + + if (source.role === 'eye' && target.role === 'eye') { + return 0.58 + } + + if (source.role === 'mouth' && target.role === 'mouth') { + return 0.46 + } + + return 0.48 +} + const createLineGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { const positions = new Float32Array(graph.edges.length * 6) const colors = new Float32Array(graph.edges.length * 6) @@ -140,14 +198,15 @@ const createLineGeometry = (graph: CalciferGraphData): THREE.BufferGeometry => { } const offset = index * 6 + const multiplier = getInitialEdgeMultiplier(source, target) positions[offset] = source.x positions[offset + 1] = source.y positions[offset + 2] = source.z positions[offset + 3] = target.x positions[offset + 4] = target.y positions[offset + 5] = target.z - writeColor(colors, offset, source.color, source.role === 'pupil' ? 0.08 : 0.48) - writeColor(colors, offset + 3, target.color, target.role === 'pupil' ? 0.08 : 0.48) + writeColor(colors, offset, source.color, multiplier) + writeColor(colors, offset + 3, target.color, multiplier) } const geometry = new THREE.BufferGeometry() @@ -202,16 +261,63 @@ const findNearestNode = ( return nearest } -const isCombustionRole = (role: GraphNodeRole): boolean => - role === 'flame' || role === 'rim' || role === 'arm' || role === 'spark' || role === 'ember' +const isCombustionNode = (node: GraphNodeSeed): boolean => + node.role === 'flame' || + node.role === 'rim' || + node.role === 'arm' || + node.role === 'spark' || + node.role === 'ember' + +const getAnimatedEdgeMultiplier = ( + source: GraphNodeSeed, + target: GraphNodeSeed, + elapsed: number, + dragHeat: number, +): number => { + const pupilEdge = source.role === 'pupil' || target.role === 'pupil' + const irisSpoke = + pupilEdge && (source.feature === 'eye-iris' || target.feature === 'eye-iris') + const cavityEdge = + source.feature === 'mouth-cavity' || target.feature === 'mouth-cavity' + const eyeEdge = + (source.role === 'eye' || source.role === 'pupil') && + (target.role === 'eye' || target.role === 'pupil') + const mouthEdge = source.role === 'mouth' && target.role === 'mouth' + const height = (source.y + target.y) * 0.5 + const travellingWave = Math.sin(elapsed * 2.85 - height * 5.4 + source.phase * 0.55) + const fastPulse = Math.sin(elapsed * 7.1 + source.phase + target.phase) + const idlePulse = Math.max(0, travellingWave) * 0.19 + Math.max(0, fastPulse) * 0.075 + + if (irisSpoke) { + return 0.2 + idlePulse * 0.32 + dragHeat * 0.42 + } + + if (pupilEdge) { + return 0.035 + dragHeat * 0.05 + } + + if (cavityEdge) { + return 0.08 + idlePulse * 0.08 + dragHeat * 0.24 + } + + if (eyeEdge) { + return 0.52 + idlePulse * 0.48 + dragHeat * 0.62 + } + + if (mouthEdge) { + return 0.4 + idlePulse * 0.42 + dragHeat * 0.7 + } + + return 0.41 + idlePulse + dragHeat * 0.8 +} export const mountCalciferGraph = ( host: HTMLElement, canvas: HTMLCanvasElement, ): (() => void) => { - const graph = createCalciferGraph({ nodeCount: getQualityNodeCount() }) + const graph = createPresentationGraph({ nodeCount: getQualityNodeCount() }) const state = createPhysicsState(graph) - const torchSurface = host.closest('.hero') ?? host + const torchSurface = host const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, @@ -247,7 +353,7 @@ export const mountCalciferGraph = ( const lineMaterial = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, - opacity: 0.36, + opacity: 0.37, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending, @@ -264,6 +370,7 @@ export const mountCalciferGraph = ( const positionAttribute = pointGeometry.getAttribute('position') as THREE.BufferAttribute const colorAttribute = pointGeometry.getAttribute('color') as THREE.BufferAttribute const heatAttribute = pointGeometry.getAttribute('aHeat') as THREE.BufferAttribute + const glowAttribute = pointGeometry.getAttribute('aGlow') as THREE.BufferAttribute const sizeAttribute = pointGeometry.getAttribute('aSize') as THREE.BufferAttribute const linePositionAttribute = lineGeometry.getAttribute('position') as THREE.BufferAttribute const lineColorAttribute = lineGeometry.getAttribute('color') as THREE.BufferAttribute @@ -311,20 +418,25 @@ export const mountCalciferGraph = ( const rect = torchSurface.getBoundingClientRect() const x = clamp(event.clientX - rect.left, 0, rect.width) const y = clamp(event.clientY - rect.top, 0, rect.height) - const radius = clamp(155 + speed * 285, 155, 390) - const warmth = clamp(0.48 + speed * 0.62, 0.48, 1) + const radius = clamp(145 + speed * 320, 145, 430) + const warmth = clamp(0.42 + speed * 0.72, 0.42, 1) + const core = clamp(0.48 + speed * 0.86, 0.48, 1) torchSurface.style.setProperty('--torch-x', `${x}px`) torchSurface.style.setProperty('--torch-y', `${y}px`) torchSurface.style.setProperty('--torch-radius', `${radius}px`) torchSurface.style.setProperty('--torch-intensity', intensity.toFixed(3)) torchSurface.style.setProperty('--torch-warmth', warmth.toFixed(3)) + torchSurface.style.setProperty('--torch-core', core.toFixed(3)) } const updateGeometry = (elapsed: number): void => { const heatArray = heatAttribute.array as Float32Array + const glowArray = glowAttribute.array as Float32Array const sizeArray = sizeAttribute.array as Float32Array const colorArray = colorAttribute.array as Float32Array + const gazeX = Math.sin(elapsed * 0.43) * 0.006 + const gazeY = Math.cos(elapsed * 0.36) * 0.004 for (let index = 0; index < graph.nodes.length; index += 1) { const seed = graph.nodes[index] @@ -336,44 +448,113 @@ export const mountCalciferGraph = ( const offset = index * 3 const influence = dragInfluence?.[index] ?? 0 - const slowFlicker = Math.sin(elapsed * 1.75 + seed.phase) - const mediumFlicker = Math.sin(elapsed * 4.25 + seed.phase * 1.61) - const quickFlicker = Math.sin(elapsed * 8.4 + seed.phase * 2.17) - const thermalWave = Math.sin(elapsed * 2.05 - seed.y * 5.8 + seed.phase * 0.44) + const slowFlicker = Math.sin(elapsed * 1.62 + seed.phase) + const mediumFlicker = Math.sin(elapsed * 4.4 + seed.phase * 1.61) + const quickFlicker = Math.sin(elapsed * 8.9 + seed.phase * 2.17) + const thermalWave = Math.sin(elapsed * 2.35 - seed.y * 6.4 + seed.phase * 0.42) + const secondThermalWave = Math.sin(elapsed * 3.75 - seed.y * 9.2 + seed.x * 2.8) const upwardBias = clamp((seed.y + 1.08) / 2.58, 0, 1) - const combustion = isCombustionRole(seed.role) + const combustion = isCombustionNode(seed) const detached = seed.role === 'spark' || seed.role === 'ember' - const renderDriftX = detached - ? (slowFlicker * 0.025 + mediumFlicker * 0.009) * (seed.role === 'ember' ? 1.35 : 1) - : combustion - ? (mediumFlicker * 0.006 + quickFlicker * 0.0028) * (0.55 + upwardBias) - : 0 - const renderDriftY = detached - ? (Math.max(0, thermalWave) * 0.034 + slowFlicker * 0.014) * - (seed.role === 'ember' ? 1.4 : 1) - : combustion - ? (Math.max(0, thermalWave) * 0.012 + mediumFlicker * 0.0038) * - (0.5 + upwardBias) - : 0 + const pupil = seed.role === 'pupil' || seed.feature === 'eye-pupil' + const sclera = + seed.feature === 'eye-sclera' || + seed.feature === 'eye-iris' || + seed.feature === 'eye-highlight' || + seed.feature === 'eye-outer-rim' + const eyeHighlight = seed.feature === 'eye-highlight' + const tongue = seed.feature === 'mouth-tongue' || seed.feature === 'mouth-highlight' + const mouthRim = + seed.feature === 'mouth-outer-rim' || seed.feature === 'mouth-inner-rim' + const cavity = seed.feature === 'mouth-cavity' + const eyeTwinkle = sclera + ? Math.max(0, Math.sin(elapsed * (5.2 + (seed.phase % 1.4)) + seed.phase * 2.4)) + : 0 + const tonguePulse = tongue + ? Math.max(0, Math.sin(elapsed * 3.8 - seed.x * 7.5 + seed.phase * 0.8)) + : 0 + const detachedLift = detached + ? (1 - Math.cos(elapsed * (0.55 + (seed.phase % 0.37)) + seed.phase)) * 0.024 + : 0 + const renderDriftX = pupil + ? gazeX + : detached + ? (slowFlicker * 0.027 + mediumFlicker * 0.01) * + (seed.role === 'ember' ? 1.35 : 1) + : combustion + ? (mediumFlicker * 0.0064 + quickFlicker * 0.003) * (0.55 + upwardBias) + : tongue + ? mediumFlicker * 0.0018 + : 0 + const renderDriftY = pupil + ? gazeY + : detached + ? (Math.max(0, thermalWave) * 0.036 + slowFlicker * 0.014 + detachedLift) * + (seed.role === 'ember' ? 1.38 : 1) + : combustion + ? (Math.max(0, thermalWave) * 0.013 + + Math.max(0, secondThermalWave) * 0.006 + + mediumFlicker * 0.0035) * + (0.48 + upwardBias) + : tongue + ? tonguePulse * 0.0045 + : 0 const renderX = node.x + renderDriftX const renderY = node.y + renderDriftY const renderZ = node.z - const idleHeat = detached - ? 0.28 + (slowFlicker + 1) * 0.16 + Math.max(0, quickFlicker) * 0.18 - : combustion - ? 0.12 + Math.max(0, thermalWave) * 0.24 + Math.max(0, quickFlicker) * 0.12 - : seed.role === 'eye' - ? 0.3 + Math.max(0, slowFlicker) * 0.1 - : seed.role === 'mouth' - ? 0.16 + Math.max(0, mediumFlicker) * 0.12 - : 0 - const activeHeat = seed.role === 'pupil' ? 0 : clamp(idleHeat + influence * 0.96, 0, 1) - const brightness = seed.role === 'pupil' ? 1 : 1 + activeHeat * 0.28 - const pulseStrength = detached ? 0.17 : seed.role === 'rim' ? 0.085 : combustion ? 0.06 : 0.018 - const scalePulse = - seed.role === 'pupil' - ? 1 - : 1 + slowFlicker * pulseStrength + quickFlicker * pulseStrength * 0.35 + influence * 0.22 + const combustionHeat = combustion + ? 0.1 + + Math.max(0, thermalWave) * 0.27 + + Math.max(0, secondThermalWave) * 0.16 + + Math.max(0, quickFlicker) * 0.1 + : 0 + const detachedHeat = detached + ? 0.28 + (slowFlicker + 1) * 0.15 + Math.max(0, quickFlicker) * 0.2 + : 0 + const eyeHeat = sclera + ? 0.23 + eyeTwinkle * (eyeHighlight ? 0.34 : 0.17) + : 0 + const mouthHeat = cavity + ? 0.015 + : tongue + ? 0.14 + tonguePulse * 0.24 + Math.max(0, quickFlicker) * 0.08 + : mouthRim + ? 0.12 + Math.max(0, mediumFlicker) * 0.13 + : 0 + const idleHeat = Math.max(combustionHeat, detachedHeat, eyeHeat, mouthHeat) + const activeHeat = pupil ? 0 : clamp(idleHeat + influence * 0.96, 0, 1) + const brightness = pupil + ? 1 + : cavity + ? 0.88 + : 1 + activeHeat * (eyeHighlight ? 0.36 : sclera ? 0.3 : 0.28) + const pulseStrength = detached + ? 0.18 + : seed.role === 'rim' + ? 0.09 + : combustion + ? 0.065 + : sclera + ? eyeHighlight + ? 0.1 + : 0.045 + : tongue + ? 0.07 + : mouthRim + ? 0.025 + : 0.012 + const scalePulse = pupil + ? 1 + : 1 + + slowFlicker * pulseStrength + + quickFlicker * pulseStrength * 0.34 + + eyeTwinkle * (eyeHighlight ? 0.12 : 0.035) + + tonguePulse * (tongue ? 0.055 : 0) + + influence * 0.22 + const baseGlow = getNodeGlow(seed) + const glowPulse = pupil || cavity + ? baseGlow + : baseGlow * (0.82 + activeHeat * 0.34 + eyeTwinkle * 0.14 + tonguePulse * 0.08) renderPositions[offset] = renderX renderPositions[offset + 1] = renderY @@ -381,7 +562,8 @@ export const mountCalciferGraph = ( positionAttribute.setXYZ(index, renderX, renderY, renderZ) writeColor(colorArray, offset, seed.color, brightness) heatArray[index] = activeHeat - sizeArray[index] = seed.size * Math.max(0.7, scalePulse) + glowArray[index] = glowPulse + sizeArray[index] = seed.size * Math.max(0.68, scalePulse) } for (let index = 0; index < graph.edges.length; index += 1) { @@ -401,12 +583,11 @@ export const mountCalciferGraph = ( const sourceOffset = edge.source * 3 const targetOffset = edge.target * 3 const lineOffset = index * 6 - const edgeHeat = Math.max(dragInfluence?.[edge.source] ?? 0, dragInfluence?.[edge.target] ?? 0) - const wave = Math.sin(elapsed * 2.45 - (sourceSeed.y + targetSeed.y) * 3.2 + sourceSeed.phase) - const quick = Math.sin(elapsed * 6.2 + sourceSeed.phase + targetSeed.phase) - const idlePulse = Math.max(0, wave) * 0.17 + Math.max(0, quick) * 0.08 - const pupilEdge = sourceSeed.role === 'pupil' || targetSeed.role === 'pupil' - const multiplier = pupilEdge ? 0.07 : 0.42 + idlePulse + edgeHeat * 0.78 + const dragHeat = Math.max( + dragInfluence?.[edge.source] ?? 0, + dragInfluence?.[edge.target] ?? 0, + ) + const multiplier = getAnimatedEdgeMultiplier(sourceSeed, targetSeed, elapsed, dragHeat) linePositionAttribute.setXYZ( index * 2, @@ -432,6 +613,7 @@ export const mountCalciferGraph = ( positionAttribute.needsUpdate = true colorAttribute.needsUpdate = true heatAttribute.needsUpdate = true + glowAttribute.needsUpdate = true sizeAttribute.needsUpdate = true linePositionAttribute.needsUpdate = true lineColorAttribute.needsUpdate = true @@ -478,7 +660,7 @@ export const mountCalciferGraph = ( canvas.setPointerCapture(event.pointerId) canvas.style.cursor = 'grabbing' host.dataset.dragging = 'true' - setTorch(event, 0.82) + setTorch(event, 0.84) } const onPointerMove = (event: PointerEvent): void => { @@ -498,7 +680,7 @@ export const mountCalciferGraph = ( event.clientY - (previous?.y ?? event.clientY), ) const speed = distance / elapsed - const intensity = clamp(0.68 + speed * 0.78, 0.68, 1) + const intensity = clamp(0.7 + speed * 0.82, 0.7, 1) pointerSample = { x: event.clientX, y: event.clientY, time: now } setTorch(event, intensity, speed) @@ -520,6 +702,7 @@ export const mountCalciferGraph = ( host.dataset.dragging = 'false' torchSurface.style.setProperty('--torch-intensity', '0') torchSurface.style.setProperty('--torch-warmth', '0') + torchSurface.style.setProperty('--torch-core', '0') } const onVisibilityChange = (): void => { diff --git a/src/scripts/graph/types.ts b/src/scripts/graph/types.ts index 78c2fb3..6a75a5b 100644 --- a/src/scripts/graph/types.ts +++ b/src/scripts/graph/types.ts @@ -8,11 +8,24 @@ export type GraphNodeRole = | 'spark' | 'ember' +export type GraphNodeFeature = + | 'eye-outer-rim' + | 'eye-sclera' + | 'eye-iris' + | 'eye-highlight' + | 'eye-pupil' + | 'mouth-outer-rim' + | 'mouth-inner-rim' + | 'mouth-cavity' + | 'mouth-tongue' + | 'mouth-highlight' + export type Rgb = readonly [number, number, number] export interface GraphNodeSeed { id: number role: GraphNodeRole + feature?: GraphNodeFeature x: number y: number z: number diff --git a/tests/generateGraph.test.ts b/tests/generateGraph.test.ts index 99e4be1..9b04744 100644 --- a/tests/generateGraph.test.ts +++ b/tests/generateGraph.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { createCalciferGraph } from '../src/scripts/graph/generateGraph' +import { createPresentationGraph } from '../src/scripts/graph/createPresentationGraph' const expectedRoles = new Set([ 'flame', @@ -13,63 +13,104 @@ const expectedRoles = new Set([ 'ember', ]) +const countBy = (values: T[]): Map => { + const counts = new Map() + + for (const value of values) { + counts.set(value, (counts.get(value) ?? 0) + 1) + } + + return counts +} + test('graph generation is deterministic for a fixed seed', () => { - const first = createCalciferGraph({ nodeCount: 180, seed: 42 }) - const second = createCalciferGraph({ nodeCount: 180, seed: 42 }) + const first = createPresentationGraph({ nodeCount: 180, seed: 42 }) + const second = createPresentationGraph({ nodeCount: 180, seed: 42 }) assert.deepEqual(first.nodes, second.nodes) assert.deepEqual(first.edges, second.edges) }) -test('graph contains the reference-matched face, contour, arms and detached fire', () => { - const graph = createCalciferGraph({ nodeCount: 180, seed: 7 }) +test('graph contains the reference-matched body, face and detached fire systems', () => { + const graph = createPresentationGraph({ nodeCount: 180, seed: 7 }) const roles = new Set(graph.nodes.map((node) => node.role)) const degree = new Uint16Array(graph.nodes.length) - const roleCounts = new Map() - - for (const node of graph.nodes) { - roleCounts.set(node.role, (roleCounts.get(node.role) ?? 0) + 1) - } + const roleCounts = countBy(graph.nodes.map((node) => node.role)) for (const edge of graph.edges) { degree[edge.source] = (degree[edge.source] ?? 0) + 1 degree[edge.target] = (degree[edge.target] ?? 0) + 1 } - assert.ok(graph.nodes.length >= 850) + assert.ok(graph.nodes.length >= 1100) assert.deepEqual(roles, expectedRoles) - assert.ok((roleCounts.get('flame') ?? 0) >= 180) - assert.ok((roleCounts.get('rim') ?? 0) >= 80) - assert.ok((roleCounts.get('arm') ?? 0) >= 140) - assert.ok((roleCounts.get('eye') ?? 0) >= 220) - assert.ok((roleCounts.get('pupil') ?? 0) >= 36) - assert.ok((roleCounts.get('mouth') ?? 0) >= 170) - assert.ok((roleCounts.get('spark') ?? 0) >= 150) - assert.ok((roleCounts.get('ember') ?? 0) >= 68) + assert.ok((roleCounts.get('flame') ?? 0) >= 140) + assert.ok((roleCounts.get('rim') ?? 0) >= 60) + assert.ok((roleCounts.get('arm') ?? 0) >= 100) + assert.ok((roleCounts.get('eye') ?? 0) >= 340) + assert.ok((roleCounts.get('pupil') ?? 0) >= 56) + assert.ok((roleCounts.get('mouth') ?? 0) >= 220) + assert.ok((roleCounts.get('spark') ?? 0) >= 130) + assert.ok((roleCounts.get('ember') ?? 0) >= 48) assert.ok(Array.from(degree).every((value) => value > 0)) assert.ok(graph.nodes.some((node) => node.size >= 12)) - assert.ok(graph.nodes.some((node) => node.size <= 2)) + assert.ok(graph.nodes.some((node) => node.size <= 1.5)) assert.ok(graph.nodes.every((node) => node.x >= -2.2 && node.x <= 2.2)) assert.ok(graph.nodes.every((node) => node.y >= -1.25 && node.y <= 1.9)) }) -test('facial landmarks remain large, bright and spatially separated', () => { - const graph = createCalciferGraph({ nodeCount: 220, seed: 2026 }) - const leftEye = graph.nodes.filter((node) => node.role === 'eye' && node.x < 0) - const rightEye = graph.nodes.filter((node) => node.role === 'eye' && node.x > 0) - const pupils = graph.nodes.filter((node) => node.role === 'pupil') - const mouth = graph.nodes.filter((node) => node.role === 'mouth') +test('eyes use dense annular sclera, bright iris spokes and compact black pupils', () => { + const graph = createPresentationGraph({ nodeCount: 220, seed: 2026 }) + const featureCounts = countBy( + graph.nodes.flatMap((node) => (node.feature === undefined ? [] : [node.feature])), + ) + const leftEye = graph.nodes.filter( + (node) => (node.role === 'eye' || node.role === 'pupil') && node.x < 0, + ) + const rightEye = graph.nodes.filter( + (node) => (node.role === 'eye' || node.role === 'pupil') && node.x > 0, + ) + const pupils = graph.nodes.filter((node) => node.feature === 'eye-pupil') + const highlights = graph.nodes.filter((node) => node.feature === 'eye-highlight') + + assert.ok((featureCounts.get('eye-outer-rim') ?? 0) >= 70) + assert.ok((featureCounts.get('eye-sclera') ?? 0) >= 200) + assert.ok((featureCounts.get('eye-iris') ?? 0) >= 44) + assert.ok((featureCounts.get('eye-highlight') ?? 0) >= 18) + assert.ok((featureCounts.get('eye-pupil') ?? 0) >= 56) + assert.ok(leftEye.length > 180) + assert.ok(rightEye.length > 180) + assert.ok(leftEye.every((node) => node.y > -0.5 && node.y < -0.02)) + assert.ok(rightEye.every((node) => node.y > -0.5 && node.y < -0.02)) + assert.ok(pupils.every((node) => node.color[0] < 0.01 && node.color[1] < 0.01)) + assert.ok(highlights.every((node) => node.color[0] > 0.98 && node.color[1] > 0.98)) +}) + +test('mouth keeps a dark open cavity with a luminous double rim and lower tongue', () => { + const graph = createPresentationGraph({ nodeCount: 220, seed: 2026 }) + const featureCounts = countBy( + graph.nodes.flatMap((node) => (node.feature === undefined ? [] : [node.feature])), + ) + const cavity = graph.nodes.filter((node) => node.feature === 'mouth-cavity') + const tongue = graph.nodes.filter((node) => node.feature === 'mouth-tongue') + const highlights = graph.nodes.filter((node) => node.feature === 'mouth-highlight') + const mouthNodes = graph.nodes.filter((node) => node.role === 'mouth') - assert.ok(leftEye.length > 100) - assert.ok(rightEye.length > 100) - assert.ok(leftEye.every((node) => node.y > -0.48 && node.y < -0.02)) - assert.ok(rightEye.every((node) => node.y > -0.48 && node.y < -0.02)) - assert.ok(pupils.every((node) => node.color[0] < 0.02 && node.color[1] < 0.02)) - assert.ok(mouth.some((node) => node.y < -0.78)) + assert.ok((featureCounts.get('mouth-outer-rim') ?? 0) >= 70) + assert.ok((featureCounts.get('mouth-inner-rim') ?? 0) >= 60) + assert.ok((featureCounts.get('mouth-cavity') ?? 0) >= 6) + assert.ok((featureCounts.get('mouth-tongue') ?? 0) >= 45) + assert.ok((featureCounts.get('mouth-highlight') ?? 0) >= 42) + assert.ok(cavity.every((node) => node.color[0] < 0.32 && node.color[1] < 0.02)) + assert.ok(tongue.every((node) => node.y < -0.65 && node.y > -0.84)) + assert.ok(highlights.some((node) => node.color[0] > 0.98 && node.color[1] > 0.45)) + assert.ok(mouthNodes.some((node) => node.x < -0.4)) + assert.ok(mouthNodes.some((node) => node.x > 0.4)) + assert.ok(mouthNodes.some((node) => node.y < -0.84)) }) test('all edges reference valid distinct nodes', () => { - const graph = createCalciferGraph({ nodeCount: 220, seed: 2026 }) + const graph = createPresentationGraph({ nodeCount: 220, seed: 2026 }) for (const edge of graph.edges) { assert.notEqual(edge.source, edge.target)