[READ-ONLY] Mirror of https://github.com/flo-bit/shapecraft. flo-bit.dev/shapecraft/
0

Configure Feed

Select the types of activity you want to include in your feed.

shapecraft / src / ops / snow.ts
7.6 kB 198 lines
1import * as THREE from 'three' 2import { Mesh } from '../core/mesh' 3import { merge } from './merge' 4import { parseColor } from '../core/math' 5import { UberNoise } from '../noise' 6import type { ColorInput } from '../core/types' 7 8export interface SnowOptions { 9 /** Vertical thickness of the snow layer. Default 0.06. */ 10 depth?: number 11 /** Max surface slope from horizontal (degrees) that still holds snow. Higher = more coverage. Default 40. */ 12 minAngle?: number 13 /** Snow color. Default a cool off-white. */ 14 color?: ColorInput 15 /** Horizontal overhang outward from the mesh's vertical axis, giving snow a soft lip. Default depth * 0.6. */ 16 spread?: number 17 /** 0..1 — fraction of eligible faces actually covered. < 1 produces patchy, noisy edges. Default 1. */ 18 coverage?: number 19 /** Noise scale for coverage patchiness and color variation. Default 2. */ 20 noiseScale?: number 21 /** Random brightness variation of the snow color. Default 0.04. */ 22 colorVariation?: number 23 /** Seed for the coverage/color noise. Default 0. */ 24 seed?: number 25 /** [x, z] axis the snow spreads away from. Defaults to the mesh's bounding-box center. */ 26 center?: [number, number] 27 /** If false, return only the snow shell instead of merging it onto the input mesh. Default true. */ 28 merge?: boolean 29} 30 31/** 32 * Lay a layer of snow with real thickness on the upward-facing surfaces of a mesh. 33 * 34 * Unlike painting snow as a face color, this builds geometry: a raised "blanket" over 35 * the snow-receiving faces plus skirt walls around its edges, so the snow reads as a 36 * solid layer sitting on top of branches, canopies, rocks, roofs, etc. 37 */ 38export function snow(mesh: Mesh, options: SnowOptions = {}): Mesh { 39 const depth = options.depth ?? 0.06 40 const minAngle = options.minAngle ?? 40 41 const spread = options.spread ?? depth * 0.6 42 const coverage = options.coverage ?? 1 43 const noiseScale = options.noiseScale ?? 2 44 const colorVariation = options.colorVariation ?? 0.04 45 const seed = options.seed ?? 0 46 const threshold = Math.cos((minAngle * Math.PI) / 180) 47 const snowColor = parseColor(options.color ?? '#eef0f5') 48 49 let geo = mesh.geometry 50 geo = geo.getIndex() ? geo.toNonIndexed() : geo.clone() 51 const pos = geo.getAttribute('position') 52 const faceCount = pos.count / 3 53 54 // Axis the snow spreads away from (for the overhang lip + skirt orientation). 55 let cx = 0 56 let cz = 0 57 if (options.center) { 58 cx = options.center[0] 59 cz = options.center[1] 60 } else { 61 geo.computeBoundingBox() 62 const bb = geo.boundingBox! 63 cx = (bb.min.x + bb.max.x) / 2 64 cz = (bb.min.z + bb.max.z) / 2 65 } 66 67 const noise = new UberNoise({ seed, scale: noiseScale }) 68 69 const q = (n: number) => Math.round(n * 1e5) 70 const edgeKey = ( 71 ax: number, ay: number, az: number, 72 bx: number, by: number, bz: number, 73 ): string => { 74 const ka = `${q(ax)},${q(ay)},${q(az)}` 75 const kb = `${q(bx)},${q(by)},${q(bz)}` 76 return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}` 77 } 78 79 // Pass 1: classify faces and count edges within the snowy region. 80 interface EdgeRec { 81 ax: number; ay: number; az: number 82 bx: number; by: number; bz: number 83 count: number 84 } 85 const snowyFaces: number[] = [] 86 const edges = new Map<string, EdgeRec>() 87 88 for (let f = 0; f < faceCount; f++) { 89 const i = f * 3 90 const ax = pos.getX(i), ay = pos.getY(i), az = pos.getZ(i) 91 const bx = pos.getX(i + 1), by = pos.getY(i + 1), bz = pos.getZ(i + 1) 92 const cxv = pos.getX(i + 2), cyv = pos.getY(i + 2), czv = pos.getZ(i + 2) 93 94 // Face normal (only the up-component matters for the slope test). 95 const e1x = bx - ax, e1y = by - ay, e1z = bz - az 96 const e2x = cxv - ax, e2y = cyv - ay, e2z = czv - az 97 const nx = e1y * e2z - e1z * e2y 98 const ny = e1z * e2x - e1x * e2z 99 const nz = e1x * e2y - e1y * e2x 100 const nl = Math.hypot(nx, ny, nz) || 1 101 if (ny / nl < threshold) continue 102 103 if (coverage < 1) { 104 const mx = (ax + bx + cxv) / 3 105 const my = (ay + by + cyv) / 3 106 const mz = (az + bz + czv) / 3 107 const n01 = noise.get(mx, my, mz) * 0.5 + 0.5 108 if (n01 > coverage) continue 109 } 110 111 snowyFaces.push(f) 112 const tri = [[ax, ay, az], [bx, by, bz], [cxv, cyv, czv]] 113 for (let k = 0; k < 3; k++) { 114 const A = tri[k] 115 const B = tri[(k + 1) % 3] 116 const key = edgeKey(A[0], A[1], A[2], B[0], B[1], B[2]) 117 const rec = edges.get(key) 118 if (rec) rec.count++ 119 else edges.set(key, { ax: A[0], ay: A[1], az: A[2], bx: B[0], by: B[1], bz: B[2], count: 1 }) 120 } 121 } 122 123 if (snowyFaces.length === 0) { 124 return options.merge === false ? new Mesh(new THREE.BufferGeometry()) : mesh.clone() 125 } 126 127 const sp: number[] = [] 128 const sc: number[] = [] 129 130 const raise = (x: number, y: number, z: number): [number, number, number] => { 131 let dx = x - cx 132 let dz = z - cz 133 const dl = Math.hypot(dx, dz) 134 if (dl > 1e-6) { dx /= dl; dz /= dl } else { dx = 0; dz = 0 } 135 return [x + dx * spread, y + depth, z + dz * spread] 136 } 137 const pushColor = (mx: number, my: number, mz: number) => { 138 const v = noise.get(mx + 10, my + 10, mz + 10) * colorVariation 139 sc.push( 140 Math.min(1, Math.max(0, snowColor.r + v)), 141 Math.min(1, Math.max(0, snowColor.g + v)), 142 Math.min(1, Math.max(0, snowColor.b + v)), 143 ) 144 } 145 146 // The raised snow blanket (same winding as the source faces → normals point up). 147 for (const f of snowyFaces) { 148 const i = f * 3 149 const a = raise(pos.getX(i), pos.getY(i), pos.getZ(i)) 150 const b = raise(pos.getX(i + 1), pos.getY(i + 1), pos.getZ(i + 1)) 151 const c = raise(pos.getX(i + 2), pos.getY(i + 2), pos.getZ(i + 2)) 152 sp.push(a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]) 153 const mx = (a[0] + b[0] + c[0]) / 3 154 const my = (a[1] + b[1] + c[1]) / 3 155 const mz = (a[2] + b[2] + c[2]) / 3 156 pushColor(mx, my, mz); pushColor(mx, my, mz); pushColor(mx, my, mz) 157 } 158 159 // Skirt walls along the region boundary (edges used by exactly one snowy face). 160 for (const rec of edges.values()) { 161 if (rec.count !== 1) continue 162 const A: [number, number, number] = [rec.ax, rec.ay, rec.az] 163 const B: [number, number, number] = [rec.bx, rec.by, rec.bz] 164 const Ar = raise(A[0], A[1], A[2]) 165 const Br = raise(B[0], B[1], B[2]) 166 167 // Orient the wall outward (its horizontal normal should point away from center). 168 const midx = (A[0] + B[0]) / 2 169 const midz = (A[2] + B[2]) / 2 170 let ox = midx - cx, oz = midz - cz 171 const ol = Math.hypot(ox, oz) || 1 172 ox /= ol; oz /= ol 173 const ux = B[0] - A[0], uy = B[1] - A[1], uz = B[2] - A[2] 174 const vx = Br[0] - A[0], vy = Br[1] - A[1], vz = Br[2] - A[2] 175 const tnx = uy * vz - uz * vy 176 const tnz = ux * vy - uy * vx 177 178 if (tnx * ox + tnz * oz >= 0) { 179 sp.push(A[0], A[1], A[2], B[0], B[1], B[2], Br[0], Br[1], Br[2]) 180 sp.push(A[0], A[1], A[2], Br[0], Br[1], Br[2], Ar[0], Ar[1], Ar[2]) 181 } else { 182 sp.push(A[0], A[1], A[2], Br[0], Br[1], Br[2], B[0], B[1], B[2]) 183 sp.push(A[0], A[1], A[2], Ar[0], Ar[1], Ar[2], Br[0], Br[1], Br[2]) 184 } 185 const mx = (A[0] + B[0]) / 2 186 const my = (A[1] + B[1]) / 2 + depth / 2 187 const mz = (A[2] + B[2]) / 2 188 for (let k = 0; k < 6; k++) pushColor(mx, my, mz) 189 } 190 191 const snowGeo = new THREE.BufferGeometry() 192 snowGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(sp), 3)) 193 snowGeo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(sc), 3)) 194 snowGeo.computeVertexNormals() 195 const snowMesh = new Mesh(snowGeo) 196 197 return options.merge === false ? snowMesh : merge(mesh, snowMesh) 198}