# AI 꾸러미 — 깎고 붙이기 (CSG 불리언) — CSG (Constructive Solid Geometry) boolean
> 모양을 다각형 목록으로 바꿔 BSP 나무로 서로 잘라, 합치기 · 빼기 · 겹친 곳만 남기기로 창문 구멍 난 집과 둥근 주사위를 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i451

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

## 주문서

### 만들어 줘: 깎고 붙이기 (CSG 불리언) — CSG (Constructive Solid Geometry) boolean

#### 1. 목표
창 · 문 구멍 난 장난감 집을 CSG 불리언으로 만들어 줘 — 합치기 · 빼기 · 겹친 곳만으로 구멍 · 홈을 내고, 화면이 멈추지 않게 나눠 계산. 분위기는 밝은 장난감 마을.

#### 2. 핵심 기술 용어
- **CSG (Constructive Solid Geometry) boolean** — 모양끼리 합치기 · 빼기 · 교집합
- **BSP tree (binary space partitioning)** — 면 평면으로 공간을 앞 · 뒤로 나눈 나무
- **Union · Subtract · Intersect** — 합집합 · 차집합 · 교집합
- **three-bvh-csg** — three.js 에서 많이 쓰는 빠른 CSG 라이브러리 (견본은 직접 짠 BSP)

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

#### 4. 조건
- 불리언 계산은 프레임당 3ms 예산으로 나눠서 (생성기 + yield) — 한 번에 돌리면 화면이 멈춘다
- 재료 모양은 세계 행렬(위치 · 회전)을 먼저 적용해 다각형으로 바꾼다
- 평면 판정에 작은 오차(1e-5)를 두어 같은 평면 다각형을 따로 다룬다
- 연산 전 재료를 반투명 유령으로 보여 주고, 결과로 바뀌는 과정을 보여 준다
- 결과는 다시 쓰지 말고 굳혀(한 번 계산) 그린다

#### 5. 완성 기준 (이게 보이면 성공)
- 상자 · 지붕 · 굴뚝 · 원통이 합쳐져 집이 되고, 창 · 문 모양이 빠져 구멍이 뚫린다
- 상자 ∩ 구 = 둥근 주사위, 거기서 작은 구들을 빼 눈 구멍이 파인다
- 「불리언 연산」을 끄면 그냥 겹쳐 놓은 것과 비교되고, 와이어프레임으로 잘린 면이 보인다
- 「창문 수」를 바꿔도 화면이 멈추지 않는다 (나눠 계산)

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

## 원리
- 모양을 세계 좌표 삼각형(다각형) 목록으로 바꾼다. 다각형마다 자기 평면(법선 · 거리)이 있다.
- BSP 나무: 다각형 하나의 평면으로 나머지를 앞 · 뒤로 나누고(걸치면 잘라서), 앞 · 뒤에 같은 일을 되풀이한다.
- A 를 B 의 나무로 자르면(clipTo) B 안에 든 A 조각이 사라진다. 뒤집기(invert)를 섞어 합 · 차 · 교를 만든다.
- 빼기 A − B = A 뒤집기 → 서로 자르기 → B 뒤집어 자르기 → 합치고 다시 뒤집기.
- 결과 다각형을 부채꼴로 삼각형화해 정점 색 · 법선과 함께 BufferGeometry 로 만든다.

## 핵심 코드 — BSP 로 합치기 · 빼기 (csg.js 방식)
(발췌: demos/demosModelB.ts CNode · buildSync · clipSync · csgSub 를 정리 (생성기 대신 보통 함수))
```ts
// p 를 평면 pl 기준으로 나눠 앞 · 뒤(같은 평면은 cf · cb)에 넣는다 — splitPoly (걸치면 잘라 둘로)
class CNode {
  plane: CPlane | null = null; front: CNode | null = null; back: CNode | null = null; polys: CPoly[] = [];
  invert(): void {
    this.polys = this.polys.map(flipPoly);
    if (this.plane) this.plane = { nx: -this.plane.nx, ny: -this.plane.ny, nz: -this.plane.nz, w: -this.plane.w };
    this.front?.invert(); this.back?.invert();
    [this.front, this.back] = [this.back, this.front];
  }
  all(out: CPoly[] = []): CPoly[] { out.push(...this.polys); this.front?.all(out); this.back?.all(out); return out; }
}
function build(n: CNode, polys: CPoly[]): void {
  if (!polys.length) return;
  const p0 = polys[0]!;
  n.plane ??= { nx: p0.nx, ny: p0.ny, nz: p0.nz, w: p0.w }; // 첫 다각형의 평면으로 나눈다
  const f: CPoly[] = [], b: CPoly[] = [];
  for (const p of polys) splitPoly(n.plane, p, n.polys, n.polys, f, b);
  if (f.length) build((n.front ??= new CNode()), f);
  if (b.length) build((n.back ??= new CNode()), b);
}
function clipPolys(n: CNode, polys: CPoly[]): CPoly[] { // n 안에 든 조각은 버린다
  if (!n.plane) return polys.slice();
  let f: CPoly[] = [], b: CPoly[] = [];
  for (const p of polys) splitPoly(n.plane, p, f, b, f, b);
  if (n.front) f = clipPolys(n.front, f);
  b = n.back ? clipPolys(n.back, b) : [];
  return f.concat(b);
}
function clipTo(n: CNode, bsp: CNode): void { n.polys = clipPolys(bsp, n.polys); n.front && clipTo(n.front, bsp); n.back && clipTo(n.back, bsp); }
const node = (p: CPoly[]) => { const n = new CNode(); build(n, p); return n; };

function subtract(a: CPoly[], b: CPoly[]): CPoly[] { // A − B
  const A = node(a), B = node(b);
  A.invert(); clipTo(A, B); clipTo(B, A);
  B.invert(); clipTo(B, A); B.invert();
  build(A, B.all()); A.invert();
  return A.all();
}
// 집: let house = union(body, roof, chimney, tower); house = subtract(house, union(...windows, door));
// 주사위: subtract(intersect(cube, sphere), union(...pips))
```

## 흔한 실수 · 확인 목록
- [ ] **CSG 를 한 번에 돌리면 화면이 수백 ms 멈춘다** — 연산을 생성기로 쓰고 「if (late()) yield」 로 프레임마다 3ms 만 돌린다. 데모의 Job 이 이 일을 한다.
- [ ] **재료를 지역 좌표 그대로 넣으면 엉뚱한 자리가 깎인다** — 다각형으로 바꿀 때 메시의 세계 행렬(위치 · 회전)과 법선 행렬을 먼저 곱한다.
- [ ] **같은 평면에 놓인 면끼리 깜빡이거나 구멍이 난다** — 평면 판정에 작은 오차(1e-5)를 두고, 같은 평면 다각형은 법선 방향으로 앞/뒤를 정한다.
- [ ] **연산을 거듭할수록 잘린 조각이 늘어 다음 연산이 더 느려진다** — 구멍 재료를 먼저 하나로 합친 뒤 본체에서 한 번만 뺀다 (견본 순서).

## 완성 기준 체크리스트
- [ ] 상자 · 지붕 · 굴뚝 · 원통이 합쳐져 집이 되고, 창 · 문 모양이 빠져 구멍이 뚫린다
- [ ] 상자 ∩ 구 = 둥근 주사위, 거기서 작은 구들을 빼 눈 구멍이 파인다
- [ ] 「불리언 연산」을 끄면 그냥 겹쳐 놓은 것과 비교되고, 와이어프레임으로 잘린 면이 보인다
- [ ] 「창문 수」를 바꿔도 화면이 멈추지 않는다 (나눠 계산)

## 이 기술 정보
- id: `i451` · 분류: 3D 모델 · 캐릭터 › 3D 모델링 · 절차 · 3D · 난이도 어려움 · 폰 부담 무거움 — 다각형 수가 늘면 BSP 나누기가 급격히 무거워진다 (수십 ms ~ 수백 ms). 견본은 생성기로 프레임마다 3ms 씩 나눠 계산.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i451
- 쓰면 좋을 때: 창문 · 홈 · 나사 구멍처럼 「파낸」 모양이 필요한 건물 · 기계 / 모양 조합을 코드로 바꿔 가며 만들 때 (창 수 슬라이더 등)
- 쓰지 말 때: 매 프레임 모양이 바뀌는 곳 — CSG 는 무겁다. 대신 셰이더로 잘라 보이기(clippingPlanes) / 부드럽게 녹아 붙어야 할 때 — 대신 SDF 부드러운 합치기(i446)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### csgDemo — `src/demos/demosModelB.ts:1235`
```ts
function csgDemo(): Scene3D {
  const scene = new THREE.Scene();
  const bg = bgTex('#1a2a44', '#456b8a');
  scene.background = bg;
  addLights(scene, { sky: 0xe8f0ff, ground: 0x4f6a3a, hemi: 0.75, sun: 3.0, dir: [-4, 6, 4.5], ext: 3.2 });
  const env = new Env();
  const camera = new THREE.PerspectiveCamera(38, 1.6, 0.1, 50);
  const turf = new THREE.Mesh(new THREE.CylinderGeometry(2.7, 2.8, 0.16, 72), new THREE.MeshStandardMaterial({ color: 0x8cc66a, roughness: 0.95 }));
  turf.position.set(0.1, -0.08, 0);
  turf.receiveShadow = true;
  const soil = new THREE.Mesh(new THREE.CylinderGeometry(2.8, 2.6, 0.3, 72), new THREE.MeshStandardMaterial({ color: 0x7a5236, roughness: 1 }));
  soil.position.set(0.1, -0.31, 0);
  scene.add(turf, soil);
  const world = new THREE.Group();
  scene.add(world);
  const resMat = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.55, emissive: 0xffffff, emissiveIntensity: 0 });
  const edgeMat = new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 });
  const tags = new Tags(2, ['rgba(12,16,34,0.72)', 'rgba(150,40,70,0.82)']);

  let seed = 3;
  let nWin = 3;
  let opOn = true;
  let wire = false;
  let ms = 0;
  let tris = 0;
  let group = new THREE.Group();
  let houseOps: Op[] = [];
  let cutOps: Op[] = [];
  let diceOps: Op[] = [];
  let pipOps: Op[] = [];
  let resU: THREE.Mesh | null = null;
  let resS: THREE.Mesh | null = null;
  let resI: THREE.Mesh | null = null;
  let resF: THREE.Mesh | null = null;
  let cone: THREE.Mesh | null = null;
  let dice = new THREE.Group();
  const job = new Job();
  const warm = new Warm();
  let t00 = -1;

  function build(): void {
    job.start(buildG());
  }
  // 재료 모양 → (셰이더 미리 굽기) → 불리언 연산, 모두 나눠서
  function* buildG(): G {
    for (const o of [...houseOps, ...cutOps, ...diceOps, ...pipOps]) {
      o.solid.dispose();
      o.glass.dispose();
    }
    world.remove(group);
    freeAll(group);
    group = new THREE.Group();
    dice = new THREE.Group();
    cone = null;
    resU = resS = resI = resF = null;
    houseOps = cutOps = diceOps = pipOps = [];
    const rng = rngOf(seed);
    const pick = <T,>(a: T[]): T => a[Math.floor(rng() * a.length)]!;
    const roofC = pick([0xe46a5e, 0x4fa3a5, 0x5b7fd6, 0x9a5fb5]);
    const pal = [pick([0xf7ead0, 0xfbe3a8, 0xf9d6d0, 0xd6f0e0]), roofC, 0xb8644a, 0xe6dcf5, 0x3a2a22, 0xd8b98a, 0x2f4f7a, 0xfdf8ef, 0x2b3a67, 0xe0443e].map((c) => new THREE.Color(c));
    const mk = (geo: THREE.BufferGeometry, x: number, y: number, z: number, c: number, from: [number, number, number], glassC: number, ry = 0): Op => {
      const glass = new THREE.MeshStandardMaterial({ color: glassC, transparent: true, opacity: 0.4, depthWrite: false, roughness: 0.3 });
      const solid = new THREE.MeshStandardMaterial({ color: pal[c]!.getHex(), roughness: 0.6 });
      const ghost = new THREE.Mesh(geo, glass);
      ghost.add(new THREE.LineSegments(new THREE.EdgesGeometry(geo, 30), edgeMat));
      ghost.rotation.y = ry;
      return { geo, pos: new THREE.Vector3(x, y, z), ry, c, from: new THREE.Vector3(...from), ghost, solid, glass };
    };
    const M = (o: Op): THREE.Matrix4 => new THREE.Matrix4().compose(o.pos, new THREE.Quaternion().setFromEuler(new THREE.Euler(0, o.ry, 0)), new THREE.Vector3(1, 1, 1));
    const roofShape = new THREE.Shape([new THREE.Vector2(-0.68, 0), new THREE.Vector2(0.68, 0), new THREE.Vector2(0, 0.62)]);
    const roofG = new THREE.ExtrudeGeometry(roofShape, { depth: 1.72, bevelEnabled: false }).translate(0, 0, -0.86).rotateY(PI / 2);
    houseOps = [
      mk(new THREE.BoxGeometry(1.5, 1.0, 1.1), -0.7, 0.5, 0, 0, [0, 0, 0], pal[0]!.getHex()),
      mk(roofG, -0.7, 0.98, 0, 1, [0, 0.9, 0], roofC),
      mk(new THREE.BoxGeometry(0.2, 0.55, 0.2), -1.1, 1.38, -0.18, 2, [0, 1.4, 0], 0xb8644a),
      mk(new THREE.CylinderGeometry(0.36, 0.36, 1.6, 40), 0.1, 0.8, 0.12, 3, [1.0, 0, 0], 0xe6dcf5),
    ];
    // 빼는 모양: 속 비우기 · 아치 문 · 창
    const archBox = (w: number, h: number): THREE.BufferGeometry => new THREE.BoxGeometry(w, h, 0.4);
    const archCyl = (r: number): THREE.BufferGeometry => new THREE.CylinderGeometry(r, r, 0.4, 28).rotateX(PI / 2);
    const red = 0xff5a6a;
    cutOps = [mk(new THREE.BoxGeometry(1.3, 0.8, 0.9), -0.7, 0.5, 0, 4, [0, 0, 0], 0xffb0b0)];
    cutOps.push(mk(archBox(0.3, 0.36), -0.95, 0.17, 0.55, 5, [0, 0, 0.9], red), mk(archCyl(0.15), -0.95, 0.35, 0.55, 5, [0, 0, 0.9], red));
    const wins: (() => Op[])[] = [
      () => [mk(archBox(0.22, 0.2), -0.42, 0.48, 0.55, 5, [0, 0, 0.9], red), mk(archCyl(0.11), -0.42, 0.58, 0.55, 5, [0, 0, 0.9], red)],
      () => [mk(archBox(0.2, 0.18), -1.25, 0.5, 0.55, 5, [0, 0, 0.9], red), mk(archCyl(0.1), -1.25, 0.59, 0.55, 5, [0, 0, 0.9], red)],
      () => [mk(new THREE.CylinderGeometry(0.14, 0.14, 0.4, 28).rotateZ(PI / 2), -1.45, 0.55, 0, 5, [-0.9, 0, 0], red)],
      () => [mk(new THREE.CylinderGeometry(0.1, 0.1, 0.3, 28).rotateX(PI / 2), 0.1, 1.22, 0.48, 6, [0, 0, 0.9], red)],
    ];
    yield;
    const order = [0, 1, 2, 3].sort(() => rng() - 0.5);
    for (let i = 0; i < nWin; i++) cutOps.push(...wins[order[i]!]!());
    yield;
    const dcube = mk(new THREE.BoxGeometry(0.7, 0.7, 0.7, 1, 1, 1), 0, 0, 0, 7, [0, 0, 0], 0x7fb2ff);
    const dsph = mk(new THREE.SphereGeometry(0.485, 30, 20), 0, 0, 0, 7, [0, 0, 0], 0xffd36e);
    diceOps = [dcube, dsph];
    const pipAt: [number, number, number, number][] = [
      [0, 0.37, 0, 9],
      [-0.15, 0.15, 0.37, 8],
      [0.15, -0.15, 0.37, 8],
      [0.37, 0.17, -0.17, 8],
      [0.37, 0, 0, 8],
      [0.37, -0.17, 0.17, 8],
    ];
    pipOps = pipAt.map(([x, y, z, c]) => {
      const n = new THREE.Vector3(Math.abs(x) > 0.3 ? 1 : 0, Math.abs(y) > 0.3 ? 1 : 0, Math.abs(z) > 0.3 ? 1 : 0).multiplyScalar(0.4);
      return mk(new THREE.SphereGeometry(0.08, 18, 12), x, y, z, c, [n.x, n.y, n.z], red);
    });
    yield;
    dice.position.set(1.35, 0.35, 0.25);
    dice.rotation.y = 0.6;
    group.add(dice);
    resU = resS = resI = resF = null;
    cone = new THREE.Mesh(new THREE.ConeGeometry(0.46, 0.62, 40), new THREE.MeshStandardMaterial({ color: new THREE.Color(roofC).multiplyScalar(0.85), roughness: 0.5 }));
    cone.position.set(0.1, 1.91, 0.12);
    cone.castShadow = true;
    group.add(cone);
    for (const o of [...houseOps, ...cutOps]) group.add(o.ghost);
    for (const o of [...diceOps, ...pipOps]) dice.add(o.ghost);
    hiddenUsers(group, [resMat, ...[...houseOps, ...cutOps, ...diceOps, ...pipOps].flatMap((o) => [o.solid, o.glass])]);
    const g0 = group;
    warm.add(group, world, () => group === g0);
    // 연산은 나눠서 (프레임마다 3ms)
    yield* (function* (): G {
        let A = polysFrom(houseOps[0]!.geo, M(houseOps[0]!), 0);
        for (let i = 1; i < houseOps.length; i++) A = yield* csgUnion(A, polysFrom(houseOps[i]!.geo, M(houseOps[i]!), houseOps[i]!.c));
        let cut = polysFrom(cutOps[0]!.geo, M(cutOps[0]!), cutOps[0]!.c);
        for (let i = 1; i < cutOps.length; i++) cut = yield* csgUnion(cut, polysFrom(cutOps[i]!.geo, M(cutOps[i]!), cutOps[i]!.c));
        const S = yield* csgSub(A, cut);
        const I = yield* csgInt(polysFrom(dcube.geo, M(dcube), 7), polysFrom(dsph.geo, M(dsph), 7));
        let pips = polysFrom(pipOps[0]!.geo, M(pipOps[0]!), pipOps[0]!.c);
        for (let i = 1; i < pipOps.length; i++) pips = yield* csgUnion(pips, polysFrom(pipOps[i]!.geo, M(pipOps[i]!), pipOps[i]!.c));
        const F = yield* csgSub(I, pips);
        if (group !== g0) return;
        const mesh = function* (polys: CPoly[], parent: THREE.Object3D): G<THREE.Mesh> {
          const m = new THREE.Mesh(yield* geoFrom(polys, pal), resMat);
          m.castShadow = m.receiveShadow = true;
          warm.add(m, parent);
          return m;
        };
        const mU = yield* mesh(A, group);
        const mS = yield* mesh(S, group);
        const mI = yield* mesh(I, dice);
        const mF = yield* mesh(F, dice);
        resU = mU;
        resS = mS;
        resI = mI;
        resF = mF;
        tris = triCount(resS.geometry) + triCount(resF.geometry);
        ms = job.spent;
        t00 = -1;
      })();
    resMat.wireframe = wire;
  }
  build();

  const CYC = 11;
  let lastSwitch = 0;
  let lastState = -1;
  function place(o: Op, k: number, show: boolean, solid: boolean, scale = 1): void {
    o.ghost.visible = show;
    if (!show) return;
    o.ghost.position.copy(o.pos).addScaledVector(o.from, 1 - k);
    o.ghost.scale.setScalar(scale);
    o.ghost.material = solid ? o.solid : o.glass;
    (o.ghost.children[0] as THREE.Object3D).visible = !solid;
    o.ghost.castShadow = solid;
  }
  let label = '';
  return {
    scene,
    camera,
    update(t) {
      camera.position.set(0.35 + Math.sin(t * 0.18) * 0.6, 2.5, 5.8);
      camera.lookAt(0.2, 0.7, 0);
      world.rotation.y = Math.sin(t * 0.3) * 0.18;
      if (job.busy) job.step();
      if (!cone || !group.parent) {
        label = '재료 모양 만드는 중…';
        return;
      }
      const ready = !!(resU && resS && resI && resF) && !warm.pending;
      if (!opOn || !ready) {
        for (const o of houseOps) place(o, 1, true, true);
        for (const o of cutOps) place(o, 1, o !== cutOps[0], true);
        for (const o of diceOps) place(o, 1, true, true);
        for (const o of pipOps) place(o, 1, true, true);
        if (resU) resU.visible = false;
        if (resS) resS.visible = false;
        if (resI) resI.visible = false;
        if (resF) resF.visible = false;
        cone!.visible = true;
        label = ready ? '불리언 끔 — 그냥 겹쳐 놓기 (구멍이 안 뚫림)' : `모양 연산 중… 나눠서 굽는 중 (${job.spent.toFixed(0)}ms)`;
        return;
      }
      if (t00 < 0) t00 = t;
      const p = (t - t00) % CYC;
      const uK = easeIO(sat((p - 0.3) / 1.5));
      for (const o of houseOps) place(o, uK, p < 2.2, false);
      const cK = easeIO(sat((p - 2.8) / 1.4));
      for (const o of cutOps) place(o, cK, p >= 2.6 && p < 4.9, false);
      const sK = lerp(0.3, 1, easeIO(sat((p - 5.6) / 1.2)));
      place(diceOps[0]!, 1, p >= 5.4 && p < 7.2, false);
      place(diceOps[1]!, 1, p >= 5.4 && p < 7.2, false, sK);
      const pK = easeIO(sat((p - 8.2) / 0.7));
      pipOps.forEach((o, i) => place(o, pK, p >= 8.0 + i * 0.05 && p < 9.0, false, easeBack(sat((p - 8.0 - i * 0.05) / 0.4))));
      resU!.visible = p >= 2.2 && p < 4.6;
      resS!.visible = p >= 4.6;
      resI!.visible = p >= 7.2 && p < 9.0;
      resF!.visible = p >= 9.0;
      cone!.visible = p >= 2.2;
      const st = p < 2.2 ? 0 : p < 4.6 ? 1 : p < 7.2 ? 2 : p < 9 ? 3 : 4;
      if (st !== lastState) {
        if (st > 0) lastSwitch = t;
        lastState = st;
      }
      resMat.emissiveIntensity = st > 0 ? 0.3 * Math.exp(-(t - lastSwitch) * 5) : 0;
      label =
        p < 2.4
          ? '① 합치기  A ∪ B — 벽 · 지붕 · 굴뚝 · 탑'
          : p < 5.2
            ? '② 빼기  A − B — 문 · 창 구멍 · 속 비우기'
            : p < 7.9
              ? '③ 겹친 곳만  A ∩ B — 상자 ∩ 공 = 둥근 주사위'
              : p < 9.6
                ? '④ 빼기 — 주사위 눈 파기'
                : '완성 — 모양끼리 연산만으로';
    },
    render(r, w, h) {
      env.apply(r, scene, 0.35);
      if (warm.tick(r, scene, camera, 0x2c4462)) shadowed(r, () => r.render(scene, camera));
      tags.set(0, `삼각형 ${fmt(tris)} · 연산 ${ms.toFixed(0)}ms (나눠서)`);
      tags.set(1, label);
      tags.draw(r, w, h, [
        [0.02, 0.06, -1],
        [0.5, 0.92, 0],
      ]);
    },
    controls: [
      { type: 'toggle', label: '불리언 연산 (끄면 그냥 겹쳐 놓기)', value: true, on: (v) => (opOn = v) },
      {
        type: 'toggle',
        label: '와이어프레임 (잘린 면 보기)',
        value: false,
        on: (v) => {
          wire = v;
          resMat.wireframe = v;
        },
      },
      {
        type: 'range',
        label: '창문 수',
        min: 0,
        max: 4,
        step: 1,
        value: 3,
        on: (v) => {
          nWin = v;
          build();
        },
      },
      {
        type: 'button',
        label: '새로 만들기',
        on: () => {
          seed = (seed * 7919 + 13) % 100000;
          build();
        },
      },
    ] as Control[],
    dispose() {
      freeAll(group);
      freeAll(turf);
      freeAll(soil);
      resMat.dispose();
      edgeMat.dispose();
      bg.dispose();
      env.dispose();
      tags.dispose();
    },
  };
}
```

### i451 견본 항목 — `src/demos/demosModelB.ts:3142`
```ts
  i451: { kind: '3d', caption: '합치기 → 빼기 → 겹친 곳만: 모양끼리 연산해 창 · 문 구멍 난 집과 둥근 주사위', make: () => csgDemo() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [SDF 조각 모델링 (부드럽게 합치기)](https://ai-techstudio.web.app/ai/t/i446.md) `i446`
- 다음에 해 볼 기술: [옆모습 윤곽 돌출 + 모서리 깎기 (베벨)](https://ai-techstudio.web.app/ai/t/i468.md) `i468` · [나사 · 볼트 · 패널 이음선](https://ai-techstudio.web.app/ai/t/i470.md) `i470`
- 참고 문서: [Evan Wallace — csg.js](https://github.com/evanw/csg.js) · [three-bvh-csg](https://github.com/gkjohnson/three-bvh-csg)
