# AI 꾸러미 — 단면 자르기 (스텐실 단면 · 원뿔 곡선) — Stencil capping (cross-section cap)
> 평면으로 물체를 잘라 속을 보이고, 잘린 면은 스텐실로 셈해 평면 위에 진짜 평평한 단면(빗금)을 메운다 — 속 빈 관 · 겹친 입체도 정확히.  
> 견본: https://ai-techstudio.web.app/#t/i35

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 단면 자르기 (스텐실 단면 · 원뿔 곡선) — Stencil capping (cross-section cap)

#### 1. 목표
원뿔 · 속 빈 관 · 겹친 입체 (원뿔 곡선)을(를) 움직이는 평면으로 잘라 속을 보여 줘 — 잘린 면은 스텐실로 셈해 평면 위에 빗금 단면을 평평하게 메우고, 여러 물체가 겹쳐도 맞게. 분위기는 밝은 교과서 도해.

#### 2. 핵심 기술 용어
- **Stencil capping (cross-section cap)** — 스텐실로 단면 뚜껑 메우기 — 뒷면 +1 · 앞면 −1 로 「속」을 셈
- **Clipping plane (shader discard)** — 평면 한쪽 조각을 버려 자르기
- **IncrementWrap / DecrementWrap stencil op** — 스텐실 값 더하기 · 빼기 (넘치면 돌아감)
- **NotEqualStencilFunc** — 스텐실 ≠ 0 인 곳에만 그리기

#### 3. 환경
- 플랫폼: three.js r186 (ES 모듈 · TypeScript, `import * as THREE from "three"`), WebGL2, 외부 라이브러리 추가 없이
- 화면: 3D · 브라우저 — PC 와 폰(가로 844×390 · 세로 390×844) 모두, 60fps 목표

#### 4. 조건
- 렌더러는 new THREE.WebGLRenderer({ stencil: true }) — r163 부터 기본값이 false 라 없으면 아무것도 안 메워진다
- 그리는 차례(renderOrder): 스텐실 셈(1) → 단면 판(2) → 실제 물체(3) → 반투명(그 뒤). 셈 재질은 colorWrite · depthWrite · depthTest 모두 false
- 단면 판 재질은 stencilWrite: true · stencilFunc NotEqual · ref 0 · 세 경우 모두 Replace (그린 곳을 0 으로 되돌림)
- 자르기는 셰이더에서 직접(onBeforeCompile 로 discard) — 재질 clippingPlanes 는 compileAsync 가 평면 수를 몰라 첫 장면에 다시 굽는다
- 모든 물체는 닫힌 껍데기(구멍 없는 메시)로. 속 빈 관은 고리 모양 Shape 를 돌출해 안팎 벽 + 위아래 뚜껑을 한 몸으로

#### 5. 완성 기준 (이게 보이면 성공)
- 평면을 기울이면 원뿔 단면이 원 → 타원 → 포물선 → 쌍곡선으로 바뀌고 이름표가 따라 바뀐다
- 속 빈 관 단면은 가운데가 뚫린 고리, 공 + 상자가 겹친 곳은 하나로 이어진 단면으로 빈틈 · 새는 곳 없이 메워진다
- 「BackSide 방식과 나란히」를 켜면 왼쪽(BackSide)은 빗금이 휘고 겹친 곳에 다른 물체 겉면이 비치며, 오른쪽(스텐실)은 곧은 빗금이다
- 폰에서도 60fps, 첫 장면에 멈칫 없음

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 자르기: 셰이더에서 세계 좌표 점 p 가 n·p > c 쪽이면 그 조각을 버린다(discard). 그러면 잘린 자리가 뻥 뚫린다.
- 셈하기: 잘린 물체를 색 없이 두 번 그린다 — 뒷면은 스텐실 +1, 앞면은 −1 (깊이 시험 끔). 화면 한 점에서 남는 값 = 그 점의 평면 위치를 품은 물체 수.
- 메우기: 자르기 평면 위에 큰 판을 놓고 스텐실 ≠ 0 인 곳에만 그린다. 판은 진짜 평면이라 빗금 무늬가 곧게 깔리고 깊이도 맞다.
- 안쪽 면(BackSide)을 칠하는 쉬운 방법은 뚜껑이 평면이 아니라 뒷벽이라, 무늬가 휘고 두 물체가 겹친 곳에서 다른 물체의 겉면이 비쳐 틀린다.
- 원뿔 단면: 꼭짓점에서 내려오는 모선마다 평면과 만나는 점을 이으면 곡선 — 평면 기울기가 모선 기울기(63.4°)보다 작으면 타원, 같으면 포물선, 크면 쌍곡선.

## 핵심 코드 — 셈(뒷면 +1 · 앞면 −1) → 단면 판(≠ 0 에만) → 실제 물체
(발췌: demos/demosStructure.ts i35 · planeClip 을 정리)
```ts
// 세계 좌표 평면으로 자르기: uClip = (n, c), n·p > c 쪽을 버린다
function planeClip(m, u) {
  m.onBeforeCompile = (sh) => {
    sh.uniforms.uClip = u;
    sh.vertexShader = sh.vertexShader
      .replace('#include <common>', '#include <common>\nvarying vec3 vClipW;')
      .replace('#include <project_vertex>', '#include <project_vertex>\nvClipW = (modelMatrix * vec4(transformed, 1.0)).xyz;');
    sh.fragmentShader = sh.fragmentShader
      .replace('#include <common>', '#include <common>\nvarying vec3 vClipW;\nuniform vec4 uClip;')
      .replace('void main() {', 'void main() {\n if (dot(vClipW, uClip.xyz) > uClip.w) discard;');
  };
  m.customProgramCacheKey = () => 'planeclip';
  return m;
}
const keep = { value: new THREE.Vector4(0, 1, 0, -0.25) };
const op = (o) => ({ stencilWrite: true, stencilFunc: THREE.AlwaysStencilFunc, stencilFail: o, stencilZFail: o, stencilZPass: o });
const blind = { colorWrite: false, depthWrite: false, depthTest: false };
const stBack = planeClip(new THREE.MeshBasicMaterial({ side: THREE.BackSide, ...blind, ...op(THREE.IncrementWrapStencilOp) }), keep);
const stFront = planeClip(new THREE.MeshBasicMaterial({ side: THREE.FrontSide, ...blind, ...op(THREE.DecrementWrapStencilOp) }), keep);
for (const geo of geos) {           // 닫힌 메시들 (겹쳐도 됨)
  const a = new THREE.Mesh(geo, stBack); a.renderOrder = 1;
  const b = new THREE.Mesh(geo, stFront); b.renderOrder = 1;
  const real = new THREE.Mesh(geo, planeClip(new THREE.MeshStandardMaterial({ color: 0xffb347 }), keep));
  real.renderOrder = 3;
  scene.add(a, b, real);
}
// 단면 판: 평면 위 큰 판을 스텐실 ≠ 0 에만, 그린 곳은 0 으로
const cap = new THREE.Mesh(new THREE.PlaneGeometry(4, 8), new THREE.MeshBasicMaterial({
  map: hatch, stencilWrite: true, stencilRef: 0, stencilFunc: THREE.NotEqualStencilFunc,
  stencilFail: THREE.ReplaceStencilOp, stencilZFail: THREE.ReplaceStencilOp, stencilZPass: THREE.ReplaceStencilOp,
}));
cap.renderOrder = 2;
// 판 방향: makeBasis(u, z, n) 로 평면에 눕히고 위치는 평면 위의 점 p0
scene.add(cap);
```

## 흔한 실수 · 확인 목록
- [ ] **렌더러에 스텐실이 없으면 단면이 하나도 안 메워진다** — r163 부터 WebGLRenderer 의 stencil 기본값이 false. 견본은 stencil: true 로 만든 전용 렌더러(ownRenderer)에 그려 붙인다.
- [ ] **재질 clippingPlanes 를 쓰면 첫 장면에 1초 넘게 멈춘다** — compileAsync 는 재질별 자르기 평면 수를 모르고 구워, 실제로 그릴 때 프로그램을 다시 만든다. 셰이더에 discard 를 직접 넣으면 미리 굽기가 그대로 통한다.
- [ ] **안쪽 면(BackSide)만 칠하면 겹친 곳 · 무늬가 틀린다** — 뚜껑이 평면이 아니라 뒷벽이라 빗금이 휘고, 겹친 물체의 겉면이 구멍으로 비친다. 스텐실 셈 + 평면 판으로 바꾼다.
- [ ] **대각선 빗금 그림을 바둑판처럼 깔면 이음매마다 끊긴 자국** — 가로 띠 그림을 그리고 texture.rotation 으로 45° 돌린다. 이때 판 UV 를 세계 길이로 맞춰야(가로세로 같은 비율) 각이 비뚤지 않다.
- [ ] **닫히지 않은 모델은 단면이 샌다** — 구멍 · 열린 원기둥(openEnded) · 얇은 판은 셈이 0 이 안 돼 엉뚱한 곳이 칠해진다. 고리는 구멍 뚫린 Shape 를 돌출해 한 몸으로.

## 완성 기준 체크리스트
- [ ] 평면을 기울이면 원뿔 단면이 원 → 타원 → 포물선 → 쌍곡선으로 바뀌고 이름표가 따라 바뀐다
- [ ] 속 빈 관 단면은 가운데가 뚫린 고리, 공 + 상자가 겹친 곳은 하나로 이어진 단면으로 빈틈 · 새는 곳 없이 메워진다
- [ ] 「BackSide 방식과 나란히」를 켜면 왼쪽(BackSide)은 빗금이 휘고 겹친 곳에 다른 물체 겉면이 비치며, 오른쪽(스텐실)은 곧은 빗금이다
- [ ] 폰에서도 60fps, 첫 장면에 멈칫 없음

## 이 기술 정보
- id: `i35` · 분류: 기계 · 구조 › 구조 · 조립 · 3D · 난이도 어려움 · 폰 부담 가벼움 (폰 OK) — 물체마다 색 없는 그리기 2번(앞 · 뒤) + 판 1장. 견본(물체 4개)은 한 장면 0.7ms. 스텐실 버퍼가 있는 렌더러(stencil: true)가 필요하다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i35
- 쓰면 좋을 때: 입체도형 · 원뿔 곡선 같은 수학 단면 체험 / 기계 · 엔진 · 건물 속을 단면도로 보여 줄 때 / 여러 부품이 겹치거나 속이 빈 물체를 정확히 자를 때
- 쓰지 말 때: 껍데기가 닫혀 있지 않은 모델(구멍 · 얇은 판) — 셈이 어긋나 단면이 샌다. 대신 닫힌 모델을 쓰거나 단면을 직접 만든다(three-mesh-bvh 단면 윤곽) / 카메라가 남는 쪽(잘리지 않은 쪽)으로 넘어가는 장면 — 그때는 셈을 반대로 하거나 평면을 카메라 쪽으로 돌린다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i35 견본 항목 — `src/demos/demosStructure.ts:3160`
```ts
  i35: {
    kind: 'dom',
    caption: '평면 하나로 원뿔 · 속 빈 관 · 겹친 입체를 함께 잘라요 — 잘린 면은 스텐실로 평평하게 메움 (원뿔 단면: 원 → 타원 → 포물선 → 쌍곡선)',
    make(box) {
      ownAcquire();
      const { g, fit } = boxCanvas(box);
      const scene = baseScene('#dff0ff', '#ffffff');
      // 자르기 평면: n·p ≤ c 쪽(아래)이 남고 위쪽이 잘려 나간다. 모든 물체 중심이 x = 0 줄에 있어 같은 평면이 셋을 다 지난다
      // three 의 재질 clippingPlanes 는 compileAsync 가 평면 수를 모른 채 구워(전역 상태만 봄) 첫 장면에 1.2초 다시 굽는다 → 셰이더에 직접 넣음
      const keep = { value: new THREE.Vector4(0, 1, 0, 0) };
      const gone = { value: new THREE.Vector4(0, -1, 0, 0) };
      // 단면 무늬 (빗금) — 같은 그림을 스텐실 판 · BackSide 둘 다에 쓴다
      const hc = document.createElement('canvas');
      hc.width = hc.height = 64;
      const hg = hc.getContext('2d')!;
      // 가로 띠를 45° 돌려 깐다 — 대각선을 그리면 칸 이음매에 끊긴 자국이 남는다
      hg.fillStyle = '#ff7aa6';
      hg.fillRect(0, 0, 64, 64);
      hg.fillStyle = '#c8124f';
      hg.fillRect(0, 0, 64, 14);
      const hatch = new THREE.CanvasTexture(hc);
      hatch.colorSpace = THREE.SRGBColorSpace;
      hatch.wrapS = hatch.wrapT = THREE.RepeatWrapping;
      hatch.anisotropy = 4;
      hatch.center.set(0.5, 0.5);
      hatch.rotation = Math.PI / 4;
      const hatchObj = hatch.clone();
      hatchObj.repeat.set(6, 6);
      // 물체: 원뿔 · 속 빈 관(닫힌 고리 돌출) · 겹친 공 + 상자 — 모두 닫힌 껍데기여야 스텐실 셈이 맞는다
      const ring = new THREE.Shape();
      ring.absarc(0, 0, 0.7, 0, TAU, false);
      const hole = new THREE.Path();
      hole.absarc(0, 0, 0.4, 0, TAU, true);
      ring.holes.push(hole);
      const tubeGeo = new THREE.ExtrudeGeometry(ring, { depth: 1.9, bevelEnabled: false, curveSegments: 64 }).rotateX(-Math.PI / 2).translate(0, -1.0, 0);
      const parts: { geo: THREE.BufferGeometry; color: number; pos: [number, number, number]; rotY?: number }[] = [
        { geo: tubeGeo, color: 0x5ab0ff, pos: [0, 0, 2.35] },
        { geo: new THREE.ConeGeometry(1, 2, 96, 1, false), color: 0xffb347, pos: [0, 0, 0] },
        { geo: new THREE.SphereGeometry(0.72, 48, 32), color: 0x4fd1a0, pos: [-0.05, -0.1, -2.15] },
        { geo: new THREE.BoxGeometry(0.95, 0.95, 0.95), color: 0xb07cff, pos: [0.2, -0.45, -2.75], rotY: 0.55 },
      ];
      const stencilMeshes: THREE.Mesh[] = [];
      const backMeshes: THREE.Mesh[] = [];
      // 뒷면 +1 · 앞면 −1 (색 · 깊이 안 씀, 깊이 시험 끔) — 잘린 물체 속을 지나는 화면 점만 0 이 아닌 값이 남는다
      const stBack = planeClip(new THREE.MeshBasicMaterial({ side: THREE.BackSide, colorWrite: false, depthWrite: false, depthTest: false, stencilWrite: true, stencilFunc: THREE.AlwaysStencilFunc, stencilFail: THREE.IncrementWrapStencilOp, stencilZFail: THREE.IncrementWrapStencilOp, stencilZPass: THREE.IncrementWrapStencilOp }), keep);
      const stFront = planeClip(new THREE.MeshBasicMaterial({ side: THREE.FrontSide, colorWrite: false, depthWrite: false, depthTest: false, stencilWrite: true, stencilFunc: THREE.AlwaysStencilFunc, stencilFail: THREE.DecrementWrapStencilOp, stencilZFail: THREE.DecrementWrapStencilOp, stencilZPass: THREE.DecrementWrapStencilOp }), keep);
      // 비교용 옛 방식: 잘린 물체 안쪽 면(BackSide)을 단면 색으로
      const backMat = planeClip(new THREE.MeshBasicMaterial({ map: hatchObj, side: THREE.BackSide }), keep);
      const ghostMat = planeClip(new THREE.MeshStandardMaterial({ color: 0x9fb4d8, transparent: true, opacity: 0.13, depthWrite: false }), gone);
      for (const p of parts) {
        const main = new THREE.Mesh(p.geo, planeClip(std(p.color, 0.42, 0.05), keep));
        main.renderOrder = 3;
        const sb = new THREE.Mesh(p.geo, stBack);
        const sf = new THREE.Mesh(p.geo, stFront);
        sb.renderOrder = sf.renderOrder = 1;
        const bm = new THREE.Mesh(p.geo, backMat);
        bm.renderOrder = 3;
        const gh = new THREE.Mesh(p.geo, ghostMat);
        gh.renderOrder = 6;
        for (const m of [main, sb, sf, bm, gh]) {
          m.position.set(...p.pos);
          m.rotation.y = p.rotY ?? 0;
          scene.add(m);
        }
        stencilMeshes.push(sb, sf);
        backMeshes.push(bm);
      }
      // 단면 판: 자르기 평면 위의 큰 판을 스텐실 ≠ 0 인 곳에만 그리고, 그린 자리는 0 으로 되돌린다
      const capW = 3.6;
      const capH = 7.6;
      const capTex = hatch.clone();
      capTex.repeat.set(4.5, 4.5);
      const capMat = new THREE.MeshBasicMaterial({ map: capTex, stencilWrite: true, stencilRef: 0, stencilFunc: THREE.NotEqualStencilFunc, stencilFail: THREE.ReplaceStencilOp, stencilZFail: THREE.ReplaceStencilOp, stencilZPass: THREE.ReplaceStencilOp });
      // 판 UV 를 세계 길이(단위)로 — 늘이기가 고르게 되어야 무늬를 45° 로 돌려도 비뚤지 않다
      const capGeo = new THREE.PlaneGeometry(capW, capH);
      const uvA = capGeo.attributes['uv'] as THREE.BufferAttribute;
      for (let i = 0; i < uvA.count; i++) uvA.setXY(i, uvA.getX(i) * capW, uvA.getY(i) * capH);
      const cap = new THREE.Mesh(capGeo, capMat);
      cap.renderOrder = 2;
      scene.add(cap);
      // 평면 테두리 (보이는 칼)
      const frame = new THREE.Group();
      const pv = new THREE.Mesh(new THREE.PlaneGeometry(capW, capH), new THREE.MeshBasicMaterial({ color: 0x7ab8ff, transparent: true, opacity: 0.1, side: THREE.DoubleSide, depthWrite: false }));
      pv.renderOrder = 7;
      const pvEdge = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.PlaneGeometry(capW, capH)), new THREE.LineBasicMaterial({ color: 0x4a7dff, transparent: true, opacity: 0.7 }));
      frame.add(pv, pvEdge);
      scene.add(frame);
      // 원뿔 단면 곡선 (모선과 평면의 교점)
      const curvePos = new Float32Array(200 * 3);
      const curveGeo = new THREE.BufferGeometry();
      curveGeo.setAttribute('position', new THREE.BufferAttribute(curvePos, 3));
      const curve = new THREE.Line(curveGeo, new THREE.LineBasicMaterial({ color: 0x8a0a3a }));
      curve.renderOrder = 8;
      scene.add(curve);
      const cam = new THREE.PerspectiveCamera(30, 1, 0.1, 60);
      const camDir = new THREE.Vector3(1, 0.78, 0.34).normalize();
      const look = new THREE.Vector3(0, -0.2, 0);
      let name = '';
      let beta = 0;
      let auto = true;
      let manual = 30;
      let compare = false;
      let useStencil = true;
      const n = new THREE.Vector3();
      const p0 = new THREE.Vector3(0, -0.25, 0);
      const apex = new THREE.Vector3(0, 1, 0);
      const dv = new THREE.Vector3();
      const u = new THREE.Vector3();
      const zAxis = new THREE.Vector3(0, 0, 1);
      const basis = new THREE.Matrix4();
      const pts: THREE.Vector3[] = [];
      const setMode = (stencil: boolean): void => {
        for (const m of stencilMeshes) m.visible = stencil;
        cap.visible = stencil;
        for (const m of backMeshes) m.visible = !stencil;
      };
      const fitCam = (): void => {
        const tn = Math.tan((cam.fov * Math.PI) / 360);
        const d = Math.max(2.0 / tn, 3.9 / (tn * cam.aspect));
        cam.position.copy(look).addScaledVector(camDir, d);
        cam.lookAt(look);
      };
      const labels: [string, THREE.Vector3][] = [
        ['속 빈 관', new THREE.Vector3(0, -1.15, 2.35)],
        ['원뿔', new THREE.Vector3(0, -1.15, 0)],
        ['겹친 공 + 상자', new THREE.Vector3(0, -1.15, -2.45)],
      ];
      const sp = new THREE.Vector3();
      const tagLabels = (x0: number, w: number, h: number, s: number): void => {
        for (const [txt, p] of labels) {
          sp.copy(p).project(cam);
          pill(g, x0 + ((sp.x + 1) / 2) * w, ((1 - sp.y) / 2) * h + s * 0.4, txt, s * 0.78, 'rgba(255,255,255,0.86)', '#1d2a55', 'center');
        }
      };
      return {
        update(t) {
          const { w, h, dpr } = fit();
          beta = auto ? pingpong(t, 12) * 80 : manual;
          const b = (beta * Math.PI) / 180;
          n.set(Math.sin(b), Math.cos(b), 0);
          const c = n.dot(p0);
          keep.value.set(n.x, n.y, n.z, c);
          gone.value.set(-n.x, -n.y, -n.z, -c);
          // 판 방향: 가로 u = 평면 안에서 z 에 수직, 세로 = z, 법선 = n
          u.crossVectors(zAxis, n).normalize();
          basis.makeBasis(u, zAxis, n);
          // 판을 평면 위에 놓되 위로(n 쪽) 아주 조금 — 잘린 면의 깊이와 겹쳐 지글거리지 않게
          for (const o of [cap, frame]) {
            o.quaternion.setFromRotationMatrix(basis);
            o.position.copy(p0);
          }
          cap.position.addScaledVector(n, 0.0005);
          // 원뿔 단면 곡선: 꼭짓점에서 내려오는 모선마다 평면과 만나는 점
          pts.length = 0;
          for (let i = 0; i < 160; i++) {
            const ph = (i / 160) * TAU;
            dv.set(Math.cos(ph), -2, Math.sin(ph));
            const den = n.dot(dv);
            if (Math.abs(den) < 1e-6) continue;
            const s = (c - n.dot(apex)) / den;
            if (s >= 0 && s <= 1) pts.push(apex.clone().addScaledVector(dv, s).addScaledVector(n, 0.004));
          }
          let k = 0;
          if (pts.length) {
            const ctr = pts.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / pts.length);
            const ang = (p: THREE.Vector3): number => Math.atan2(p.z - ctr.z, (p.x - ctr.x) * u.x + (p.y - ctr.y) * u.y);
            pts.sort((a, b2) => ang(a) - ang(b2));
            for (const p of pts) {
              if (k >= 199) break;
              curvePos.set([p.x, p.y, p.z], k * 3);
              k++;
            }
            if (beta < 61) {
              curvePos.set([pts[0]!.x, pts[0]!.y, pts[0]!.z], k * 3);
              k++;
            }
          }
          curveGeo.setDrawRange(0, k);
          curveGeo.attributes['position']!.needsUpdate = true;
          name = beta < 2 ? '원' : beta < 61 ? '타원' : beta < 65.5 ? '포물선' : '쌍곡선';
          scene.updateMatrixWorld();
          g.setTransform(dpr, 0, 0, dpr, 0, 0);
          g.clearRect(0, 0, w, h);
          const warm = glWarm.get(scene) === 'done';
          const pre = (stencil: boolean) => (): void => {
            // 데우는 동안은 두 방식 다 보이게 — 셰이더를 한 번에 굽는다
            if (warm) setMode(stencil);
            else {
              for (const m of [...stencilMeshes, ...backMeshes, cap]) m.visible = true;
            }
            fitCam();
          };
          const s = fsz(w, h, 0.9);
          if (compare) {
            const hw = Math.floor(w / 2);
            glPaint(g, dpr, scene, cam, 0, 0, hw, h, pre(false));
            if (warm) tagLabels(0, hw, h, s);
            glPaint(g, dpr, scene, cam, hw, 0, w - hw, h, pre(true));
            if (warm) tagLabels(hw, w - hw, h, s);
            g.fillStyle = 'rgba(255,255,255,0.85)';
            g.fillRect(hw - 1, 0, 2, h);
            pill(g, s * 0.6, s * 0.6, 'BackSide 색칠 (틀림)', s, 'rgba(120,130,150,0.9)');
            pill(g, hw + s * 0.6, s * 0.6, '스텐실 단면', s, 'rgba(212,20,90,0.92)');
            pill(g, w / 2, h - s * 2.2, '겹친 곳 · 빗금 모양을 비교해 보세요', s * 0.85, 'rgba(30,40,80,0.75)', '#fff', 'center');
          } else {
            glPaint(g, dpr, scene, cam, 0, 0, w, h, pre(useStencil));
            if (warm) tagLabels(0, w, h, s);
            pill(g, s * 0.6, s * 0.6, `기울기 ${Math.round(beta)}°`, s * 0.95);
            pill(g, w - s * 0.6, s * 0.6, `원뿔 단면: ${name}`, s * 1.1, 'rgba(212,20,90,0.92)', '#fff', 'right', TITLE);
            pill(g, s * 0.6, s * 2.5, useStencil ? '스텐실 단면 (뒷면 +1 · 앞면 −1)' : 'BackSide 색칠', s * 0.8, 'rgba(255,255,255,0.85)', '#1d2a55');
          }
        },
        controls: [
          { type: 'toggle', label: '저절로 기울이기', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '평면 기울기(°)', min: 0, max: 85, step: 1, value: 30, on: (v) => ((auto = false), (manual = v)) },
          { type: 'toggle', label: 'BackSide 방식과 나란히', value: false, on: (v) => (compare = v) },
          { type: 'toggle', label: '스텐실 단면 (끄면 BackSide)', value: true, on: (v) => (useStencil = v) },
        ],
        dispose() {
          disposeScene(scene);
          hatch.dispose();
          ownRelease();
          box.innerHTML = '';
        },
      };
    },
  }
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [뚜껑 없는 단면 (뒷면 셰이딩)](https://ai-techstudio.web.app/ai/t/i85.md) `i85`
- 먼저 알면 좋은 기술: [선반 회전체 + 장식 (윤곽선 하나로)](https://ai-techstudio.web.app/ai/t/i450.md) `i450`
- 다음에 해 볼 기술: [분해도 (exploded view)](https://ai-techstudio.web.app/ai/t/i36.md) `i36` · [BVH 빠른 고르기 (three-mesh-bvh)](https://ai-techstudio.web.app/ai/t/i67.md) `i67`
- 참고 문서: [three.js 예제 — webgl_clipping_stencil](https://threejs.org/examples/#webgl_clipping_stencil) · [MDN — WebGLRenderingContext.stencilOp](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/stencilOp) · [Wikipedia — Conic section](https://en.wikipedia.org/wiki/Conic_section)
