diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
new file mode 100644
index 0000000..fd59dd3
--- /dev/null
+++ b/.github/workflows/deploy-pages.yml
@@ -0,0 +1,39 @@
+name: Deploy to GitHub Pages
+
+# Publishes the static site (repo root) to GitHub Pages on every push to main.
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow one concurrent deployment; newer pushes cancel in-progress runs.
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload site artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: .
+
+ - name: Deploy
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.nojekyll b/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/README.md
index 1d73aa8..e9a4c06 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,45 @@
-# reigen
-reigen
+# Reigen Arataka — Three.js
+
+A tiny web app that draws **Reigen Arataka** from *Mob Psycho 100* — his face
+and slicked-back hair — as a procedural 3D model built with **plain JavaScript
+and Three.js only**. No build step, no framework.
+
+**Live demo:** https://tiennm99.github.io/reigen/
+
+## Features
+
+- Reigen's head, face (half-lidded "unimpressed" eyes, smug mouth, brows, nose,
+ ears), side-parted swept-back hair, and a suit + red tie bust.
+- **7 camera shots** you can switch between with a smooth tween:
+ front, 3/4 view, side profile, face close-up, "the stare", hair/top, low hero.
+- Orbit with the mouse (drag to rotate, scroll to zoom) and an auto-rotate toggle.
+- Warm key + cool rim lighting for an anime-portrait feel.
+
+## Run locally
+
+Because it uses ES modules and an import map, open it through a local server
+(not `file://`):
+
+```bash
+python3 -m http.server 8099
+# then visit http://localhost:8099
+```
+
+## Project structure
+
+```
+index.html # markup, import map, UI chrome
+css/style.css # layout and controls styling
+js/main.js # renderer, lights, controls, shot switching, loop
+js/reigen-character.js # procedural Reigen model (head, face, hair, bust)
+js/camera-shots.js # named camera positions/targets
+.github/workflows/ # GitHub Pages deployment
+```
+
+Three.js is loaded from a CDN via the import map in `index.html`.
+
+## Deployment
+
+Pushing to `main` triggers the GitHub Actions workflow
+(`.github/workflows/deploy-pages.yml`), which publishes the repo root to GitHub
+Pages. Enable Pages once under **Settings → Pages → Source: GitHub Actions**.
diff --git a/css/style.css b/css/style.css
new file mode 100644
index 0000000..77960af
--- /dev/null
+++ b/css/style.css
@@ -0,0 +1,134 @@
+/* Reigen Arataka Three.js showcase — layout & UI chrome only.
+ All 3D rendering happens on #scene. */
+
+:root {
+ --ink: #1c1a17;
+ --paper: #f4efe6;
+ --accent: #c8963e; /* Reigen's warm suit/hair tone */
+ --panel: rgba(28, 26, 23, 0.72);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html,
+body {
+ height: 100%;
+ overflow: hidden;
+ background: #0b0b0d;
+ color: var(--paper);
+ font-family: "Trebuchet MS", "Segoe UI", system-ui, sans-serif;
+}
+
+#app {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+}
+
+#scene {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+#titlebar {
+ position: absolute;
+ top: 18px;
+ left: 22px;
+ pointer-events: none;
+ text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
+}
+
+#titlebar h1 {
+ font-size: clamp(1.1rem, 2.4vw, 1.8rem);
+ letter-spacing: 0.04em;
+ color: var(--paper);
+}
+
+#titlebar p {
+ font-size: clamp(0.7rem, 1.4vw, 0.9rem);
+ color: var(--accent);
+ margin-top: 4px;
+}
+
+#shot-bar {
+ position: absolute;
+ right: 22px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ z-index: 5;
+}
+
+.shot-btn {
+ min-width: 116px;
+ padding: 9px 14px;
+ border: 1px solid rgba(200, 150, 62, 0.5);
+ border-radius: 999px;
+ background: var(--panel);
+ color: var(--paper);
+ font: inherit;
+ font-size: 0.82rem;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ backdrop-filter: blur(6px);
+ transition: background 0.2s, transform 0.15s, border-color 0.2s;
+ text-align: left;
+}
+
+.shot-btn:hover {
+ background: rgba(200, 150, 62, 0.25);
+ transform: translateX(-3px);
+}
+
+.shot-btn.active {
+ background: var(--accent);
+ color: #201a10;
+ border-color: var(--accent);
+ font-weight: 700;
+}
+
+#hint {
+ position: absolute;
+ bottom: 16px;
+ left: 22px;
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ font-size: 0.78rem;
+ color: rgba(244, 239, 230, 0.72);
+ z-index: 5;
+}
+
+#hint label {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ cursor: pointer;
+}
+
+@media (max-width: 620px) {
+ #shot-bar {
+ top: auto;
+ bottom: 58px;
+ right: 12px;
+ left: 12px;
+ transform: none;
+ flex-direction: row;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+ .shot-btn {
+ min-width: 0;
+ flex: 1 1 30%;
+ text-align: center;
+ }
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..05f7b17
--- /dev/null
+++ b/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Reigen Arataka — Three.js
+
+
+
+
+
+
+
+
+
+
diff --git a/js/camera-shots.js b/js/camera-shots.js
new file mode 100644
index 0000000..d4b794e
--- /dev/null
+++ b/js/camera-shots.js
@@ -0,0 +1,61 @@
+// Named camera shots for framing Reigen. Each shot defines where the camera
+// sits and where it looks. main.js tweens between them when a button is pressed.
+import * as THREE from "three";
+
+/**
+ * @typedef {Object} Shot
+ * @property {string} id
+ * @property {string} label
+ * @property {THREE.Vector3} position - camera position
+ * @property {THREE.Vector3} target - orbit/look-at target
+ */
+
+/** @type {Shot[]} */
+export const SHOTS = [
+ {
+ id: "front",
+ label: "① Front",
+ position: new THREE.Vector3(0, 0.2, 6.2),
+ target: new THREE.Vector3(0, 0, 0),
+ },
+ {
+ id: "three-quarter",
+ label: "② 3/4 View",
+ position: new THREE.Vector3(3.8, 0.6, 4.8),
+ target: new THREE.Vector3(0, 0, 0),
+ },
+ {
+ id: "profile",
+ label: "③ Side Profile",
+ position: new THREE.Vector3(6.0, 0.1, 0.4),
+ target: new THREE.Vector3(0, -0.1, 0),
+ },
+ {
+ id: "face-closeup",
+ label: "④ Face Close-up",
+ position: new THREE.Vector3(0, 0.25, 3.2),
+ target: new THREE.Vector3(0, 0.15, 0.4),
+ },
+ {
+ id: "eyes",
+ label: "⑤ The Stare",
+ position: new THREE.Vector3(1.1, 0.3, 2.6),
+ target: new THREE.Vector3(0, 0.2, 0.6),
+ },
+ {
+ id: "hair-top",
+ label: "⑥ Hair / Top",
+ position: new THREE.Vector3(0.2, 4.6, 3.2),
+ target: new THREE.Vector3(0, 0.7, 0),
+ },
+ {
+ id: "low-hero",
+ label: "⑦ Low Hero",
+ position: new THREE.Vector3(-2.6, -2.2, 5.0),
+ target: new THREE.Vector3(0, 0.1, 0),
+ },
+];
+
+export function getShot(id) {
+ return SHOTS.find((s) => s.id === id) ?? SHOTS[0];
+}
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..795091d
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,143 @@
+// Scene bootstrap: renderer, lights, Reigen, OrbitControls and shot switching.
+import * as THREE from "three";
+import { OrbitControls } from "three/addons/controls/OrbitControls.js";
+import { createReigen } from "./reigen-character.js";
+import { SHOTS, getShot } from "./camera-shots.js";
+
+const canvas = document.getElementById("scene");
+
+// --- Renderer ---------------------------------------------------------------
+const renderer = new THREE.WebGLRenderer({
+ canvas,
+ antialias: true,
+});
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.setSize(window.innerWidth, window.innerHeight);
+renderer.shadowMap.enabled = true;
+renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+
+// --- Scene ------------------------------------------------------------------
+const scene = new THREE.Scene();
+scene.background = new THREE.Color(0x14141a);
+scene.fog = new THREE.Fog(0x14141a, 10, 22);
+
+// --- Camera -----------------------------------------------------------------
+const camera = new THREE.PerspectiveCamera(
+ 42,
+ window.innerWidth / window.innerHeight,
+ 0.1,
+ 100
+);
+camera.position.copy(SHOTS[0].position);
+
+// --- Lighting: warm key + cool rim for that anime portrait feel -------------
+const ambient = new THREE.AmbientLight(0xffffff, 0.55);
+scene.add(ambient);
+
+const key = new THREE.DirectionalLight(0xfff2d6, 1.5);
+key.position.set(4, 6, 6);
+key.castShadow = true;
+key.shadow.mapSize.set(1024, 1024);
+scene.add(key);
+
+const rim = new THREE.DirectionalLight(0x6fa8ff, 0.8);
+rim.position.set(-5, 2, -4);
+scene.add(rim);
+
+const fill = new THREE.PointLight(0xffd9a0, 0.6, 30);
+fill.position.set(-3, -2, 4);
+scene.add(fill);
+
+// --- Floor: subtle disc so shadows have somewhere to land -------------------
+const floor = new THREE.Mesh(
+ new THREE.CircleGeometry(9, 64),
+ new THREE.MeshStandardMaterial({ color: 0x1d1d26, roughness: 1 })
+);
+floor.rotation.x = -Math.PI / 2;
+floor.position.y = -3.4;
+floor.receiveShadow = true;
+scene.add(floor);
+
+// --- Reigen -----------------------------------------------------------------
+const reigen = createReigen();
+scene.add(reigen);
+
+// --- Controls ---------------------------------------------------------------
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.08;
+controls.minDistance = 2;
+controls.maxDistance = 14;
+controls.target.copy(SHOTS[0].target);
+
+// --- Shot switching with a smooth tween -------------------------------------
+let tween = null; // { fromPos, toPos, fromTarget, toTarget, t }
+
+function goToShot(id) {
+ const shot = getShot(id);
+ tween = {
+ fromPos: camera.position.clone(),
+ toPos: shot.position.clone(),
+ fromTarget: controls.target.clone(),
+ toTarget: shot.target.clone(),
+ t: 0,
+ };
+ document.querySelectorAll(".shot-btn").forEach((b) =>
+ b.classList.toggle("active", b.dataset.shot === id)
+ );
+}
+
+function easeInOut(t) {
+ return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
+}
+
+// --- Build shot buttons -----------------------------------------------------
+const shotBar = document.getElementById("shot-bar");
+SHOTS.forEach((shot, i) => {
+ const btn = document.createElement("button");
+ btn.className = "shot-btn" + (i === 0 ? " active" : "");
+ btn.textContent = shot.label;
+ btn.dataset.shot = shot.id;
+ btn.addEventListener("click", () => goToShot(shot.id));
+ shotBar.appendChild(btn);
+});
+
+// --- Auto-rotate toggle -----------------------------------------------------
+const autoRotateEl = document.getElementById("auto-rotate");
+autoRotateEl.addEventListener("change", () => {
+ controls.autoRotate = autoRotateEl.checked;
+ controls.autoRotateSpeed = 1.4;
+});
+
+// --- Resize -----------------------------------------------------------------
+window.addEventListener("resize", () => {
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize(window.innerWidth, window.innerHeight);
+});
+
+// --- Render loop ------------------------------------------------------------
+const clock = new THREE.Clock();
+
+function animate() {
+ requestAnimationFrame(animate);
+ const dt = clock.getDelta();
+
+ if (tween) {
+ tween.t = Math.min(1, tween.t + dt / 1.1); // ~1.1s transition
+ const e = easeInOut(tween.t);
+ camera.position.lerpVectors(tween.fromPos, tween.toPos, e);
+ controls.target.lerpVectors(tween.fromTarget, tween.toTarget, e);
+ if (tween.t >= 1) tween = null;
+ }
+
+ // Gentle idle breathing so the portrait feels alive when still.
+ const idle = Math.sin(clock.elapsedTime * 1.1) * 0.015;
+ reigen.position.y = idle;
+ reigen.rotation.y = Math.sin(clock.elapsedTime * 0.4) * 0.04;
+
+ controls.update();
+ renderer.render(scene, camera);
+}
+
+animate();
diff --git a/js/reigen-character.js b/js/reigen-character.js
new file mode 100644
index 0000000..38a104b
--- /dev/null
+++ b/js/reigen-character.js
@@ -0,0 +1,294 @@
+// Procedural Reigen Arataka (Mob Psycho 100).
+// Builds his head, face and slicked-back parted hair from Three.js primitives.
+// Everything is returned inside a single THREE.Group so shots can frame it.
+
+import * as THREE from "three";
+
+// --- Palette matching Reigen's anime look -----------------------------------
+const COLORS = {
+ skin: 0xf0c9a4,
+ skinShadow: 0xd9a97f,
+ hair: 0xb98a4a, // light gingery brown, slicked back
+ hairDark: 0x8a6330,
+ eyeWhite: 0xf7f4ee,
+ iris: 0x5a4632, // narrow, unimpressed brown eyes
+ brow: 0x8a6330,
+ mouth: 0x8a4b45,
+ suit: 0x2c2c34,
+ shirt: 0xf4f1ea,
+ tie: 0x9a2f2f,
+};
+
+function mat(color, opts = {}) {
+ return new THREE.MeshStandardMaterial({
+ color,
+ roughness: opts.roughness ?? 0.75,
+ metalness: opts.metalness ?? 0.05,
+ ...opts,
+ });
+}
+
+// Build one eye assembly (white + iris + upper lid) facing +Z.
+// Reigen's signature is half-lidded, narrow eyes, so the lid covers the top.
+function buildEye(side) {
+ const eye = new THREE.Group();
+ const dir = side === "left" ? 1 : -1;
+
+ const white = new THREE.Mesh(
+ new THREE.SphereGeometry(0.16, 24, 24),
+ mat(COLORS.eyeWhite, { roughness: 0.35 })
+ );
+ white.scale.set(1.35, 0.85, 0.6);
+ eye.add(white);
+
+ const iris = new THREE.Mesh(
+ new THREE.SphereGeometry(0.075, 20, 20),
+ mat(COLORS.iris, { roughness: 0.3 })
+ );
+ iris.position.set(0.02 * dir, -0.01, 0.11);
+ iris.scale.set(1, 1.15, 0.5);
+ eye.add(iris);
+
+ const pupil = new THREE.Mesh(
+ new THREE.SphereGeometry(0.032, 16, 16),
+ mat(0x161210, { roughness: 0.2 })
+ );
+ pupil.position.set(0.02 * dir, -0.01, 0.15);
+ pupil.scale.set(1, 1.2, 0.4);
+ eye.add(pupil);
+
+ // Heavy upper lid — the "done with your nonsense" Reigen stare.
+ const lid = new THREE.Mesh(
+ new THREE.SphereGeometry(0.19, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.55),
+ mat(COLORS.skin)
+ );
+ lid.scale.set(1.35, 0.9, 0.7);
+ lid.position.set(0, 0.05, 0.02);
+ lid.rotation.z = -0.12 * dir;
+ eye.add(lid);
+
+ return eye;
+}
+
+// A gently curved eyebrow made from a thin, slightly rotated box.
+function buildBrow(side) {
+ const dir = side === "left" ? 1 : -1;
+ const brow = new THREE.Mesh(
+ new THREE.BoxGeometry(0.34, 0.045, 0.08),
+ mat(COLORS.brow, { roughness: 0.6 })
+ );
+ brow.rotation.z = 0.14 * dir; // slight confident arch
+ return brow;
+}
+
+// Slicked-back, side-parted hair. Reigen keeps it tidy and swept up/back.
+function buildHair() {
+ const hair = new THREE.Group();
+ const hairMat = mat(COLORS.hair, { roughness: 0.55 });
+ const hairDarkMat = mat(COLORS.hairDark, { roughness: 0.6 });
+
+ // Main dome cap covering the crown and back of the head.
+ const cap = new THREE.Mesh(
+ new THREE.SphereGeometry(1.06, 40, 40, 0, Math.PI * 2, 0, Math.PI * 0.62),
+ hairMat
+ );
+ cap.scale.set(1.02, 1.05, 1.06);
+ cap.position.y = 0.08;
+ hair.add(cap);
+
+ // Back-of-head volume so the crown isn't flat.
+ const back = new THREE.Mesh(
+ new THREE.SphereGeometry(1.0, 32, 32, 0, Math.PI * 2, Math.PI * 0.3, Math.PI * 0.55),
+ hairMat
+ );
+ back.position.set(0, 0.02, -0.18);
+ back.scale.set(1.0, 1.0, 1.05);
+ hair.add(back);
+
+ // Swept-back strands rendered as slim, tapered boxes fanning from the part.
+ // The part sits slightly to his left; strands sweep up and toward the back.
+ const strandGeo = new THREE.BoxGeometry(0.14, 0.9, 0.1);
+ const partX = 0.18;
+ for (let i = 0; i < 9; i++) {
+ const t = i / 8; // 0..1 across the forehead
+ const strand = new THREE.Mesh(strandGeo, i % 2 ? hairDarkMat : hairMat);
+ const x = partX + (t - 0.5) * 1.7;
+ strand.position.set(x, 0.86, 0.72 - Math.abs(x) * 0.25);
+ // Sweep back: tilt tops backward, fan outward from the part.
+ strand.rotation.x = -0.62;
+ strand.rotation.z = (x - partX) * 0.32;
+ strand.scale.y = 1 - Math.abs(x) * 0.12;
+ hair.add(strand);
+ }
+
+ // A couple of short front tufts near the part for that neat framed forehead.
+ for (let i = 0; i < 3; i++) {
+ const tuft = new THREE.Mesh(
+ new THREE.ConeGeometry(0.11, 0.5, 8),
+ hairMat
+ );
+ tuft.position.set(partX - 0.05 + i * 0.16, 0.98, 0.62);
+ tuft.rotation.x = -0.5;
+ tuft.rotation.z = -0.2 + i * 0.15;
+ hair.add(tuft);
+ }
+
+ // Sideburns / temple hair edges.
+ [-1, 1].forEach((s) => {
+ const side = new THREE.Mesh(
+ new THREE.BoxGeometry(0.12, 0.55, 0.5),
+ hairMat
+ );
+ side.position.set(0.95 * s, 0.25, 0.05);
+ side.rotation.z = 0.1 * s;
+ hair.add(side);
+ });
+
+ return hair;
+}
+
+// Suit shoulders + shirt + red tie so head shots read as a portrait.
+function buildBust() {
+ const bust = new THREE.Group();
+
+ const shoulders = new THREE.Mesh(
+ new THREE.CylinderGeometry(1.15, 1.55, 0.9, 32, 1, true),
+ mat(COLORS.suit, { roughness: 0.85, side: THREE.DoubleSide })
+ );
+ shoulders.position.y = -2.05;
+ bust.add(shoulders);
+
+ const chest = new THREE.Mesh(
+ new THREE.SphereGeometry(1.3, 24, 24, 0, Math.PI * 2, 0, Math.PI * 0.5),
+ mat(COLORS.suit, { roughness: 0.85 })
+ );
+ chest.position.y = -2.5;
+ chest.scale.set(1.2, 0.7, 0.9);
+ bust.add(chest);
+
+ // Shirt V + collar.
+ const shirt = new THREE.Mesh(
+ new THREE.ConeGeometry(0.5, 0.9, 3),
+ mat(COLORS.shirt, { roughness: 0.6 })
+ );
+ shirt.rotation.y = Math.PI / 4;
+ shirt.position.set(0, -1.85, 0.55);
+ bust.add(shirt);
+
+ const tie = new THREE.Mesh(
+ new THREE.ConeGeometry(0.13, 0.8, 4),
+ mat(COLORS.tie, { roughness: 0.5 })
+ );
+ tie.rotation.y = Math.PI / 4;
+ tie.position.set(0, -2.0, 0.62);
+ bust.add(tie);
+
+ const knot = new THREE.Mesh(
+ new THREE.BoxGeometry(0.18, 0.18, 0.12),
+ mat(COLORS.tie, { roughness: 0.5 })
+ );
+ knot.rotation.z = Math.PI / 4;
+ knot.position.set(0, -1.6, 0.6);
+ bust.add(knot);
+
+ return bust;
+}
+
+/**
+ * Build the whole Reigen figure.
+ * @returns {THREE.Group} centered roughly on the face, +Z is forward.
+ */
+export function createReigen() {
+ const reigen = new THREE.Group();
+ reigen.name = "Reigen";
+
+ // --- Head: a softened, slightly long face shape ---------------------------
+ const head = new THREE.Mesh(
+ new THREE.SphereGeometry(1.0, 48, 48),
+ mat(COLORS.skin, { roughness: 0.68 })
+ );
+ head.scale.set(0.92, 1.08, 0.95);
+ head.castShadow = true;
+ reigen.add(head);
+
+ // Jaw / chin taper for a more masculine, defined lower face.
+ const jaw = new THREE.Mesh(
+ new THREE.SphereGeometry(0.7, 32, 32),
+ mat(COLORS.skin, { roughness: 0.68 })
+ );
+ jaw.position.set(0, -0.62, 0.06);
+ jaw.scale.set(0.95, 0.85, 0.9);
+ reigen.add(jaw);
+
+ // --- Nose ---------------------------------------------------------------
+ const nose = new THREE.Mesh(
+ new THREE.ConeGeometry(0.12, 0.42, 12),
+ mat(COLORS.skin, { roughness: 0.7 })
+ );
+ nose.rotation.x = Math.PI / 2.05;
+ nose.position.set(0, -0.12, 0.96);
+ reigen.add(nose);
+
+ // --- Eyes ---------------------------------------------------------------
+ const eyeY = 0.14;
+ const eyeZ = 0.82;
+ const eyeX = 0.38;
+
+ const leftEye = buildEye("left");
+ leftEye.position.set(eyeX, eyeY, eyeZ);
+ leftEye.rotation.y = -0.28;
+ reigen.add(leftEye);
+
+ const rightEye = buildEye("right");
+ rightEye.position.set(-eyeX, eyeY, eyeZ);
+ rightEye.rotation.y = 0.28;
+ reigen.add(rightEye);
+
+ // --- Brows --------------------------------------------------------------
+ const leftBrow = buildBrow("left");
+ leftBrow.position.set(eyeX, eyeY + 0.32, eyeZ + 0.12);
+ leftBrow.rotation.y = -0.28;
+ reigen.add(leftBrow);
+
+ const rightBrow = buildBrow("right");
+ rightBrow.position.set(-eyeX, eyeY + 0.32, eyeZ + 0.12);
+ rightBrow.rotation.y = 0.28;
+ reigen.add(rightBrow);
+
+ // --- Mouth: a flat, slightly smug line ----------------------------------
+ const mouth = new THREE.Mesh(
+ new THREE.BoxGeometry(0.4, 0.04, 0.06),
+ mat(COLORS.mouth, { roughness: 0.5 })
+ );
+ mouth.position.set(0, -0.58, 0.86);
+ mouth.rotation.z = -0.04; // faint confident smirk
+ reigen.add(mouth);
+
+ // Subtle cheek/nasolabial hint via small darker spheres.
+ [-1, 1].forEach((s) => {
+ const cheek = new THREE.Mesh(
+ new THREE.SphereGeometry(0.16, 16, 16),
+ mat(COLORS.skinShadow, { roughness: 0.8, transparent: true, opacity: 0.35 })
+ );
+ cheek.position.set(0.42 * s, -0.3, 0.82);
+ cheek.scale.set(1, 1.3, 0.4);
+ reigen.add(cheek);
+ });
+
+ // --- Ears ---------------------------------------------------------------
+ [-1, 1].forEach((s) => {
+ const ear = new THREE.Mesh(
+ new THREE.SphereGeometry(0.22, 16, 16),
+ mat(COLORS.skin, { roughness: 0.7 })
+ );
+ ear.position.set(0.92 * s, -0.05, 0.02);
+ ear.scale.set(0.45, 0.9, 0.6);
+ reigen.add(ear);
+ });
+
+ // --- Hair + bust --------------------------------------------------------
+ reigen.add(buildHair());
+ reigen.add(buildBust());
+
+ return reigen;
+}