diff --git a/src/aura/AuraCanvas.tsx b/src/aura/AuraCanvas.tsx index 4fb989b..f3a9bbc 100644 --- a/src/aura/AuraCanvas.tsx +++ b/src/aura/AuraCanvas.tsx @@ -60,6 +60,7 @@ export function AuraCanvas({ aria-label={`${name} 3D 오라 장면`} data-seed={plan.seed} data-variation={plan.variation} + data-family={plan.flower.family} /> ) } diff --git a/src/aura/renderer/AuraRenderer.ts b/src/aura/renderer/AuraRenderer.ts index 95e5505..f9ef6c4 100644 --- a/src/aura/renderer/AuraRenderer.ts +++ b/src/aura/renderer/AuraRenderer.ts @@ -1,9 +1,9 @@ import * as THREE from 'three' import { createSeededRandom, hashText } from '../scenePlan' import type { AuraPalette, AuraScenePlan, RenderQuality } from '../types' +import { createAuraForm, type AuraColors, type AuraFormPart } from './auraForms' -type Palette = { background: number; primary: number; secondary: number; core: number } -const PALETTES: Record = { +const PALETTES: Record = { dawn: { background: 0x100d0b, primary: 0xffb47a, secondary: 0xf9dec8, core: 0xfff3d0 }, ocean: { background: 0x071012, primary: 0x5dbec4, secondary: 0x8dafa7, core: 0xd9fff4 }, ember: { background: 0x110a08, primary: 0xe56d3b, secondary: 0xf0aa55, core: 0xffe0a0 }, @@ -23,7 +23,7 @@ export class AuraRenderer { private readonly camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100) private readonly renderer: THREE.WebGLRenderer private readonly flower = new THREE.Group() - private readonly petals: Array<{ mesh: THREE.Mesh; baseScale: THREE.Vector3 }> = [] + private readonly formParts: AuraFormPart[] private readonly particles: THREE.Points private readonly resizeObserver: ResizeObserver private animationFrame: number | null = null @@ -55,7 +55,7 @@ export class AuraRenderer { this.camera.position.set(0, 0.25, 6.3) this.scene.add(this.flower) this.addLighting(palette) - this.addFlower(palette, settings.segments) + this.formParts = createAuraForm(this.flower, plan, palette, settings.segments) this.particles = this.addParticles(palette, settings.particles) this.addAtmosphere(palette) this.resizeObserver = new ResizeObserver(() => this.resize()) @@ -67,7 +67,7 @@ export class AuraRenderer { this.start() } - private addLighting(palette: Palette): void { + private addLighting(palette: AuraColors): void { this.scene.add(new THREE.AmbientLight(palette.secondary, 1.4)) const key = new THREE.DirectionalLight(palette.core, 4.2) key.position.set(2.5, 3.5, 4) @@ -77,50 +77,7 @@ export class AuraRenderer { this.scene.add(rim) } - private createPetalGeometry(segments: number): THREE.BufferGeometry { - if (this.plan.flower.family === 'crystal') return new THREE.ConeGeometry(0.42, 1.8, 5, 1) - if (this.plan.flower.family === 'ribbon') return new THREE.SphereGeometry(0.6, segments, 6) - return new THREE.SphereGeometry(0.64, segments, Math.max(8, Math.floor(segments / 2))) - } - - private addFlower(palette: Palette, segments: number): void { - const random = createSeededRandom(hashText(`${this.plan.seed}:${this.plan.variation}:flower`)) - const geometry = this.createPetalGeometry(segments) - const material = new THREE.MeshPhysicalMaterial({ - color: palette.primary, - emissive: palette.secondary, - emissiveIntensity: 0.14 + this.plan.atmosphere.bloomStrength * 0.4, - roughness: 0.35 + this.plan.flower.sharpness * 0.22, - metalness: this.plan.flower.family === 'crystal' ? 0.62 : 0.12, - transparent: true, - opacity: 0.82, - side: THREE.DoubleSide, - }) - for (let index = 0; index < this.plan.flower.petalCount; index += 1) { - const angle = (index / this.plan.flower.petalCount) * Math.PI * 2 - const mesh = new THREE.Mesh(geometry, material) - const length = this.plan.flower.radius * (0.78 + random() * 0.2) - const width = 0.34 + (1 - this.plan.flower.sharpness) * 0.25 - const thickness = this.plan.flower.family === 'ribbon' ? 0.08 : 0.2 - mesh.scale.set(width, length, thickness) - mesh.position.set(Math.cos(angle) * 0.62, Math.sin(angle) * 0.62, (random() - 0.5) * 0.34) - mesh.rotation.set(0.3 + this.plan.flower.openness * 0.55, 0, angle - Math.PI / 2) - this.flower.add(mesh) - this.petals.push({ mesh, baseScale: mesh.scale.clone() }) - } - const core = new THREE.Mesh( - new THREE.IcosahedronGeometry(0.55, 2), - new THREE.MeshPhysicalMaterial({ - color: palette.core, - emissive: palette.primary, - emissiveIntensity: 1.4, - roughness: 0.22, - }), - ) - this.flower.add(core) - } - - private addParticles(palette: Palette, maximum: number): THREE.Points { + private addParticles(palette: AuraColors, maximum: number): THREE.Points { const count = Math.round(maximum * this.plan.atmosphere.particleDensity) const positions = new Float32Array(count * 3) const random = createSeededRandom( @@ -150,7 +107,7 @@ export class AuraRenderer { return points } - private addAtmosphere(palette: Palette): void { + private addAtmosphere(palette: AuraColors): void { const halo = new THREE.Mesh( new THREE.SphereGeometry(1.35, 24, 16), new THREE.MeshBasicMaterial({ @@ -192,17 +149,50 @@ export class AuraRenderer { const pulse = 1 + Math.sin(elapsed * (0.7 + this.plan.motion.speed * 2)) * this.plan.motion.pulse * 0.12 this.flower.scale.setScalar(pulse) - this.flower.rotation.z = elapsed * this.plan.motion.rotation - this.flower.rotation.x = Math.sin(elapsed * 0.3) * this.plan.motion.turbulence * 0.16 - this.petals.forEach(({ mesh, baseScale }, index) => { - const drift = 1 + Math.sin(elapsed * this.plan.motion.speed + index * 0.7) * 0.035 - mesh.scale.copy(baseScale).multiplyScalar(drift) - }) + this.animateGroup(elapsed) + this.formParts.forEach((part, index) => this.animatePart(part, elapsed, index)) this.particles.rotation.z = -elapsed * this.plan.motion.speed * 0.06 } this.renderer.render(this.scene, this.camera) } + private animateGroup(elapsed: number): void { + const family = this.plan.flower.family + if (family === 'lotus') this.flower.rotation.z = elapsed * this.plan.motion.rotation + if (family === 'crystal') this.flower.rotation.z = Math.sin(elapsed * 0.24) * 0.035 + if (family === 'nebula') + this.flower.rotation.z = elapsed * (0.04 + this.plan.motion.speed * 0.08) + if (family === 'ribbon') this.flower.rotation.z = Math.sin(elapsed * 0.2) * 0.09 + this.flower.rotation.x = + family === 'ribbon' + ? Math.sin(elapsed * 0.32) * 0.12 + : Math.sin(elapsed * 0.3) * this.plan.motion.turbulence * 0.16 + } + + private animatePart(part: AuraFormPart, elapsed: number, index: number): void { + const wave = Math.sin(elapsed * this.plan.motion.speed + part.phase + index * 0.13) + part.mesh.scale.copy(part.baseScale) + part.mesh.position.copy(part.basePosition) + part.mesh.rotation.copy(part.baseRotation) + if (part.motion === 'breathe') part.mesh.scale.multiplyScalar(1 + wave * 0.035) + if (part.motion === 'shard') { + part.mesh.scale.y *= 1 + wave * 0.025 + part.mesh.rotation.z += wave * 0.018 + } + if (part.motion === 'orbit') { + const angle = elapsed * (0.03 + this.plan.motion.speed * 0.035) + part.mesh.position.x = + part.basePosition.x * Math.cos(angle) - part.basePosition.y * Math.sin(angle) + part.mesh.position.y = + part.basePosition.x * Math.sin(angle) + part.basePosition.y * Math.cos(angle) + part.mesh.scale.multiplyScalar(1 + wave * 0.05) + } + if (part.motion === 'wave') { + part.mesh.position.y += wave * 0.055 + part.mesh.rotation.x += wave * 0.035 + } + } + private readonly tick = (time: number): void => { this.animationFrame = null if (!this.visible) return diff --git a/src/aura/renderer/auraForms.ts b/src/aura/renderer/auraForms.ts new file mode 100644 index 0000000..f55f8c8 --- /dev/null +++ b/src/aura/renderer/auraForms.ts @@ -0,0 +1,246 @@ +import * as THREE from 'three' +import { createSeededRandom, hashText } from '../scenePlan' +import type { AuraScenePlan } from '../types' +import { createRibbonGeometry } from './ribbonGeometry' + +export type AuraColors = { + background: number + primary: number + secondary: number + core: number +} + +type PartMotion = 'breathe' | 'shard' | 'orbit' | 'wave' | 'still' + +export type AuraFormPart = { + mesh: THREE.Mesh + baseScale: THREE.Vector3 + basePosition: THREE.Vector3 + baseRotation: THREE.Euler + phase: number + motion: PartMotion +} + +function addPart( + group: THREE.Group, + parts: AuraFormPart[], + mesh: THREE.Mesh, + phase: number, + motion: PartMotion, +): void { + group.add(mesh) + parts.push({ + mesh, + baseScale: mesh.scale.clone(), + basePosition: mesh.position.clone(), + baseRotation: mesh.rotation.clone(), + phase, + motion, + }) +} + +function physicalMaterial( + colors: AuraColors, + plan: AuraScenePlan, + overrides: THREE.MeshPhysicalMaterialParameters = {}, +): THREE.MeshPhysicalMaterial { + return new THREE.MeshPhysicalMaterial({ + color: colors.primary, + emissive: colors.secondary, + emissiveIntensity: 0.14 + plan.atmosphere.bloomStrength * 0.4, + roughness: 0.35 + plan.flower.sharpness * 0.22, + metalness: 0.12, + transparent: true, + opacity: 0.82, + side: THREE.DoubleSide, + ...overrides, + }) +} + +function addLotus( + group: THREE.Group, + parts: AuraFormPart[], + plan: AuraScenePlan, + colors: AuraColors, + segments: number, + random: () => number, +): void { + const geometry = new THREE.SphereGeometry(0.64, segments, Math.max(8, segments / 2)) + const layers = [ + { count: plan.flower.petalCount, radius: 0.7, scale: 1, z: -0.08 }, + { + count: Math.max(5, Math.round(plan.flower.petalCount * 0.62)), + radius: 0.38, + scale: 0.64, + z: 0.22, + }, + ] + for (const [layerIndex, layer] of layers.entries()) { + const material = physicalMaterial(colors, plan, { + color: layerIndex === 0 ? colors.primary : colors.secondary, + opacity: layerIndex === 0 ? 0.78 : 0.7, + }) + for (let index = 0; index < layer.count; index += 1) { + const angle = (index / layer.count) * Math.PI * 2 + layerIndex * 0.28 + const mesh = new THREE.Mesh(geometry, material) + const length = plan.flower.radius * layer.scale * (0.82 + random() * 0.16) + const width = (0.33 + (1 - plan.flower.sharpness) * 0.24) * layer.scale + mesh.scale.set(width, length, 0.18 * layer.scale) + mesh.position.set(Math.cos(angle) * layer.radius, Math.sin(angle) * layer.radius, layer.z) + mesh.rotation.set(0.28 + plan.flower.openness * 0.6, 0, angle - Math.PI / 2) + addPart(group, parts, mesh, angle, 'breathe') + } + } + const core = new THREE.Mesh( + new THREE.IcosahedronGeometry(0.48, 2), + physicalMaterial(colors, plan, { color: colors.core, emissive: colors.primary, opacity: 1 }), + ) + addPart(group, parts, core, 0, 'breathe') +} + +function addCrystal( + group: THREE.Group, + parts: AuraFormPart[], + plan: AuraScenePlan, + colors: AuraColors, + random: () => number, +): void { + const count = Math.max(6, Math.round(plan.flower.petalCount * 0.72)) + const geometry = new THREE.ConeGeometry(0.48, 1.8, 5, 1) + const material = physicalMaterial(colors, plan, { + metalness: 0.7, + roughness: 0.2, + opacity: 0.9, + }) + for (let index = 0; index < count; index += 1) { + const spread = count === 1 ? 0 : index / (count - 1) - 0.5 + const mesh = new THREE.Mesh(geometry, material) + const height = 0.65 + random() * 0.65 + (1 - Math.abs(spread)) * 0.45 + const width = 0.34 + random() * 0.22 + mesh.scale.set(width, height, width) + mesh.position.set( + spread * 2.8 + (random() - 0.5) * 0.24, + -0.55 + height * 0.32, + (random() - 0.5) * 0.5, + ) + mesh.rotation.set((random() - 0.5) * 0.18, (random() - 0.5) * 0.35, -spread * 0.62) + addPart(group, parts, mesh, index * 0.7, 'shard') + } + const base = new THREE.Mesh( + new THREE.DodecahedronGeometry(0.72, 0), + physicalMaterial(colors, plan, { color: colors.secondary, metalness: 0.78, opacity: 0.95 }), + ) + base.position.y = -0.68 + base.scale.set(1.45, 0.48, 0.76) + addPart(group, parts, base, 0, 'still') +} + +function addNebula( + group: THREE.Group, + parts: AuraFormPart[], + plan: AuraScenePlan, + colors: AuraColors, + segments: number, + random: () => number, +): void { + const nodesPerArm = Math.max(9, Math.round(plan.flower.petalCount * 0.78)) + const geometry = new THREE.IcosahedronGeometry(0.25, 1) + const material = physicalMaterial(colors, plan, { + roughness: 0.18, + opacity: 0.48, + depthWrite: false, + blending: THREE.AdditiveBlending, + }) + for (let arm = 0; arm < 2; arm += 1) { + for (let index = 0; index < nodesPerArm; index += 1) { + const progress = index / Math.max(1, nodesPerArm - 1) + const angle = arm * Math.PI + progress * Math.PI * 3.4 + const radius = 0.2 + progress * plan.flower.radius * 0.95 + const mesh = new THREE.Mesh(geometry, material) + const scale = 0.42 + (1 - progress) * 0.74 + random() * 0.26 + mesh.scale.setScalar(scale) + mesh.position.set( + Math.cos(angle) * radius, + Math.sin(angle) * radius * 0.58, + (random() - 0.5) * 0.72, + ) + addPart(group, parts, mesh, angle, 'orbit') + } + } + const ringMaterial = new THREE.MeshBasicMaterial({ + color: colors.secondary, + transparent: true, + opacity: 0.22, + blending: THREE.AdditiveBlending, + depthWrite: false, + }) + for (let index = 0; index < 2; index += 1) { + const ring = new THREE.Mesh( + new THREE.TorusGeometry(1 + index * 0.3, 0.025, 6, segments * 2), + ringMaterial, + ) + ring.rotation.set(0.95 + index * 0.28, 0.22 - index * 0.4, index * 0.6) + ring.scale.y = 0.62 + addPart(group, parts, ring, index * Math.PI, 'orbit') + } + const core = new THREE.Mesh( + new THREE.SphereGeometry(0.34, segments, Math.max(8, segments / 2)), + physicalMaterial(colors, plan, { color: colors.core, emissive: colors.primary, opacity: 1 }), + ) + addPart(group, parts, core, 0, 'breathe') +} + +function addRibbon( + group: THREE.Group, + parts: AuraFormPart[], + plan: AuraScenePlan, + colors: AuraColors, + segments: number, +): void { + for (let band = 0; band < 3; band += 1) { + const phase = (band * (Math.PI * 2)) / 3 + const points = Array.from({ length: 7 }, (_, index) => { + const progress = index / 6 + return new THREE.Vector3( + (progress - 0.5) * plan.flower.radius * 2.2, + Math.sin(progress * Math.PI * 2 + phase) * (0.48 + band * 0.07) + (band - 1) * 0.16, + Math.cos(progress * Math.PI * 1.5 + phase) * 0.34, + ) + }) + const curve = new THREE.CatmullRomCurve3(points) + const geometry = createRibbonGeometry( + curve, + 0.13 + (1 - plan.flower.sharpness) * 0.08, + segments * 3, + ) + const material = physicalMaterial(colors, plan, { + color: band === 1 ? colors.secondary : colors.primary, + roughness: 0.26, + opacity: 0.68, + depthWrite: false, + }) + const mesh = new THREE.Mesh(geometry, material) + mesh.rotation.z = (band - 1) * 0.12 + addPart(group, parts, mesh, phase, 'wave') + } + const knot = new THREE.Mesh( + new THREE.TorusKnotGeometry(0.34, 0.075, Math.max(48, segments * 3), 8, 2, 3), + physicalMaterial(colors, plan, { color: colors.core, emissive: colors.primary, opacity: 0.9 }), + ) + addPart(group, parts, knot, 0, 'wave') +} + +export function createAuraForm( + group: THREE.Group, + plan: AuraScenePlan, + colors: AuraColors, + segments: number, +): AuraFormPart[] { + const parts: AuraFormPart[] = [] + const random = createSeededRandom(hashText(`${plan.seed}:${plan.variation}:form`)) + if (plan.flower.family === 'lotus') addLotus(group, parts, plan, colors, segments, random) + if (plan.flower.family === 'crystal') addCrystal(group, parts, plan, colors, random) + if (plan.flower.family === 'nebula') addNebula(group, parts, plan, colors, segments, random) + if (plan.flower.family === 'ribbon') addRibbon(group, parts, plan, colors, segments) + return parts +} diff --git a/src/aura/renderer/ribbonGeometry.ts b/src/aura/renderer/ribbonGeometry.ts new file mode 100644 index 0000000..91a2ade --- /dev/null +++ b/src/aura/renderer/ribbonGeometry.ts @@ -0,0 +1,35 @@ +import * as THREE from 'three' + +export function createRibbonGeometry( + curve: THREE.CatmullRomCurve3, + width: number, + segments: number, +): THREE.BufferGeometry { + const vertices: number[] = [] + const indices: number[] = [] + for (let index = 0; index <= segments; index += 1) { + const progress = index / segments + const point = curve.getPoint(progress) + const tangent = curve.getTangent(progress) + const side = new THREE.Vector3(-tangent.y, tangent.x, 0) + if (side.lengthSq() < 0.0001) side.set(1, 0, 0) + side.normalize().multiplyScalar(width) + vertices.push(point.x + side.x, point.y + side.y, point.z + side.z) + vertices.push(point.x - side.x, point.y - side.y, point.z - side.z) + if (index < segments) { + indices.push( + index * 2, + index * 2 + 1, + index * 2 + 2, + index * 2 + 1, + index * 2 + 3, + index * 2 + 2, + ) + } + } + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) + geometry.setIndex(indices) + geometry.computeVertexNormals() + return geometry +} diff --git a/src/aura/scenePlan.ts b/src/aura/scenePlan.ts index 3e83bd5..7a9bfd6 100644 --- a/src/aura/scenePlan.ts +++ b/src/aura/scenePlan.ts @@ -36,11 +36,11 @@ function unit(value: number): number { return (clamp(value, -1, 1) + 1) / 2 } -function chooseFamily(mood: MoodVector, random: () => number): AuraFamily { - if (mood.softness < -0.42) return 'crystal' - if (mood.openness > 0.46 && mood.direction > 0.08) return 'nebula' - if (mood.softness > 0.45 && mood.arousal > -0.2) return 'ribbon' - return random() > 0.84 ? 'nebula' : 'lotus' +function chooseFamily(mood: MoodVector): AuraFamily { + if (mood.softness <= -0.08) return 'crystal' + if (mood.openness + mood.direction >= 0.15) return 'nebula' + if (mood.softness >= 0.08 && mood.arousal >= -0.35) return 'ribbon' + return 'lotus' } function choosePalette(mood: MoodVector): AuraPalette { @@ -56,7 +56,7 @@ export function createAuraScenePlan(input: string, mood: MoodVector, variation = const seed = hashText(normalized) const variationSeed = hashText(`${normalized}:${Math.max(0, Math.trunc(variation))}`) const random = createSeededRandom(variationSeed) - const family = chooseFamily(mood, random) + const family = chooseFamily(mood) const palette = choosePalette(mood) const plan: AuraScenePlan = { seed, diff --git a/src/ui/Composer.tsx b/src/ui/Composer.tsx index 152559d..5fe2afc 100644 --- a/src/ui/Composer.tsx +++ b/src/ui/Composer.tsx @@ -1,10 +1,10 @@ import { normalizeInput } from '../aura/scenePlan' const EXAMPLES = [ - '비가 그친 새벽, 젖은 나뭇잎 사이로 빛이 번진다.', - '고요한 물결이 열린 수평선으로 은빛을 실어 나른다.', - '뜨거운 바람이 도시의 끝을 향해 빠르게 달린다.', - '작은 방 안에서 포근한 담요처럼 시간이 천천히 흐른다.', + { label: '연꽃', text: '고요한 호수에서 모든 빛이 중심으로 천천히 모인다.' }, + { label: '결정', text: '차갑고 날카로운 금속 조각이 단단한 긴장 속에서 맞선다.' }, + { label: '성운', text: '끝없는 하늘로 별빛의 파동이 사방으로 힘차게 펼쳐진다.' }, + { label: '유영', text: '부드러운 비단이 따뜻한 바람을 따라 유연하게 흐른다.' }, ] type ComposerProps = { @@ -40,14 +40,14 @@ export function Composer({ input, busy, unsupported, onInput, onGenerate }: Comp {input.length}/280자
- {EXAMPLES.map((example, index) => ( + {EXAMPLES.map((example) => ( ))}
diff --git a/tests/e2e/aura.spec.ts b/tests/e2e/aura.spec.ts index 27469b7..ecf8571 100644 --- a/tests/e2e/aura.spec.ts +++ b/tests/e2e/aura.spec.ts @@ -2,9 +2,10 @@ import { expect, test, type Page } from '@playwright/test' async function createAura(page: Page) { await page.goto('/') - await page.getByRole('button', { name: '예제 1' }).click() + await page.getByRole('button', { name: '연꽃' }).click() await page.getByRole('button', { name: '오라 피우기' }).click() await expect(page.locator('.aura-canvas')).toBeVisible() + await expect(page.locator('.aura-canvas')).toHaveAttribute('data-family', 'lotus') await expect(page.getByRole('heading', { level: 2 })).toBeVisible() } diff --git a/tests/unit/scenePlan.test.ts b/tests/unit/scenePlan.test.ts index 5c54011..f3cb3a9 100644 --- a/tests/unit/scenePlan.test.ts +++ b/tests/unit/scenePlan.test.ts @@ -17,6 +17,29 @@ const mood: MoodVector = { softness: 0.56, } +const CURATED_MODEL_CASES = [ + [ + 'lotus', + '고요한 호수에서 모든 빛이 중심으로 천천히 모인다.', + { valence: 0.0123, arousal: -0.0811, openness: 0.0865, direction: 0.0124, softness: 0.0302 }, + ], + [ + 'crystal', + '차갑고 날카로운 금속 조각이 단단한 긴장 속에서 맞선다.', + { valence: -0.043, arousal: -0.0071, openness: 0.0224, direction: 0.0163, softness: -0.1203 }, + ], + [ + 'nebula', + '끝없는 하늘로 별빛의 파동이 사방으로 힘차게 펼쳐진다.', + { valence: 0.0106, arousal: 0.0239, openness: 0.1213, direction: 0.0922, softness: 0.0123 }, + ], + [ + 'ribbon', + '부드러운 비단이 따뜻한 바람을 따라 유연하게 흐른다.', + { valence: 0.0097, arousal: 0.0065, openness: 0.0143, direction: 0.0219, softness: 0.1143 }, + ], +] satisfies Array<[AuraScenePlan['flower']['family'], string, MoodVector]> + describe('deterministic scene planning', () => { it('normalizes equivalent input and hashes it consistently', () => { expect(normalizeInput(' 빛이\n 번진다 ')).toBe('빛이 번진다') @@ -45,6 +68,16 @@ describe('deterministic scene planning', () => { expect(second).not.toEqual(first) }) + it.each(CURATED_MODEL_CASES)( + 'keeps the curated %s example in its family across variations', + (family, input, signals) => { + const families = [0, 1, 7].map( + (variation) => createAuraScenePlan(input, signals, variation).flower.family, + ) + expect(families).toEqual([family, family, family]) + }, + ) + it('normalizes incompatible values into a valid safe plan', () => { const invalid = { seed: Number.NaN,