import * as THREE from 'three'; import type { SceneTheme } from './ThreeCanvas'; export interface ScenePalette { accent: number; accentSoft: number; edge: number; foliage: number; ground: number; mountain: number; mountainDark: number; mountainLight: number; snow: number; trunk: number; water: number; } export const palettes: Record = { light: { accent: 0x3d8afa, accentSoft: 0x82b5ff, edge: 0x1557a6, foliage: 0x1557a6, ground: 0xf8faff, mountain: 0x1557a6, mountainDark: 0x091c3f, mountainLight: 0x82b5ff, snow: 0xe6f0fe, trunk: 0x0b2f5f, water: 0x3d8afa, }, dark: { accent: 0x82b5ff, accentSoft: 0x3d8afa, edge: 0x426b9d, foliage: 0x3d8afa, ground: 0x07132f, mountain: 0x0b2f5f, mountainDark: 0x010d27, mountainLight: 0x103f7f, snow: 0x38526e, trunk: 0x091c3f, water: 0x1557a6, }, }; export function fitOrthographicCamera( camera: THREE.OrthographicCamera, width: number, height: number, viewHeight: number, ) { const aspect = width / Math.max(height, 1); camera.left = (-viewHeight * aspect) / 2; camera.right = (viewHeight * aspect) / 2; camera.top = viewHeight / 2; camera.bottom = -viewHeight / 2; camera.updateProjectionMatrix(); } export function createRibbonShape(points: THREE.Vector2[], width: number) { const halfWidth = width / 2; const n = points.length; if (n < 2) return new THREE.Shape(); const left: THREE.Vector2[] = []; const right: THREE.Vector2[] = []; for (let i = 0; i < n; i++) { let normal: THREE.Vector2; let miterScale = 1; if (i === 0) { const d = points[1].clone().sub(points[0]).normalize(); normal = new THREE.Vector2(-d.y, d.x); } else if (i === n - 1) { const d = points[n - 1].clone().sub(points[n - 2]).normalize(); normal = new THREE.Vector2(-d.y, d.x); } else { const d1 = points[i].clone().sub(points[i - 1]).normalize(); const d2 = points[i + 1].clone().sub(points[i]).normalize(); const n1 = new THREE.Vector2(-d1.y, d1.x); const n2 = new THREE.Vector2(-d2.y, d2.x); const miter = n1.clone().add(n2); const len = miter.length(); if (len > 0.0001) { miter.normalize(); const dot = Math.max(miter.dot(n1), 0.25); miterScale = Math.min(1 / dot, 2.5); normal = miter; } else { normal = n1; } } const offset = normal.clone().multiplyScalar(halfWidth * miterScale); left.push(points[i].clone().add(offset)); right.push(points[i].clone().sub(offset)); } const shape = new THREE.Shape(); shape.moveTo(left[0].x, left[0].y); for (let i = 1; i < left.length; i++) { shape.lineTo(left[i].x, left[i].y); } for (let i = right.length - 1; i >= 0; i--) { shape.lineTo(right[i].x, right[i].y); } shape.closePath(); return shape; } export function createMesa( width: number, height: number, depth: number, palette: ScenePalette, ) { const halfWidth = width / 2; const halfDepth = depth / 2; const inset = Math.min(width, depth) * 0.19; const positions = [ -halfWidth, 0, -halfDepth, halfWidth, 0, -halfDepth, halfWidth, 0, halfDepth, -halfWidth, 0, halfDepth, -halfWidth + inset, height, -halfDepth + inset, halfWidth - inset, height, -halfDepth + inset, halfWidth - inset, height, halfDepth - inset, -halfWidth + inset, height, halfDepth - inset, ]; const indices = [ 4, 6, 5, 4, 7, 6, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7, ]; const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setIndex(indices); geometry.clearGroups(); geometry.addGroup(0, 6, 0); geometry.addGroup(6, 6, 1); geometry.addGroup(12, 6, 2); geometry.addGroup(18, 6, 1); geometry.addGroup(24, 6, 2); geometry.computeVertexNormals(); const mesa = new THREE.Mesh( geometry, [ new THREE.MeshStandardMaterial({ color: palette.mountainLight, flatShading: true, roughness: 0.9, }), new THREE.MeshStandardMaterial({ color: palette.mountain, flatShading: true, roughness: 0.92, }), new THREE.MeshStandardMaterial({ color: palette.mountainDark, flatShading: true, roughness: 0.94, }), ], ); mesa.castShadow = true; mesa.receiveShadow = true; return mesa; } export function createTree(palette: ScenePalette, scale = 1, pill = false) { const tree = new THREE.Group(); const trunk = new THREE.Mesh( new THREE.CylinderGeometry(0.08 * scale, 0.1 * scale, 0.45 * scale, 8), new THREE.MeshStandardMaterial({ color: palette.trunk, roughness: 1 }), ); trunk.position.y = 0.22 * scale; tree.add(trunk); const crown = new THREE.Mesh( pill ? new THREE.CapsuleGeometry(0.2 * scale, 0.5 * scale, 4, 8) : new THREE.ConeGeometry(0.38 * scale, 1.25 * scale, 10), new THREE.MeshStandardMaterial({ color: pill ? palette.accentSoft : palette.foliage, flatShading: true, roughness: 0.9, }), ); crown.position.y = pill ? 0.9 * scale : 0.95 * scale; crown.castShadow = true; tree.add(crown); return tree; } export function addSoftLights(scene: THREE.Scene, theme: SceneTheme, shadowArea: number) { scene.add( new THREE.HemisphereLight( theme === 'dark' ? 0x3d8afa : 0xffffff, theme === 'dark' ? 0x010d27 : 0xc3cfe0, theme === 'dark' ? 1.7 : 2.8, ), ); const key = new THREE.DirectionalLight(0xffffff, theme === 'dark' ? 1.7 : 2.8); key.position.set(-7, 12, 8); key.castShadow = true; key.shadow.mapSize.set(1024, 1024); key.shadow.camera.left = -shadowArea; key.shadow.camera.right = shadowArea; key.shadow.camera.top = shadowArea; key.shadow.camera.bottom = -shadowArea; key.shadow.camera.near = 1; key.shadow.camera.far = 40; key.shadow.bias = -0.0004; scene.add(key); }