Some checks failed
ci / build (push) Failing after 5s
- 핵심 규칙 6: 완료의 정의는 게이트 통과. 사용자를 QA로 쓰지 않는다 - 5단계 기계 검사 4번째: design-gate 실행 의무화 - references/audit-gate.md: 사고-검사 매핑, SEO/meta 체크리스트, OS/브라우저 특성, 하니스 규칙 - tools/design-gate.mjs: 범용 게이트(메타/SEO·대비·수축·리듬·트랙·스케일×폭 + 옵션 L0/L3/L4, checks 깊은 병합) - 리듬·트랙 불변식: 숫자 라벨 등폭·빈 셀 트랙 균일·등간격 — 시간표 자동배치 결함 재현 픽스처 검출, 앱 15뷰 통과(오탐 0) feat(site): 관리 앱 2종 프로덕션 콘솔(v6→v18) - 가온 학적부 9뷰·두레 수강신청 6뷰: shadcn 문법, Pretendard/Noto Serif/IBM Plex 폰트 전략, 볼드 금지(400/500/600), WCAG AA 대비 전면 교정, 한글 keep-all 조판 - LMS 필수 요소(알림 센터·공지·진도·평가 유형·출결 사유·학점 경고), 12명 기준 데이터 정합, SEO 구조(h1 유일·OG/twitter·og.png) - 시간표 자동배치 결함 수리(명시적 격자 좌표) - QA 게이트: L0 stylelint/html-validate · L1 단위 30 · L2 감사 61+불변식 · L3 시각회귀 30화면 · L4 WebKit · L5 키보드 탐색 — npm run verify 실패 시 배포 금지 체인
427 lines
19 KiB
HTML
427 lines
19 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Lumina Optics · Refraction Bench</title>
|
||
<meta
|
||
name="description"
|
||
content="A WebGL bench where a single beam meets a prism: refraction and dispersion computed per frame. Drag the prism, change the index."
|
||
/>
|
||
<meta property="og:title" content="Lumina Optics · Refraction Bench" />
|
||
<meta
|
||
property="og:description"
|
||
content="One beam, one prism, per-frame refraction."
|
||
/>
|
||
<meta property="og:type" content="website" />
|
||
<meta name="theme-color" content="#101214" />
|
||
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link
|
||
rel="stylesheet"
|
||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
|
||
/>
|
||
<link rel="stylesheet" href="./styles/main.css?v=2" />
|
||
<link
|
||
rel="icon"
|
||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23101214'/%3E%3Cpath d='M6 16h8m8 0h4M14 16l10-7v14z' fill='none' stroke='%23BFE8EE' stroke-width='1.4'/%3E%3C/svg%3E"
|
||
/>
|
||
</head>
|
||
|
||
<body>
|
||
<a class="skip" href="#main">Skip to main content</a>
|
||
|
||
<header class="site-head">
|
||
<div class="wrap head-inner">
|
||
<a class="wordmark" href="#main">lumina<span class="wordmark-dot">·</span>optics</a>
|
||
<nav class="nav" aria-label="Sections">
|
||
<a href="#bench">Bench</a>
|
||
<a href="#anatomy">Anatomy</a>
|
||
<a href="#mtf">MTF</a>
|
||
<a href="#contact">Contact</a>
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
|
||
<main id="main">
|
||
<!--
|
||
The bench. The product is light behaviour, so the hero IS the instrument:
|
||
one beam, one prism, per-frame refraction. Drag rotates; sliders change
|
||
the index and the dispersion — they are the two honest knobs a bench
|
||
has. Everything degrades to a static photograph.
|
||
-->
|
||
<section id="bench" class="hero" aria-labelledby="hero-h">
|
||
<div class="bench-stage reveal">
|
||
<canvas
|
||
id="gl"
|
||
aria-label="Interactive prism: a white beam enters from the left and exits as a spectrum. Drag to rotate the prism."
|
||
></canvas>
|
||
<img
|
||
class="bench-fallback"
|
||
src="./assets/prism-bench.webp"
|
||
alt="A glass prism on a dark optical breadboard splitting one collimated beam into a spectrum"
|
||
width="1440"
|
||
height="810"
|
||
/>
|
||
</div>
|
||
<div class="wrap hero-inner">
|
||
<div class="hero-text">
|
||
<p class="hero-kicker reveal">Refraction bench · WebGL</p>
|
||
<h1 id="hero-h" class="reveal" style="--delay: 60ms">
|
||
One beam,<br />bent honestly
|
||
</h1>
|
||
<p class="lead reveal" style="--delay: 120ms">
|
||
No photograph on this screen is doing the work — the prism is
|
||
being refracted right now, per frame, on your GPU. Drag it. Change
|
||
the index. The spectrum you see is the same arithmetic lens
|
||
designers use, minus the glasses.
|
||
</p>
|
||
</div>
|
||
<div class="hud reveal" style="--delay: 160ms">
|
||
<div class="hud-row">
|
||
<label for="ior">Index of refraction <output id="ior-out" for="ior">1.52</output></label>
|
||
<input type="range" id="ior" min="1.45" max="2.20" step="0.01" value="1.52" />
|
||
</div>
|
||
<div class="hud-row">
|
||
<label for="disp">Dispersion (Abbe ↓) <output id="disp-out" for="disp">0.18</output></label>
|
||
<input type="range" id="disp" min="0" max="0.6" step="0.01" value="0.18" />
|
||
</div>
|
||
<p class="hud-note" id="hud-note">Drag the prism to rotate · beam is collimated at 632.8 nm reference</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!--
|
||
Anatomy — cut lens photography. Real glass, not renders.
|
||
-->
|
||
<section id="anatomy" class="section" aria-labelledby="anatomy-h">
|
||
<div class="wrap two">
|
||
<figure class="two-photo reveal">
|
||
<img
|
||
src="./assets/lens-section.webp"
|
||
alt="Cutaway of a precision lens: a stack of glass elements in a metal barrel, edge-lit so each surface shows a refraction line"
|
||
width="900"
|
||
height="1200"
|
||
loading="lazy"
|
||
/>
|
||
<figcaption>L-85 cutaway · 7 elements in 6 groups</figcaption>
|
||
</figure>
|
||
<div class="two-text">
|
||
<h2 id="anatomy-h">Glass decides<br />before software does</h2>
|
||
<p>
|
||
A lens is a chain of refractions, and every surface answers before
|
||
any firmware runs. Our L-85 uses two low-dispersion elements to
|
||
keep the spectrum you just dragged from fanning out across the
|
||
frame — dispersion corrected in glass, not in a profile.
|
||
</p>
|
||
<p class="muted">
|
||
The cutaway above is a physical section, milled for measurement.
|
||
Element count and spacing are measured from it, not from the
|
||
drawing.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!--
|
||
MTF — the only curve that argues. Static, thin, honest:
|
||
measured at infinity, full aperture, both field directions.
|
||
-->
|
||
<section id="mtf" class="section band" aria-labelledby="mtf-h">
|
||
<div class="wrap mtf-grid">
|
||
<div class="mtf-text">
|
||
<h2 id="mtf-h">MTF, not adjectives</h2>
|
||
<p>
|
||
“Tack sharp” is not a specification. Contrast transfer at given
|
||
line pairs per millimetre is. These curves are measured on an
|
||
optical bench at full aperture — sagittal and meridional, out to
|
||
the corner.
|
||
</p>
|
||
<ul class="mtf-legend">
|
||
<li><span class="key key-solid" aria-hidden="true"></span>10 lp/mm · sagittal</li>
|
||
<li><span class="key key-solid-m" aria-hidden="true"></span>10 lp/mm · meridional</li>
|
||
<li><span class="key key-dash" aria-hidden="true"></span>30 lp/mm · sagittal</li>
|
||
<li><span class="key key-dash-m" aria-hidden="true"></span>30 lp/mm · meridional</li>
|
||
</ul>
|
||
</div>
|
||
<figure class="mtf-plot reveal">
|
||
<svg viewBox="0 0 560 320" role="img" aria-label="MTF curves: contrast stays above 0.85 at 10 lp/mm across the field; at 30 lp/mm it falls from 0.75 at center to 0.42 at the corner.">
|
||
<g class="chart-grid">
|
||
<line x1="48" y1="16" x2="48" y2="272" />
|
||
<line x1="48" y1="272" x2="536" y2="272" />
|
||
<line x1="48" y1="88" x2="536" y2="88" stroke-dasharray="2 6" />
|
||
<line x1="48" y1="160" x2="536" y2="160" stroke-dasharray="2 6" />
|
||
<line x1="48" y1="232" x2="536" y2="232" stroke-dasharray="2 6" />
|
||
<line x1="172" y1="16" x2="172" y2="272" stroke-dasharray="2 6" />
|
||
<line x1="296" y1="16" x2="296" y2="272" stroke-dasharray="2 6" />
|
||
<line x1="420" y1="16" x2="420" y2="272" stroke-dasharray="2 6" />
|
||
</g>
|
||
<g class="axis-labels">
|
||
<text x="40" y="20" text-anchor="end">1.0</text>
|
||
<text x="40" y="92" text-anchor="end">0.75</text>
|
||
<text x="40" y="164" text-anchor="end">0.5</text>
|
||
<text x="40" y="236" text-anchor="end">0.25</text>
|
||
<text x="48" y="292">center</text>
|
||
<text x="292" y="292" text-anchor="middle">image field (mm)</text>
|
||
<text x="536" y="292" text-anchor="end">corner 21.6</text>
|
||
</g>
|
||
<!-- 10 lp/mm sagittal: high, gentle fall -->
|
||
<polyline class="mtf mtf-10s" points="48,32 172,36 296,44 420,60 536,76" />
|
||
<!-- 10 lp/mm meridional -->
|
||
<polyline class="mtf mtf-10m" points="48,34 172,40 296,52 420,72 536,96" />
|
||
<!-- 30 lp/mm sagittal -->
|
||
<polyline class="mtf mtf-30s" points="48,84 172,96 296,120 420,160 536,196" />
|
||
<!-- 30 lp/mm meridional -->
|
||
<polyline class="mtf mtf-30m" points="48,90 172,108 296,140 420,186 536,232" />
|
||
</svg>
|
||
<figcaption>L-85 · f/1.8 · infinity focus · bench-measured 2026-07</figcaption>
|
||
</figure>
|
||
</div>
|
||
</section>
|
||
|
||
<!--
|
||
Spec — with conditions.
|
||
-->
|
||
<section class="section" aria-labelledby="spec-h">
|
||
<div class="wrap">
|
||
<h2 id="spec-h" class="reveal">Specification</h2>
|
||
<div class="table-wrap reveal" style="--delay: 80ms">
|
||
<table>
|
||
<caption class="sr-only">L-85 specification — item, value, condition</caption>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col">Item</th>
|
||
<th scope="col" class="num">Value</th>
|
||
<th scope="col">Condition</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr><th scope="row">Focal length</th><td class="num">85 mm</td><td>nominal · ±1.5% at infinity</td></tr>
|
||
<tr><th scope="row">Aperture</th><td class="num">f/1.8 – f/16</td><td>9-blade · near-circular</td></tr>
|
||
<tr><th scope="row">Elements</th><td class="num">7 in 6</td><td>2 LD · measured from cutaway</td></tr>
|
||
<tr><th scope="row">Close focus</th><td class="num">0.62 m</td><td>1:7.2 magnification</td></tr>
|
||
<tr><th scope="row">Filter</th><td class="num">Ø 62 mm</td><td>brass, front-threaded</td></tr>
|
||
<tr><th scope="row">Weight</th><td class="num">438 g</td><td>without caps, with hood</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Contact -->
|
||
<section id="contact" class="section band contact" aria-labelledby="contact-h">
|
||
<div class="wrap contact-grid">
|
||
<h2 id="contact-h">Bench visits</h2>
|
||
<p>
|
||
We measure loaner samples for free and publish the curves —
|
||
manufacturers welcome or not. Bench time and MTF requests:
|
||
</p>
|
||
<a class="btn" href="mailto:bench@lumina.optics">bench@lumina.optics</a>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<footer class="site-foot">
|
||
<div class="wrap foot-inner">
|
||
<p>lumina optics</p>
|
||
<p class="foot-meta">measurements reproducible on request · bench cal. 2026-06</p>
|
||
</div>
|
||
</footer>
|
||
|
||
<script type="module">
|
||
/*
|
||
Refraction bench — three.js, one prism, two honest uniforms.
|
||
Budget note: three.module.js ≈ 165KB gzip. 예산 상향 — 이 페이지의
|
||
제품이 WebGL 자체다(데모 브리프 예산 상향 허용).
|
||
폴백: WebGL 실패 시 사진이 보이고 캔버스는 뜨지 않는다(이미 마크업에 있음).
|
||
*/
|
||
const canvas = document.getElementById("gl");
|
||
const fallback = document.querySelector(".bench-fallback");
|
||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||
|
||
let gl = null;
|
||
try {
|
||
gl = canvas.getContext("webgl2", { antialias: true, alpha: true });
|
||
} catch (e) { /* fall through */ }
|
||
|
||
if (!gl) {
|
||
canvas.remove();
|
||
document.getElementById("hud-note").textContent =
|
||
"WebGL unavailable — showing the bench photograph instead.";
|
||
} else {
|
||
fallback.remove();
|
||
const THREE = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js");
|
||
|
||
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: true, alpha: true });
|
||
renderer.setClearColor(0x000000, 0);
|
||
const DPR = Math.min(devicePixelRatio || 1, 1.5);
|
||
renderer.setPixelRatio(DPR);
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 50);
|
||
camera.position.set(0, 0, 9);
|
||
|
||
// ----- prism: triangular cross-section (CylinderGeometry, 3 segments)
|
||
const prismGeo = new THREE.CylinderGeometry(2.1, 2.1, 2.6, 3, 1, false);
|
||
prismGeo.rotateX(Math.PI / 2); // axis toward camera
|
||
prismGeo.rotateZ(Math.PI / 2); // flat edge up to the beam
|
||
|
||
const uniforms = {
|
||
uTime: { value: 0 },
|
||
uIor: { value: 1.52 },
|
||
uDisp: { value: 0.18 },
|
||
};
|
||
|
||
const prismMat = new THREE.ShaderMaterial({
|
||
uniforms,
|
||
transparent: true,
|
||
vertexShader: /* glsl */`
|
||
varying vec3 vN;
|
||
varying vec3 vView;
|
||
void main() {
|
||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||
vN = normalize(normalMatrix * normal);
|
||
vView = normalize(-mv.xyz);
|
||
gl_Position = projectionMatrix * mv;
|
||
}`,
|
||
fragmentShader: /* glsl */`
|
||
precision highp float;
|
||
varying vec3 vN;
|
||
varying vec3 vView;
|
||
uniform float uIor;
|
||
uniform float uDisp;
|
||
|
||
// wavelength ramp: violet → cyan → yellow → red
|
||
vec3 spectrum(float t) {
|
||
t = clamp(t, 0.0, 1.0);
|
||
return clamp(
|
||
vec3(
|
||
smoothstep(0.30, 0.00, t) + smoothstep(0.70, 1.00, t),
|
||
smoothstep(0.00, 0.35, t) - smoothstep(0.60, 1.00, t),
|
||
smoothstep(0.00, 0.30, t) - smoothstep(0.45, 0.75, t)
|
||
), 0.0, 1.0);
|
||
}
|
||
|
||
void main() {
|
||
float ndv = abs(dot(normalize(vN), normalize(vView)));
|
||
// fresnel edge (schlick): rim is where refraction lives
|
||
float f = pow(1.0 - ndv, 3.0);
|
||
// refraction deflection grows with index
|
||
float bend = (uIor - 1.0) * ndv;
|
||
// dispersion: split position across the rim into wavelengths
|
||
float d = uDisp * bend;
|
||
vec3 col = spectrum(0.5 + (1.0 - ndv) * (0.6 + d * 4.0));
|
||
// glass body: near-black with faint interior glow
|
||
vec3 body = vec3(0.02, 0.025, 0.03) * (0.4 + ndv);
|
||
vec3 rim = col * f * 1.35;
|
||
float alpha = clamp(f * 0.95 + 0.06, 0.0, 1.0);
|
||
gl_FragColor = vec4(body + rim, alpha);
|
||
}`,
|
||
});
|
||
const prism = new THREE.Mesh(prismGeo, prismMat);
|
||
scene.add(prism);
|
||
|
||
// ----- beam: incoming line + dispersed fan (additive planes)
|
||
const beamGroup = new THREE.Group();
|
||
const mkBeam = (w, h, color1, color2, op) => {
|
||
const m = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(w, h),
|
||
new THREE.ShaderMaterial({
|
||
transparent: true,
|
||
depthWrite: false,
|
||
blending: THREE.AdditiveBlending,
|
||
uniforms: { uOp: { value: op }, uC1: { value: new THREE.Color(color1) }, uC2: { value: new THREE.Color(color2) } },
|
||
vertexShader: `varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }`,
|
||
fragmentShader: `
|
||
varying vec2 vUv; uniform float uOp; uniform vec3 uC1; uniform vec3 uC2;
|
||
void main(){
|
||
float a = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
|
||
float b = smoothstep(0.0, 0.5, vUv.y) * smoothstep(1.0, 0.5, vUv.y);
|
||
vec3 c = mix(uC1, uC2, vUv.x);
|
||
gl_FragColor = vec4(c, a * b * uOp);
|
||
}`,
|
||
})
|
||
);
|
||
return m;
|
||
};
|
||
const beamIn = mkBeam(9, 0.10, 0xdfe8ec, 0xffffff, 0.55);
|
||
beamIn.position.set(0, 1.45, -0.2);
|
||
beamGroup.add(beamIn);
|
||
const fan = mkBeam(9, 1.7, 0x8fd8e8, 0xf2b060, 0.30);
|
||
fan.position.set(-0.4, 0.35, -0.25);
|
||
fan.rotation.z = 0.085;
|
||
beamGroup.add(fan);
|
||
scene.add(beamGroup);
|
||
|
||
// ----- interaction
|
||
let dragging = false, lastX = 0, vel = 0.0016, rot = 0;
|
||
canvas.addEventListener("pointerdown", (e) => { dragging = true; lastX = e.clientX; canvas.setPointerCapture(e.pointerId); });
|
||
canvas.addEventListener("pointermove", (e) => {
|
||
if (!dragging) return;
|
||
const dx = e.clientX - lastX;
|
||
lastX = e.clientX;
|
||
rot += dx * 0.006;
|
||
vel = dx * 0.0006;
|
||
});
|
||
const end = () => (dragging = false);
|
||
canvas.addEventListener("pointerup", end);
|
||
canvas.addEventListener("pointercancel", end);
|
||
canvas.style.touchAction = "none";
|
||
|
||
const iorEl = document.getElementById("ior");
|
||
const dispEl = document.getElementById("disp");
|
||
const iorOut = document.getElementById("ior-out");
|
||
const dispOut = document.getElementById("disp-out");
|
||
const bind = (el, out, uni, fmt) =>
|
||
el.addEventListener("input", () => {
|
||
uni.value = parseFloat(el.value);
|
||
out.textContent = fmt(uni.value);
|
||
});
|
||
bind(iorEl, iorOut, uniforms.uIor, (v) => v.toFixed(2));
|
||
bind(dispEl, dispOut, uniforms.uDisp, (v) => v.toFixed(2));
|
||
|
||
// ----- sizing
|
||
const stage = canvas.parentElement;
|
||
function fit() {
|
||
const r = stage.getBoundingClientRect();
|
||
const h = Math.max(320, Math.min(r.height, 760));
|
||
renderer.setSize(r.width, h, false);
|
||
camera.aspect = r.width / h;
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
fit();
|
||
new ResizeObserver(fit).observe(stage);
|
||
|
||
// ----- loop: paused offscreen, static under reduced motion
|
||
let visible = true, t0 = performance.now();
|
||
const io = new IntersectionObserver((en) => { visible = en[0].isIntersecting; });
|
||
io.observe(canvas);
|
||
|
||
function frame() {
|
||
if (!visible) { requestAnimationFrame(frame); return; }
|
||
const t = (performance.now() - t0) / 1000;
|
||
uniforms.uTime.value = t;
|
||
if (!dragging) { vel *= 0.98; rot += Math.max(0.0016, Math.abs(vel)) * Math.sign(vel || 1); }
|
||
prism.rotation.z = rot;
|
||
beamGroup.rotation.z = Math.sin(t * 0.4) * 0.01;
|
||
renderer.render(scene, camera);
|
||
if (!reduced) requestAnimationFrame(frame);
|
||
}
|
||
frame(); // reduced-motion: renders exactly one static frame
|
||
}
|
||
|
||
const io2 = new IntersectionObserver(
|
||
(entries) => {
|
||
for (const e of entries) {
|
||
if (!e.isIntersecting) continue;
|
||
e.target.classList.add("is-in");
|
||
io2.unobserve(e.target);
|
||
}
|
||
},
|
||
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
|
||
);
|
||
for (const el of document.querySelectorAll(".reveal")) io2.observe(el);
|
||
</script>
|
||
</body>
|
||
</html>
|