import * as THREE from "three";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";
const MODEL = "https://cdn.jsdelivr.net/gh/KhronosGroup/glTF-Sample-Assets@2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf/Models/Lantern/glTF-Binary/Lantern.glb";
{ id: "shaded", label: "Shaded" },
{ id: "wire", label: "Wire" },
{ id: "points", label: "Points" }
const STYLE_ID = "exp-three-style";
font-family: var(--font-sans);
--three-accent: var(--ink);
font-family: var(--font-mono);
font-size: var(--fs-mono-meta,0.75rem);
text-transform: uppercase;
transition: opacity 0.4s ease;
.tj[data-ready] .tj__loading {
inset-block-start: var(--s-16,1rem);
height: var(--s-40,2.5rem);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md,6px);
box-shadow: var(--shadow-control,0 1px 3px rgba(30,30,30,0.06));
inset-block: var(--s-2,2px);
inset-inline-start: var(--s-2,2px);
width: calc((100% - var(--s-2,2px) * 2) / 3);
border-radius: var(--r-sm,4px);
box-shadow: var(--shadow-seg,0 1px 2px rgba(30,30,30,0.05));
transition: transform 0.4s var(--ease-out-expo,cubic-bezier(0.16,1,0.3,1));
padding-inline: var(--s-12,0.75rem);
font-family: var(--font-mono);
font-size: var(--fs-mono-meta,0.75rem);
text-transform: uppercase;
transition: color 0.3s var(--ease-out-quad,cubic-bezier(0.5,1,0.89,1)),scale 0.15s ease;
.tj__seg[aria-pressed="true"] {
outline: 2px solid var(--ink);
border-radius: var(--r-sm,4px);
@media (prefers-reduced-motion: reduce) {
if (document.getElementById(STYLE_ID)) return;
const el = document.createElement("style");
document.head.appendChild(el);
const cv = document.createElement("canvas");
cv.width = cv.height = s;
const g = cv.getContext("2d");
const key = g.createRadialGradient(s * 0.36, s * 0.3, s * 0.04, s * 0.5, s * 0.5, s * 0.62);
key.addColorStop(0, "#f4f4f4");
key.addColorStop(0.45, "#c9c9c9");
key.addColorStop(0.8, "#8f8f8f");
key.addColorStop(1, "#565656");
const rim = g.createRadialGradient(s * 0.72, s * 0.8, 0, s * 0.72, s * 0.8, s * 0.42);
rim.addColorStop(0, "rgba(255,255,255,0.45)");
rim.addColorStop(1, "rgba(255,255,255,0)");
const tex = new THREE.CanvasTexture(cv);
tex.colorSpace = THREE.SRGBColorSpace;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { spin: 0.9, pointSize: 0.02 };
const ac = new AbortController();
const wrap = document.createElement("div");
wrap.innerHTML = `<p class="tj__loading">Loading model</p>
<div class="tj__modes" role="group" aria-label="Render mode">
<span class="tj__pill" aria-hidden="true"></span>
(m, i) => `<button class="tj__seg" type="button" data-mode="${m.id}" aria-pressed="${i === 0}">${m.label}</button>`
const pill = wrap.querySelector(".tj__pill");
const segs = [...wrap.querySelectorAll(".tj__seg")];
const resolveInk = () => {
const probe = document.createElement("span");
probe.style.cssText = "color:var(--ink);position:absolute;visibility:hidden";
const m = getComputedStyle(probe).color.match(/\d+/g);
if (m) ink = Number(m[0]) << 16 | Number(m[1]) << 8 | Number(m[2]);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setClearColor(0, 0);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
wrap.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
camera.position.set(0, 0.15, FIT * 2.4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enablePan = false;
controls.minDistance = FIT * 1.4;
controls.maxDistance = FIT * 4.5;
controls.minPolarAngle = 0.15;
controls.maxPolarAngle = Math.PI * 0.9;
controls.enableDamping = !reduced;
controls.dampingFactor = 0.08;
controls.autoRotate = !reduced;
controls.autoRotateSpeed = P.spin;
const matcap = makeMatcap();
const shadedMat = new THREE.MeshMatcapMaterial({ matcap });
const wireMat = new THREE.MeshBasicMaterial({
const pointsMat = new THREE.PointsMaterial({ color: ink, size: P.pointSize });
const sampledGeoms = new Set();
const reps = { shaded: null, wire: null, points: null };
const render = () => renderer.render(scene, camera);
const applyMode = (next) => {
Object.keys(reps).forEach((k) => {
if (reps[k]) reps[k].visible = k === next;
segs.forEach((s) => s.setAttribute("aria-pressed", String(s.dataset.mode === next)));
pill.style.transform = `translateX(${MODES.findIndex((m) => m.id === next) * 100}%)`;
const loader = new GLTFLoader();
const model = gltf.scene;
mesh.material = shadedMat;
geoms.add(mesh.geometry);
const box = new THREE.Box3().setFromObject(model);
const size2 = box.getSize(new THREE.Vector3());
model.scale.multiplyScalar(FIT / Math.max(size2.x, size2.y, size2.z));
box.setFromObject(model);
model.position.sub(box.getCenter(new THREE.Vector3()));
const wire = model.clone();
if (o.isMesh) o.material = wireMat;
const points = model.clone();
if (o.isMesh) pMeshes.push(o);
const areas = pMeshes.map((m) => {
m.geometry.computeBoundingBox();
const s = m.geometry.boundingBox.getSize(new THREE.Vector3());
return 2 * (s.x * s.y + s.y * s.z + s.z * s.x) || 1;
const areaSum = areas.reduce((a, b) => a + b, 0) || 1;
const tmp = new THREE.Vector3();
pMeshes.forEach((mesh, i) => {
const n = Math.max(400, Math.round(BUDGET * areas[i] / areaSum));
const sampler = new MeshSurfaceSampler(mesh).build();
const pos = new Float32Array(n * 3);
for (let k = 0; k < n; k++) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const p = new THREE.Points(geo, pointsMat);
p.position.copy(mesh.position);
p.quaternion.copy(mesh.quaternion);
p.scale.copy(mesh.scale);
mesh.parent?.remove(mesh);
scene.add(model, wire, points);
(err) => console.error("[lab] threejs-test model failed", err)
const w = wrap.clientWidth;
const h = wrap.clientHeight;
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(w, h, false);
camera.updateProjectionMatrix();
const ro = new ResizeObserver(size);
const c = accentHex ?? ink;
const themeObs = new MutationObserver(() => {
themeObs.observe(document.documentElement, {
attributeFilter: ["data-theme", "class"]
(s) => s.addEventListener("click", () => applyMode(s.dataset.mode), { signal })
controls.addEventListener("change", render);
raf = requestAnimationFrame(loop);
const set = (key, value) => {
controls.autoRotateSpeed = P.spin;
} else if (key === "pointSize") {
P.pointSize = Number(value);
pointsMat.size = P.pointSize;
} else if (key === "accent") {
accentHex = String(value).startsWith("var(") ? null : String(value);
cancelAnimationFrame(raf);
geoms.forEach((g) => g.dispose());
sampledGeoms.forEach((g) => g.dispose());
renderer.forceContextLoss();
renderer.domElement.remove();
experiment.mount(document.querySelector(".stage"));