| import { clamp01, type Vec2 } from './utils'; |
| import { convexHull } from './colors'; |
|
|
| |
| |
| |
| |
| |
|
|
| export function normToClip(p: Vec2): [number, number] { |
| |
| const x = 1.0 - p.x; |
| const y = p.y; |
| return [x * 2 - 1, (1 - y) * 2 - 1]; |
| } |
|
|
| function sortAroundCenter(pts: Vec2[], center: Vec2): Vec2[] { |
| return [...pts].sort((a, b) => { |
| const aa = Math.atan2(a.y - center.y, a.x - center.x); |
| const bb = Math.atan2(b.y - center.y, b.x - center.x); |
| return aa - bb; |
| }); |
| } |
|
|
| export function buildFan(pts: Vec2[], sortByAngle: boolean = false): Float32Array { |
| if (pts.length < 3) return new Float32Array(0); |
| let cx = 0, cy = 0; |
| for (const p of pts) { |
| cx += p.x; |
| cy += p.y; |
| } |
| cx /= pts.length; |
| cy /= pts.length; |
| const center: Vec2 = { x: cx, y: cy }; |
|
|
| const contour = sortByAngle ? sortAroundCenter(pts, center) : pts; |
|
|
| const out: number[] = []; |
| const [ccx, ccy] = normToClip(center); |
| for (let i = 0; i < contour.length; i++) { |
| const a = contour[i]; |
| const b = contour[(i + 1) % contour.length]; |
| const [ax, ay] = normToClip(a); |
| const [bx, by] = normToClip(b); |
| out.push(ccx, ccy, ax, ay, bx, by); |
| } |
| return new Float32Array(out); |
| } |
|
|
| export function buildRibbon( |
| pts: Vec2[], |
| halfWidth: number, |
| taper: boolean = false, |
| ): Float32Array { |
| |
| if (pts.length < 2) return new Float32Array(0); |
| const hw = Math.max(0.0005, halfWidth); |
|
|
| const out: number[] = []; |
|
|
| const getDir = (a: Vec2, b: Vec2) => { |
| const dx = b.x - a.x; |
| const dy = b.y - a.y; |
| const len = Math.hypot(dx, dy) || 1; |
| return { x: dx / len, y: dy / len }; |
| }; |
|
|
| for (let i = 0; i < pts.length; i++) { |
| const p = pts[i]; |
| const prev = pts[Math.max(0, i - 1)]; |
| const next = pts[Math.min(pts.length - 1, i + 1)]; |
|
|
| const d0 = getDir(prev, p); |
| const d1 = getDir(p, next); |
| |
| let dx = d0.x + d1.x; |
| let dy = d0.y + d1.y; |
| const dlen = Math.hypot(dx, dy) || 1; |
| dx /= dlen; |
| dy /= dlen; |
|
|
| const nx = -dy; |
| const ny = dx; |
|
|
| let w = hw; |
| if (taper) { |
| const t = pts.length === 1 ? 0.5 : i / (pts.length - 1); |
| |
| const s = Math.sin(Math.PI * t); |
| w = hw * (0.35 + 0.65 * s); |
| } |
|
|
| const left: Vec2 = { x: clamp01(p.x + nx * w), y: clamp01(p.y + ny * w) }; |
| const right: Vec2 = { x: clamp01(p.x - nx * w), y: clamp01(p.y - ny * w) }; |
|
|
| const [lx, ly] = normToClip(left); |
| const [rx, ry] = normToClip(right); |
| out.push(lx, ly, rx, ry); |
| } |
|
|
| const tri: number[] = []; |
| for (let i = 0; i < pts.length - 1; i++) { |
| const i0 = i * 4; |
| const i1 = (i + 1) * 4; |
| const v0L = [out[i0], out[i0 + 1]]; |
| const v0R = [out[i0 + 2], out[i0 + 3]]; |
| const v1L = [out[i1], out[i1 + 1]]; |
| const v1R = [out[i1 + 2], out[i1 + 3]]; |
| tri.push(v0L[0], v0L[1], v0R[0], v0R[1], v1L[0], v1L[1]); |
| tri.push(v1L[0], v1L[1], v0R[0], v0R[1], v1R[0], v1R[1]); |
| } |
| return new Float32Array(tri); |
| } |
|
|
| function resamplePolyline(pts: Vec2[], n: number): Vec2[] { |
| if (n < 2 || pts.length === 0) return []; |
| if (pts.length === 1) return Array.from({ length: n }, () => ({ x: pts[0].x, y: pts[0].y })); |
| const lens: number[] = [0]; |
| for (let i = 1; i < pts.length; i++) { |
| lens.push(lens[i - 1] + Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y)); |
| } |
| const total = lens[lens.length - 1]; |
| const out: Vec2[] = []; |
| for (let k = 0; k < n; k++) { |
| const t = total < 1e-9 ? 0 : (k / (n - 1)) * total; |
| let j = 0; |
| while (j < lens.length - 2 && lens[j + 1] < t) j++; |
| const segLen = lens[j + 1] - lens[j]; |
| const u = segLen < 1e-9 ? 0 : (t - lens[j]) / segLen; |
| const p0 = pts[j]; |
| const p1 = pts[j + 1]; |
| out.push({ x: clamp01(p0.x + (p1.x - p0.x) * u), y: clamp01(p0.y + (p1.y - p0.y) * u) }); |
| } |
| return out; |
| } |
|
|
| function revPts(pts: Vec2[]): Vec2[] { |
| return [...pts].reverse(); |
| } |
|
|
| |
| |
| |
| |
| function loftCorrespondenceMse(crease: Vec2[], lash: Vec2[], sampleN = 28): number { |
| if (crease.length < 2 || lash.length < 2) return Infinity; |
| const cS = resamplePolyline(crease, sampleN); |
| const lS = resamplePolyline(lash, sampleN); |
| let acc = 0; |
| for (let i = 0; i < sampleN; i++) { |
| const dx = cS[i].x - lS[i].x; |
| const dy = cS[i].y - lS[i].y; |
| acc += dx * dx + dy * dy; |
| } |
| return acc / sampleN; |
| } |
|
|
| |
| function alignCreaseLashForStrip(crease: Vec2[], lash: Vec2[]): { crease: Vec2[]; lash: Vec2[] } { |
| if (crease.length < 2 || lash.length < 2) return { crease, lash }; |
| const endScore = (c: Vec2[], l: Vec2[]) => { |
| const [ax, ay] = normToClip(c[0]); |
| const [bx, by] = normToClip(c[c.length - 1]); |
| const [cx, cy] = normToClip(l[0]); |
| const [dx, dy] = normToClip(l[l.length - 1]); |
| const d2 = (p: number, q: number, r: number, s: number) => { |
| const u = p - r; |
| const v = q - s; |
| return u * u + v * v; |
| }; |
| return d2(ax, ay, cx, cy) + d2(bx, by, dx, dy); |
| }; |
| const meanY = (v: Vec2[]) => v.reduce((a, p) => a + p.y, 0) / v.length; |
| const lRev = revPts(lash); |
| const cRev = revPts(crease); |
| const opts = [ |
| { id: 0, c: crease, l: lash, s: endScore(crease, lash), m: loftCorrespondenceMse(crease, lash) }, |
| { id: 1, c: crease, l: lRev, s: endScore(crease, lRev), m: loftCorrespondenceMse(crease, lRev) }, |
| { id: 2, c: cRev, l: lash, s: endScore(cRev, lash), m: loftCorrespondenceMse(cRev, lash) }, |
| { id: 3, c: cRev, l: lRev, s: endScore(cRev, lRev), m: loftCorrespondenceMse(cRev, lRev) }, |
| ]; |
| const okAny = opts.some((o) => meanY(o.c) < meanY(o.l) - 0.001); |
| opts.sort((a, b) => { |
| const okA = meanY(a.c) < meanY(a.l) - 0.001; |
| const okB = meanY(b.c) < meanY(b.l) - 0.001; |
| if (okAny) { |
| if (okA !== okB) return okA ? -1 : 1; |
| } |
| if (a.m !== b.m) return a.m < b.m ? -1 : 1; |
| if (a.s !== b.s) return a.s < b.s ? -1 : 1; |
| return a.id - b.id; |
| }); |
| return { crease: opts[0].c, lash: opts[0].l }; |
| } |
|
|
| |
| |
| |
| |
| |
| function buildEyeshadowCreaseLashStrip(crease: Vec2[], lash: Vec2[], segments: number): Float32Array { |
| if (crease.length < 2 || lash.length < 2) return new Float32Array(0); |
| const n = Math.max(12, Math.min(56, segments | 0)); |
| const creaseS = resamplePolyline(crease, n); |
| const lashS = resamplePolyline(lash, n); |
| const tri: number[] = []; |
| const pushClipTri = (a: Vec2, b: Vec2, cPt: Vec2) => { |
| const [ax, ay] = normToClip(a); |
| const [bx, by] = normToClip(b); |
| const [cx, cy] = normToClip(cPt); |
| tri.push(ax, ay, bx, by, cx, cy); |
| }; |
| for (let i = 0; i < n - 1; i++) { |
| const a0 = creaseS[i]; |
| const a1 = creaseS[i + 1]; |
| const b0 = lashS[i]; |
| const b1 = lashS[i + 1]; |
| pushClipTri(a0, a1, b1); |
| pushClipTri(a0, b1, b0); |
| } |
| return new Float32Array(tri); |
| } |
|
|
| |
| export function buildShadowRibbon( |
| pts: Vec2[], |
| creaseIdx: readonly number[], |
| lashIdx: readonly number[], |
| ): Float32Array { |
| const crease = creaseIdx.map((i) => pts[i]).filter(Boolean) as Vec2[]; |
| const lash = lashIdx.map((i) => pts[i]).filter(Boolean) as Vec2[]; |
| const { crease: c, lash: l } = alignCreaseLashForStrip(crease, lash); |
| return buildEyeshadowCreaseLashStrip(c, l, 36); |
| } |
|
|
| export function buildWingedRibbon( |
| pts: Vec2[], |
| halfWidth: number, |
| side: 'left' | 'right', |
| wingLen: number, |
| ): Float32Array { |
| if (pts.length < 2) return new Float32Array(0); |
| |
| |
| const corner = pts[0]; |
| const next = pts[1]; |
| let dx = corner.x - next.x; |
| let dy = corner.y - next.y; |
|
|
| |
| |
| if (side === 'left' && dx > 0) dx = -dx; |
| if (side === 'right' && dx < 0) dx = -dx; |
|
|
| |
| dy -= 0.45 * Math.abs(dx); |
|
|
| const len = Math.hypot(dx, dy) || 1; |
| const ux = dx / len; |
| const uy = dy / len; |
| const wing: Vec2 = { x: clamp01(corner.x + ux * wingLen), y: clamp01(corner.y + uy * wingLen) }; |
| const withWing = [wing, ...pts]; |
| return buildRibbon(withWing, halfWidth); |
| } |
|
|
| export function buildBlushRegion(allPts: Vec2[], centerIdx: number, side: 'left' | 'right'): Float32Array { |
| const c = allPts[centerIdx]; |
| if (!c) return new Float32Array(0); |
|
|
| |
| const radius = 0.075; |
| const candidates: Vec2[] = []; |
| for (let i = 0; i < allPts.length; i++) { |
| const p = allPts[i]; |
| const dx = p.x - c.x; |
| const dy = p.y - c.y; |
| const d2 = dx * dx + dy * dy; |
| if (d2 > radius * radius) continue; |
|
|
| |
| if (Math.abs(dy) > 0.05) continue; |
| if (side === 'left' && p.x > c.x + 0.02) continue; |
| if (side === 'right' && p.x < c.x - 0.02) continue; |
| candidates.push(p); |
| } |
|
|
| if (candidates.length < 8) { |
| const simple: Vec2[] = []; |
| const r = 0.03; |
| for (let k = 0; k < 16; k++) { |
| const a = (k / 16) * Math.PI * 2; |
| simple.push({ x: clamp01(c.x + Math.cos(a) * r), y: clamp01(c.y + Math.sin(a) * r) }); |
| } |
| return buildFan(simple); |
| } |
|
|
| return buildFan(convexHull(candidates)); |
| } |
|
|
| |
| |
| |
| |
| |
| export function buildUnderEyeRegion( |
| pts: Vec2[], |
| lowerLidIdx: readonly number[], |
| offsetDown = 0.030, |
| ): Float32Array { |
| const lid = lowerLidIdx.map((i) => pts[i]).filter(Boolean) as Vec2[]; |
| if (lid.length < 3) return new Float32Array(0); |
| const lower = lid.map((p) => ({ x: clamp01(p.x), y: clamp01(p.y + offsetDown) })); |
| const poly: Vec2[] = lid.concat([...lower].reverse()); |
| return buildFan(poly); |
| } |
|
|
| export function offsetPts(pts: Vec2[], dx: number, dy: number = 0): Vec2[] { |
| return pts.map((p) => ({ x: clamp01(p.x + dx), y: clamp01(p.y + dy) })); |
| } |
|
|
| export function makeCircle(center: Vec2, r: number, seg = 24): Vec2[] { |
| const pts: Vec2[] = []; |
| for (let i = 0; i < seg; i++) { |
| const a = (i / seg) * Math.PI * 2; |
| pts.push({ x: clamp01(center.x + Math.cos(a) * r), y: clamp01(center.y + Math.sin(a) * r) }); |
| } |
| return pts; |
| } |
|
|
| export function uniqueConcat<T>(a: T[], b: T[]): T[] { |
| const out: T[] = []; |
| const seen = new Set<T>(); |
| for (const x of a.concat(b)) { |
| if (seen.has(x)) continue; |
| seen.add(x); |
| out.push(x); |
| } |
| return out; |
| } |
|
|
| |
| |
| |
| |
| |
|
|
| export type ClipBounds = { xMin: number; xMax: number; yMin: number; yMax: number }; |
|
|
| export function clipBounds(verts: Float32Array): ClipBounds { |
| let xMin = Infinity; |
| let yMin = Infinity; |
| let xMax = -Infinity; |
| let yMax = -Infinity; |
| for (let i = 0; i < verts.length; i += 2) { |
| const x = verts[i]; |
| const y = verts[i + 1]; |
| if (x < xMin) xMin = x; |
| if (x > xMax) xMax = x; |
| if (y < yMin) yMin = y; |
| if (y > yMax) yMax = y; |
| } |
| return { xMin, xMax, yMin, yMax }; |
| } |
|
|
| |
| export function clipBoundsToPixelRect( |
| fbW: number, |
| fbH: number, |
| b: ClipBounds, |
| padPx: number, |
| ) { |
| const x = Math.max(0, Math.floor((b.xMin + 1) * 0.5 * fbW) - padPx); |
| const x2 = Math.min(fbW, Math.ceil((b.xMax + 1) * 0.5 * fbW) + padPx); |
| const y = Math.max(0, Math.floor((b.yMin + 1) * 0.5 * fbH) - padPx); |
| const y2 = Math.min(fbH, Math.ceil((b.yMax + 1) * 0.5 * fbH) + padPx); |
| return { x, y, w: Math.max(0, x2 - x), h: Math.max(0, y2 - y) }; |
| } |
|
|