Files
homarr/tools/motion-reel/three/wordmark-rig.ts
T
+12 fcd7c979df feat!: release v2 (#6545)
Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
2026-10-02 21:00:14 +02:00

375 lines
13 KiB
TypeScript

import * as THREE from "three";
import { SVGLoader } from "three/examples/jsm/loaders/SVGLoader.js";
import type { LabConfig } from "./lab-types";
export interface WordmarkRig {
model: THREE.Group;
baseScale: number;
frame: THREE.Group;
spine: THREE.Group;
cursor: THREE.Group;
clickRays: THREE.Group;
panels: THREE.Group[];
letters: THREE.Group[];
kana: THREE.Group[];
arrowHead: THREE.Group;
arrowSegments: THREE.Group[];
arrowTip: THREE.Group;
breathingOrder: THREE.Group[];
hoverParts: THREE.Group[];
hoverMeshes: THREE.Mesh[];
beamParts: THREE.Group[];
beamAnchors: number[];
beamPath: THREE.CatmullRomCurve3;
beamDots: THREE.Mesh[];
}
export interface WordmarkMotion {
cursor: boolean;
arrow: boolean;
panels: boolean;
letters: boolean;
kana: boolean;
beam: boolean;
breathe: boolean;
}
export const defaultWordmarkMotion: WordmarkMotion = {
cursor: true,
arrow: true,
panels: true,
letters: true,
kana: true,
beam: true,
breathe: true,
};
function home(part: THREE.Group) {
return part.userData.home as THREE.Vector3;
}
const beamColor = new THREE.Color(0xffe9c4);
const hoverColor = new THREE.Color(0xffc1c1);
function tintPart(part: THREE.Group, color: THREE.Color, amount: number) {
for (const child of part.children) {
if (!(child instanceof THREE.Mesh)) continue;
const face = Array.isArray(child.material) ? child.material[0] : child.material;
if (!(face instanceof THREE.MeshBasicMaterial)) continue;
face.color.copy(child.userData.baseFaceColor as THREE.Color).lerp(color, amount);
}
}
export function buildWordmarkRig(svg: string, config: LabConfig): WordmarkRig {
const paths = new SVGLoader().parse(svg).paths;
const model = new THREE.Group();
model.name = "IllustratedWordmarkRig";
const reverseArtwork = new THREE.Group();
reverseArtwork.name = "StaticReverseArtwork";
const parts = new Map<string, THREE.Group>();
const plateDepth = config.depth * 60;
paths.forEach((path, pathIndex) => {
const node = path.userData?.node as Element | undefined;
const parent = node?.parentNode as Element | null | undefined;
const partName = parent?.getAttribute("data-rig-part");
if (!partName) throw new Error(`Wordmark contour ${pathIndex} has no rig part`);
let part = parts.get(partName);
if (!part) {
part = new THREE.Group();
part.name = partName;
parts.set(partName, part);
}
let depth = 2.2;
let back = 4 + pathIndex * 0.025;
if (pathIndex === 0) {
depth = plateDepth;
back = -plateDepth;
} else if (pathIndex === 1) {
depth = 2.4;
back = 0.25;
} else if (partName === "arrow-spine") {
depth = 2;
back = 2.8 + pathIndex * 0.025;
} else if (partName === "cursor" || partName === "click-rays") {
depth = 3;
back = 7 + pathIndex * 0.025;
}
const face = new THREE.MeshBasicMaterial({
color: path.color,
side: THREE.DoubleSide,
wireframe: config.wireframe,
toneMapped: false,
});
const edgeColor = pathIndex === 0 ? new THREE.Color(0xf93232) : path.color.clone().multiplyScalar(0.58);
const edge = new THREE.MeshStandardMaterial({
color: edgeColor,
metalness: 0.1,
roughness: 0.58,
side: THREE.DoubleSide,
wireframe: config.wireframe,
});
for (const shape of SVGLoader.createShapes(path)) {
const geometry = new THREE.ExtrudeGeometry(shape, {
depth,
bevelEnabled: pathIndex === 0,
bevelThickness: config.bevel * 8,
bevelSize: config.bevel * 6,
bevelSegments: 2,
curveSegments: 10,
});
geometry.computeBoundingBox();
const center = geometry.boundingBox?.getCenter(new THREE.Vector3()) ?? new THREE.Vector3();
geometry.translate(-center.x, -center.y, -center.z);
const mesh = new THREE.Mesh(geometry, [face, edge]);
mesh.name = `${partName}-${pathIndex}`;
mesh.position.set(center.x, center.y, center.z + back);
mesh.renderOrder = pathIndex;
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.baseFaceColor = path.color.clone();
part.add(mesh);
if (pathIndex > 0) {
// The reverse is painted too, so the lockup remains readable from
// behind instead of becoming an unmarked white silhouette.
const reverseGeometry = new THREE.ShapeGeometry(shape, 10);
reverseGeometry.translate(-center.x, -center.y, 0);
const reverse = new THREE.Mesh(reverseGeometry, face.clone());
reverse.name = `${partName}-${pathIndex}-reverse`;
reverse.position.set(center.x, center.y, -plateDepth - 0.5 - pathIndex * 0.025);
reverse.renderOrder = pathIndex;
// The rear print belongs to the plaque, not the articulated foreground.
// Moving it with a letter would bury it inside the backing from behind.
reverseArtwork.add(reverse);
}
}
});
for (const part of parts.values()) {
const bounds = new THREE.Box3().setFromObject(part);
const origin = bounds.getCenter(new THREE.Vector3());
if (part.name === "cursor" || part.name === "click-rays") origin.set(1229, 287, origin.z);
for (const child of part.children) child.position.sub(origin);
part.position.copy(origin);
model.add(part);
}
model.add(reverseArtwork);
const bounds = new THREE.Box3().setFromObject(model);
const center = bounds.getCenter(new THREE.Vector3());
const size = bounds.getSize(new THREE.Vector3());
for (const part of parts.values()) {
part.position.sub(center);
part.userData.home = part.position.clone();
}
reverseArtwork.position.sub(center);
const scale = 8.5 / Math.max(size.x, size.y);
model.scale.set(scale, -scale, scale);
const getPart = (name: string) => {
const part = parts.get(name);
if (!part) throw new Error(`Missing wordmark rig part: ${name}`);
return part;
};
const frame = getPart("frame");
const spine = getPart("arrow-spine");
const cursor = getPart("cursor");
const clickRays = getPart("click-rays");
const panels = ["panel-left", "panel-center", "panel-right"].map(getPart);
const letters = ["letter-h", "letter-o", "letter-m", "letter-a", "letter-r-one", "letter-r-two"].map(getPart);
const kana = ["kana-ho", "kana-ma", "kana-dash", "kana-ru"].map(getPart);
const arrowHead = getPart("arrow-head");
const arrowSegments = [
"arrow-segment-one",
"arrow-segment-two",
"arrow-segment-three",
"arrow-segment-four",
"arrow-segment-five",
].map(getPart);
const arrowTip = getPart("arrow-tip");
const chain = [arrowTip, ...arrowSegments.toReversed(), arrowHead];
const beamPoints = chain.map((part) => new THREE.Vector3(home(part).x, home(part).y, home(part).z + 17));
const beamPath = new THREE.CatmullRomCurve3(beamPoints);
let previous = beamPoints[0];
if (!previous) throw new Error("Wordmark chain has no parts");
let beamLength = 0;
const beamDistances = [0];
for (const point of beamPoints.slice(1)) {
beamLength += point.distanceTo(previous);
beamDistances.push(beamLength);
previous = point;
}
if (beamLength === 0) throw new Error("Wordmark chain has no length");
const beamAnchors = beamDistances.map((distance) => distance / beamLength);
const beamDots = [
{ radius: 10, opacity: 0.95 },
{ radius: 22, opacity: 0.22 },
{ radius: 7, opacity: 0.48 },
{ radius: 5, opacity: 0.28 },
].map(({ radius, opacity }) => {
const dot = new THREE.Mesh(
new THREE.SphereGeometry(radius, 12, 8),
new THREE.MeshBasicMaterial({
color: 0xffe7b5,
transparent: true,
opacity,
depthWrite: false,
toneMapped: false,
}),
);
dot.visible = false;
model.add(dot);
return dot;
});
const breathingOrder = [frame, ...panels, cursor, clickRays, ...letters, ...kana, spine, ...chain];
const hoverParts = [...panels, ...letters, ...kana, cursor, clickRays, ...chain];
const hoverMeshes = hoverParts.flatMap((part) =>
part.children.filter((child): child is THREE.Mesh => child instanceof THREE.Mesh),
);
return {
model,
baseScale: scale,
frame,
spine,
cursor,
clickRays,
panels,
letters,
kana,
arrowHead,
arrowSegments,
arrowTip,
breathingOrder,
hoverParts,
hoverMeshes,
beamParts: chain,
beamAnchors,
beamPath,
beamDots,
};
}
export function animateWordmarkRig(
rig: WordmarkRig,
time: number,
delta: number,
motion: WordmarkMotion,
hoveredPart: string | null,
) {
const breathPeriod = rig.breathingOrder.length * 0.23 + 1.3;
const breath = (index: number) => {
if (!motion.breathe) return 0;
const phase = (((time - index * 0.23) % breathPeriod) + breathPeriod) % breathPeriod;
const distance = (phase - 0.24) / 0.3;
return Math.exp(-(distance * distance));
};
rig.breathingOrder.forEach((part, index) => {
part.position.copy(home(part));
part.rotation.set(0, 0, 0);
part.scale.setScalar(1);
if (part === rig.frame) return;
const pulse = breath(index);
part.position.z += pulse * 9;
part.scale.setScalar(1 + pulse * 0.05);
});
const plaqueScale = rig.baseScale * (1 + breath(0) * 0.006);
rig.model.scale.set(plaqueScale, -plaqueScale, plaqueScale);
const blend = 1 - Math.exp(-delta * 14);
const hover = (part: THREE.Group, target: number) => {
const current = (part.userData.hoverLevel as number | undefined) ?? 0;
const next = current + (target - current) * blend;
part.userData.hoverLevel = next;
return next;
};
const activePanel = rig.panels.findIndex((part) => part.name === hoveredPart);
rig.panels.forEach((part, index) => {
let target = 0;
if (motion.panels && activePanel >= 0) target = index === activePanel ? 1 : 0.12;
const level = hover(part, target);
part.position.y -= level * (12 + Math.sin(time * 7 + index) * 2);
part.position.z += level * 46;
part.rotation.y = level * (Math.sin(time * 7 + index) * 0.08);
part.scale.multiplyScalar(1 + level * 0.13);
tintPart(part, hoverColor, level * 0.42);
});
const activeLetter = rig.letters.findIndex((part) => part.name === hoveredPart);
rig.letters.forEach((part, index) => {
let target = 0;
if (motion.letters && activeLetter >= 0) {
if (index === activeLetter) target = 1;
else if (Math.abs(index - activeLetter) === 1) target = 0.18;
}
const level = hover(part, target);
part.position.y -= level * 12;
part.position.z += level * 55;
part.rotation.y = Math.sin(time * 6 + index) * level * 0.05;
part.scale.multiplyScalar(1 + level * 0.14);
tintPart(part, hoverColor, level * 0.38);
});
const activeKana = rig.kana.findIndex((part) => part.name === hoveredPart);
rig.kana.forEach((part, index) => {
let target = 0;
if (motion.kana && activeKana >= 0) {
if (index === activeKana) target = 1;
else if (Math.abs(index - activeKana) === 1) target = 0.18;
}
const level = hover(part, target);
part.position.y -= level * 10;
part.position.z += level * 50;
part.rotation.z = Math.sin(time * 7 + index) * level * 0.035;
part.scale.multiplyScalar(1 + level * 0.16);
tintPart(part, hoverColor, level * 0.42);
});
const cursorHover = hover(rig.cursor, motion.cursor && hoveredPart === rig.cursor.name ? 1 : 0);
const raysHover = hover(rig.clickRays, motion.cursor && hoveredPart === rig.clickRays.name ? 1 : 0);
const click = motion.cursor ? ((1 + Math.cos(time * 3)) / 2) ** 10 : 0;
rig.cursor.position.y += click * 13 - cursorHover * 8;
rig.cursor.position.z += click * 18 + cursorHover * 30;
rig.cursor.scale.multiplyScalar(1 - click * 0.12 + cursorHover * 0.14);
rig.clickRays.position.z += click * 18 + raysHover * 26;
rig.clickRays.scale.multiplyScalar(1 + click * 0.28 + raysHover * 0.24);
rig.arrowSegments.forEach((part, index) => {
if (motion.arrow) {
const wave = Math.sin(time * 1.35 - index * 0.48);
part.position.y += wave * 8;
part.position.z += Math.max(0, wave) * 12;
part.rotation.z = wave * 0.02;
}
});
if (motion.arrow) {
rig.arrowHead.rotation.z = Math.sin(time * 1.35 + 0.4) * 0.018;
rig.arrowTip.rotation.z = Math.sin(time * 1.35 - 2.9) * 0.018;
}
const beamProgress = (time % 5.2) / 4.2;
const beamActive = motion.beam && beamProgress <= 1;
rig.beamDots.forEach((dot, index) => {
const trail = index < 2 ? 0 : (index - 1) * 0.035;
dot.visible = beamActive && beamProgress >= trail;
if (dot.visible) rig.beamPath.getPointAt(beamProgress - trail, dot.position);
});
rig.beamParts.forEach((part, index) => {
const center = rig.beamAnchors[index];
if (center === undefined) return;
const distance = (beamProgress - center) / 0.1;
const light = beamActive ? Math.exp(-(distance * distance)) : 0;
const level = hover(part, motion.arrow && hoveredPart === part.name ? 1 : 0);
part.position.z += light * 9 + level * 24;
part.scale.multiplyScalar(1 + light * 0.055 + level * 0.06);
tintPart(part, beamColor, Math.min(1, light * 0.7 + level * 0.36));
});
}