BLK-88Blocks

断面ビューア(切断面を動かして内部構造を見せる)

立体を平らな面で切り、切断位置を左右に動かすと中の層が見える3Dの断面ビューア。見本は二重床の一部(幅1200・奥行き600・高さ310mm)で、仕上げ材・遮音マット・合板下地・根太・断熱材・空気層・給水管・配線ダクト・ケーブル・基礎スラブなど11層を持ち、切り口には層ごとの断面色を塗る。切断位置で切り口の顔ぶれが変わり、右の断面図・面積の内訳の帯・層の表(床からの高さと切り口に占める面積の割合)が同時に書き換わる。今の切り口に現れない層を押すと、その層が現れるいちばん近い位置まで切断面が移る。断面は立体を箱と円柱の組み合わせで持ち、平面で切った区間を解析的に求めて長方形の蓋として置くため、3Dの切り口・2Dの断面図・面積比がすべて同じ計算から出て食い違わない。埋め込まれた部材(端根太を貫く給水管など)は親の層の区間から差し引くので、蓋どうしが重ならない。本体だけを clippingPlanes で切り、蓋は MeshBasicMaterial で断面図と同じ色に見せる。入力の正はネイティブのスライダー(矢印・PageUp/Down・Home/End)と層のボタンで、キーボードだけで操作が完結する。3Dのクリックで切り口の層を選び、ドラッグで向きを変えられる。描画はオンデマンドで、3Dライブラリ(three)は画面に入った実寸表示のときだけ動的importで読み込み、一覧の縮小カード・WebGL非対応・コンテキスト喪失では同じ切断位置を斜めから見た2Dの図(SVG)で出す。prefers-reduced-motion では切り込みの補間を使わず、操作のたびに最終状態を1枚だけ描く。英字の小見出し・末尾の矢印リンク・角丸・スクロール出現は使わず、hairline 罫線と余白で組んでいる。寸法と層構成はサンプル。

追加日:
2026-10-03
依存:
three
tags
#3d #cross-section #section-view #cutaway #webgl #clipping-plane #data-visualization #interactive #keyboard #no-image #dynamic-import

プレビュー

外からは見えない層を、切り口で読む

床の一部を平らな面で切り、切断位置を左右に動かして中を見せます。切り口には層ごとの色を塗り、どの層がどの高さに、どれだけの面積で現れるかを断面図と表に並べます。

幅
1200mm
奥行き
600mm
高さ
310mm

切り口をクリックで層を選択・ドラッグで回転(矢印キーでも回転・Homeで元の向き)

07給水管

外径100の樹脂管。端根太を貫くこの切り口では高さ 122〜222mm、面積の 7%

切り口を正面から

1108090230310奥 0600 手前

面積の内訳

数値は床からの高さ(mm)と、切り口に占める面積の割合です。切れない層を押すと、その層が現れる位置まで切断位置を動かします

寸法と層の構成は、切り口の見せ方を示すための見本です。自社の製品・建物の部材表に差し替えてお使いください。部材は箱と円柱の組み合わせで書けば、切り口・面積・高さは同じ計算から出ます。

"use client";

import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type SVGProps } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";

const montserrat = Montserrat({ weight: ["500", "600", "700"], subsets: ["latin"], display: "swap" });

/**
 * 断面ビューア(切断面を動かして内部構造を見せる)
 *
 * 差別化: 既存の3D(product-configurator-3d・three-object-gallery-hero)は外観を回して眺める・塗り替える表現で、
 * depth-strata-navigator の「層」は名前の付いた抽象の帯だった。本ブロックは1つの立体を平面で切り、
 * 切断位置を動かすと切り口に現れる層の顔ぶれ・高さ・面積比が変わる「中を読む」図で、操作軸も情報も別物。
 * 英字の小見出し・見出しの「。」止め・末尾の矢印リンク・角丸・スクロール出現は置かない。
 *
 * 汎用技法メモ:
 * 1. **切り口は解析的に作る**。立体を箱と円柱(軸は奥行き方向)だけで組むと、平面 x=c で切った断面は
 *    「箱なら [y0,y1]・円柱なら中心から ±√(r²−d²)」の長方形の集まりになる。この長方形を3Dの蓋(板)にも、
 *    2Dの断面図にも、面積比の帯にもそのまま使うので、3D と 2D の表示が食い違わない。
 *    ステンシルで蓋を作る定石より単純で、層ごとの面積も同じ計算から出る。
 * 2. **埋め込まれた部材は親の層から差し引く**(carve)。断熱材や端根太の区間から、そこを貫く配管などの区間を
 *    引いておくと、蓋どうしが同じ平面で重ならず z-fighting が起きない。
 * 3. 本体のマテリアルにだけ `clippingPlanes` を付け(`renderer.localClippingEnabled = true`)、蓋は切らない。
 *    蓋は切断面より 0.4mm 手前に置き、縁取りの線はさらに手前に置く。蓋は `toneMapped: false` の
 *    MeshBasicMaterial にして、2D の断面図と同じ色に見せる。
 * 4. 描画はオンデマンド。切断位置と視点の補間が終われば rAF を止める。3Dライブラリは画面に入った実寸表示のときだけ
 *    useEffect 内の動的 import で読み込み、縮小カード(rect.width ÷ offsetWidth)・WebGL 非対応・コンテキスト喪失では、
 *    同じ切断位置の斜投影図(SVG)を出す。
 * 5. 入力の正はネイティブの range(矢印・PageUp/Down・Home/End)と層のボタン。3D のドラッグとクリックは近道。
 *    prefers-reduced-motion では補間を使わず、操作のたびに最終状態を1枚だけ描く。
 *
 * 寸法・層構成・配色はすべて First CH のオリジナル(構成を示すための見本)。
 */

type ThreeModule = typeof import("three");

const AMBER = 0xd97706;

/* ------------------------------------------------------------------ データ(単位は mm) */

const W = 1200; // 幅(切断位置を動かす向き)
const H = 310; // 高さ
const D = 600; // 奥行き
const CUT_MAX = 1195;
const CUT_STEP = 5;
const INITIAL_CUT = 960;

type LayerId = "finish" | "mat" | "ply" | "joist" | "insul" | "air" | "pipe" | "water" | "duct" | "cable" | "slab";
type Pattern = "none" | "hatch" | "dots" | "air";

type Layer = {
  id: LayerId;
  no: string;
  name: string;
  /** 断面色(切り口に塗る色) */
  color: string;
  pattern: Pattern;
  note: string;
  /** 中身の無い層(3Dでは塗らない) */
  void?: boolean;
};

// 上から下へ
const LAYERS: Layer[] = [
  { id: "finish", no: "01", name: "仕上げ材", color: "#c2925c", pattern: "none", note: "歩く面。木質の床材" },
  { id: "mat", no: "02", name: "遮音マット", color: "#57534e", pattern: "none", note: "足音を下の階へ伝えにくくする" },
  { id: "ply", no: "03", name: "合板下地", color: "#e2c08d", pattern: "none", note: "仕上げ材を受ける平らな下地" },
  { id: "joist", no: "04", name: "根太・端根太", color: "#8a5a2b", pattern: "none", note: "荷重を受ける骨組み。両端と中央を通る" },
  { id: "insul", no: "05", name: "断熱材", color: "#f3d36b", pattern: "hatch", note: "左の区画だけに充填" },
  { id: "air", no: "06", name: "空気層", color: "#ffffff", pattern: "air", note: "右の区画。配管と配線の通り道", void: true },
  { id: "pipe", no: "07", name: "給水管", color: "#64748b", pattern: "none", note: "外径100の樹脂管。端根太を貫く" },
  { id: "water", no: "08", name: "管内の水", color: "#8fb3c9", pattern: "none", note: "給水管の内側(内径80)" },
  { id: "duct", no: "09", name: "配線ダクト", color: "#a8a29e", pattern: "none", note: "厚さ6の角形ダクト" },
  { id: "cable", no: "10", name: "ケーブル", color: "#d97706", pattern: "none", note: "ダクトの中を通る3本" },
  { id: "slab", no: "11", name: "基礎スラブ", color: "#b8b2a7", pattern: "dots", note: "全体を支えるコンクリート" },
];

const LAYER = Object.fromEntries(LAYERS.map((l) => [l.id, l])) as Record<LayerId, Layer>;

type Prim =
  | { layer: LayerId; kind: "box"; x0: number; x1: number; y0: number; y1: number; z0: number; z1: number; carve?: boolean; elev?: boolean }
  | { layer: LayerId; kind: "tube"; cx: number; cy: number; r: number; z0: number; z1: number }
  | { layer: LayerId; kind: "ring"; cx: number; cy: number; r: number; rin: number; z0: number; z1: number };

const box = (layer: LayerId, x0: number, x1: number, y0: number, y1: number, z0 = 0, z1 = D, extra: { carve?: boolean; elev?: boolean } = {}): Prim => ({
  layer,
  kind: "box",
  x0,
  x1,
  y0,
  y1,
  z0,
  z1,
  ...extra,
});

const PRIMS: Prim[] = [
  box("slab", 0, W, 0, 90),
  // 根太(奥行き方向に通る3本)と端根太(手前と奥のふた。給水管が貫くので carve)
  box("joist", 0, 80, 90, 230),
  box("joist", 560, 640, 90, 230),
  box("joist", 1120, W, 90, 230),
  box("joist", 80, 560, 90, 230, 0, 30, { carve: true, elev: false }),
  box("joist", 80, 560, 90, 230, 570, D, { carve: true, elev: false }),
  box("joist", 640, 1120, 90, 230, 0, 30, { carve: true, elev: false }),
  box("joist", 640, 1120, 90, 230, 570, D, { carve: true, elev: false }),
  box("insul", 80, 560, 90, 230, 30, 570, { carve: true }),
  box("air", 640, 1120, 90, 230, 30, 570, { carve: true }),
  { layer: "pipe", kind: "ring", cx: 960, cy: 172, r: 50, rin: 40, z0: -24, z1: D + 24 },
  { layer: "water", kind: "tube", cx: 960, cy: 172, r: 40, z0: -22, z1: D + 22 },
  // 配線ダクト(4枚の壁)とケーブル
  box("duct", 700, 820, 90, 96, 30, 570),
  box("duct", 700, 820, 150, 156, 30, 570),
  box("duct", 700, 706, 96, 150, 30, 570),
  box("duct", 814, 820, 96, 150, 30, 570),
  { layer: "cable", kind: "tube", cx: 735, cy: 112, r: 14, z0: 30, z1: 570 },
  { layer: "cable", kind: "tube", cx: 774, cy: 112, r: 14, z0: 30, z1: 570 },
  { layer: "cable", kind: "tube", cx: 755, cy: 135, r: 12, z0: 30, z1: 570 },
  box("ply", 0, W, 230, 260),
  box("mat", 0, W, 260, 285),
  box("finish", 0, W, 285, H),
];

type Span = [number, number];
type Rect = { layer: LayerId; y0: number; y1: number; z0: number; z1: number };

/** 平面 x=c で切ったときの、部材1つぶんの高さ方向の区間 */
function spansOf(p: Prim, c: number): Span[] {
  if (p.kind === "box") return c >= p.x0 && c < p.x1 ? [[p.y0, p.y1]] : [];
  const d = Math.abs(c - p.cx);
  if (d >= p.r) return [];
  const ro = Math.sqrt(p.r * p.r - d * d);
  if (p.kind === "ring" && d < p.rin) {
    const ri = Math.sqrt(p.rin * p.rin - d * d);
    return [
      [p.cy - ro, p.cy - ri],
      [p.cy + ri, p.cy + ro],
    ];
  }
  return [[p.cy - ro, p.cy + ro]];
}

function subtract(spans: Span[], cut: Span): Span[] {
  const out: Span[] = [];
  for (const [a, b] of spans) {
    if (cut[1] <= a || cut[0] >= b) {
      out.push([a, b]);
      continue;
    }
    if (cut[0] > a) out.push([a, cut[0]]);
    if (cut[1] < b) out.push([cut[1], b]);
  }
  return out;
}

/** 切断位置 c(左端からの mm)の断面。長方形の集まりで返す */
function sectionAt(c: number): Rect[] {
  const raw = PRIMS.map((p) => ({ p, spans: spansOf(p, c) }));
  const rects: Rect[] = [];
  for (const { p, spans } of raw) {
    let s = spans;
    if (p.kind === "box" && p.carve) {
      // 埋め込まれた部材(carve でない部材で、奥行きの範囲が重なるもの)を差し引く
      for (const o of raw) {
        if (o.p === p || (o.p.kind === "box" && o.p.carve) || !o.spans.length) continue;
        if (o.p.z1 <= p.z0 || o.p.z0 >= p.z1) continue;
        for (const sp of o.spans) s = subtract(s, sp);
      }
    }
    for (const [y0, y1] of s) if (y1 - y0 > 0.3) rects.push({ layer: p.layer, y0, y1, z0: p.z0, z1: p.z1 });
  }
  return rects;
}

type Summary = { present: boolean; y0: number; y1: number; area: number; share: number };

function summarize(rects: Rect[]): Record<LayerId, Summary> {
  const out = Object.fromEntries(LAYERS.map((l) => [l.id, { present: false, y0: Infinity, y1: -Infinity, area: 0, share: 0 }])) as Record<LayerId, Summary>;
  let total = 0;
  for (const r of rects) {
    const s = out[r.layer];
    s.present = true;
    s.y0 = Math.min(s.y0, r.y0);
    s.y1 = Math.max(s.y1, r.y1);
    const a = (r.y1 - r.y0) * (r.z1 - r.z0);
    s.area += a;
    total += a;
  }
  for (const l of LAYERS) out[l.id].share = total > 0 ? out[l.id].area / total : 0;
  return out;
}

// 層ごとに「切り口に現れる位置」の連なりを先に求めておく(ない層を押したら、いちばん近い連なりの中央へ移る)
const RUNS: Record<LayerId, Span[]> = (() => {
  const runs = Object.fromEntries(LAYERS.map((l) => [l.id, [] as Span[]])) as Record<LayerId, Span[]>;
  const open: Partial<Record<LayerId, number>> = {};
  for (let c = 0; c <= CUT_MAX + CUT_STEP; c += CUT_STEP) {
    const here = c <= CUT_MAX ? new Set(sectionAt(c).map((r) => r.layer)) : new Set<LayerId>();
    for (const l of LAYERS) {
      const on = here.has(l.id);
      if (on && open[l.id] === undefined) open[l.id] = c;
      if (!on && open[l.id] !== undefined) {
        runs[l.id].push([open[l.id] as number, c - CUT_STEP]);
        delete open[l.id];
      }
    }
  }
  return runs;
})();

function nearestCut(id: LayerId, c: number) {
  let best = c;
  let bestD = Infinity;
  for (const [a, b] of RUNS[id]) {
    const d = c < a ? a - c : c > b ? c - b : 0;
    if (d < bestD) {
      bestD = d;
      best = Math.round((a + b) / 2 / CUT_STEP) * CUT_STEP;
    }
  }
  return best;
}

const mm = (n: number) => String(Math.round(n));
const pct = (n: number) => (n < 0.0005 ? "0" : n < 0.01 ? (n * 100).toFixed(1) : String(Math.round(n * 100)));

/** 切り口に現れる「全面に通る層以外」の部材。スライダーの読み上げに使う */
const DISTINCT: LayerId[] = ["joist", "insul", "air", "pipe", "duct", "cable"];

/* ------------------------------------------------------------------ 3D */

type ViewerApi = {
  setCut: (c: number) => void;
  setSelected: (id: LayerId) => void;
  nudge: (dYaw: number, dElev: number) => void;
  recenter: () => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type ViewerOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  label: HTMLElement;
  cut: number;
  selected: LayerId;
  onPick: (id: LayerId) => void;
  onFirstFrame: () => void;
  onFail: () => void;
};

const YAW0 = 0.8;
const ELEV0 = 0.46;

function createViewer(T: ThreeModule, opts: ViewerOptions): ViewerApi {
  const { canvas, host, label, onPick, onFirstFrame, onFail } = opts;
  // mm → world(1 unit = 1000mm・立体の中心を原点に)
  const wx = (x: number) => (x - W / 2) / 1000;
  const wz = (z: number) => (z - D / 2) / 1000;
  const wy = (y: number) => y / 1000;

  const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
  renderer.localClippingEnabled = true;
  renderer.toneMapping = T.NeutralToneMapping;

  const scene = new T.Scene();
  const camera = new T.PerspectiveCamera(28, 1, 0.05, 30);
  const target = new T.Vector3(0, 0.13, 0);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  const keepG = <G extends THREE.BufferGeometry>(g: G) => (geometries.push(g), g);
  const keepM = <M extends THREE.Material>(m: M) => (materials.push(m), m);

  scene.add(new T.HemisphereLight(0xffffff, 0xd6d3d1, 1.5));
  const key = new T.DirectionalLight(0xffffff, 1.7);
  key.position.set(-2.2, 3.4, 2.6);
  scene.add(key);
  const fill = new T.DirectionalLight(0xfff4e6, 0.5);
  fill.position.set(2.5, 1.2, -1.5);
  scene.add(fill);

  // 本体: 切断平面の手前(x < c)を消す。distanceToPoint = x − c が負の側が切られる
  const plane = new T.Plane(new T.Vector3(1, 0, 0), 0);
  const paper = new T.Color(0xf5f3ef);
  const bodyMats = {} as Record<LayerId, THREE.MeshStandardMaterial>;
  for (const l of LAYERS) {
    // 外観は淡く(断面色を紙色へ寄せる)。中を通る部材だけ少し濃くして、空気層の奥で読めるようにする
    const inner = l.id === "pipe" || l.id === "duct" || l.id === "cable" || l.id === "water";
    const col = new T.Color(l.color).lerp(paper, inner ? 0.25 : 0.55);
    bodyMats[l.id] = keepM(new T.MeshStandardMaterial({ color: col, roughness: 0.82, metalness: 0, clippingPlanes: [plane] }));
  }
  const edgeMat = keepM(new T.LineBasicMaterial({ color: 0x78716c, transparent: true, opacity: 0.45, clippingPlanes: [plane] }));
  const outer = new Set<LayerId>(["slab", "ply", "mat", "finish"]);

  // 管(ring)は中空にする。内面と管口は裏からも見えるので両面で描く
  const ringMats = {} as Partial<Record<LayerId, THREE.MeshStandardMaterial>>;
  const ringMat = (id: LayerId) => {
    let m = ringMats[id];
    if (!m) {
      m = keepM(bodyMats[id].clone());
      m.side = T.DoubleSide;
      m.clippingPlanes = [plane]; // clone() は平面を複製するため、動く切断平面を共有し直す
      ringMats[id] = m;
    }
    return m;
  };

  for (const p of PRIMS) {
    if (LAYER[p.layer].void) continue;
    if (p.kind === "ring") {
      const len = (p.z1 - p.z0) / 1000;
      const ro = p.r / 1000;
      const ri = p.rin / 1000;
      const m = ringMat(p.layer);
      const pipe = new T.Group();
      pipe.add(new T.Mesh(keepG(new T.CylinderGeometry(ro, ro, len, 56, 1, true)), bodyMats[p.layer]));
      pipe.add(new T.Mesh(keepG(new T.CylinderGeometry(ri, ri, len, 56, 1, true)), m));
      const end = keepG(new T.RingGeometry(ri, ro, 56));
      for (const s of [-1, 1]) {
        const lip = new T.Mesh(end, m);
        lip.rotation.x = -Math.PI / 2; // RingGeometry は xy 面。円柱の軸(y)に直交させる
        lip.position.y = (s * len) / 2;
        pipe.add(lip);
      }
      pipe.rotation.x = Math.PI / 2;
      pipe.position.set(wx(p.cx), wy(p.cy), wz((p.z0 + p.z1) / 2));
      scene.add(pipe);
      continue;
    }
    let mesh: THREE.Mesh;
    if (p.kind === "box") {
      const g = keepG(new T.BoxGeometry((p.x1 - p.x0) / 1000, (p.y1 - p.y0) / 1000, (p.z1 - p.z0) / 1000));
      mesh = new T.Mesh(g, bodyMats[p.layer]);
      mesh.position.set(wx((p.x0 + p.x1) / 2), wy((p.y0 + p.y1) / 2), wz((p.z0 + p.z1) / 2));
      if (outer.has(p.layer)) {
        const lines = new T.LineSegments(keepG(new T.EdgesGeometry(g)), edgeMat);
        mesh.add(lines);
      }
    } else {
      const g = keepG(new T.CylinderGeometry(p.r / 1000, p.r / 1000, (p.z1 - p.z0) / 1000, 56));
      mesh = new T.Mesh(g, bodyMats[p.layer]);
      mesh.rotation.x = Math.PI / 2;
      mesh.position.set(wx(p.cx), wy(p.cy), wz((p.z0 + p.z1) / 2));
    }
    scene.add(mesh);
  }

  // 蓋(切り口)。部材の区間ごとに1枚の板を使い回す
  const capGeo = keepG(new T.PlaneGeometry(1, 1));
  const capEdgeGeo = keepG(new T.EdgesGeometry(capGeo));
  const capMats = {} as Record<LayerId, THREE.MeshBasicMaterial>;
  for (const l of LAYERS) {
    capMats[l.id] = keepM(new T.MeshBasicMaterial({ color: l.color, toneMapped: false, side: T.DoubleSide }));
    if (l.void) capMats[l.id].visible = false;
  }
  const capLine = keepM(new T.LineBasicMaterial({ color: 0x44403c, transparent: true, opacity: 0.7, toneMapped: false }));
  const voidLine = keepM(new T.LineBasicMaterial({ color: 0xa8a29e, toneMapped: false }));
  const pickLine = keepM(new T.LineBasicMaterial({ color: AMBER, toneMapped: false }));
  type Cap = { mesh: THREE.Mesh; line: THREE.LineSegments };
  const caps: Cap[] = [];
  const capGroup = new T.Group();
  scene.add(capGroup);
  const getCap = (i: number) => {
    while (caps.length <= i) {
      const mesh = new T.Mesh(capGeo, capMats.slab);
      mesh.rotation.y = -Math.PI / 2; // 法線を −x(見ている側)へ
      const line = new T.LineSegments(capEdgeGeo, capLine);
      line.position.z = 0.0006; // ローカル +z = 手前
      mesh.add(line);
      capGroup.add(mesh);
      caps.push({ mesh, line });
    }
    return caps[i];
  };

  // 切り取った部分の輪郭と、切断平面の枠
  const unitBoxEdges = keepG(new T.EdgesGeometry(keepG(new T.BoxGeometry(1, 1, 1))));
  const ghost = new T.LineSegments(unitBoxEdges, keepM(new T.LineBasicMaterial({ color: 0xa8a29e, transparent: true, opacity: 0.55 })));
  scene.add(ghost);
  const frame = new T.LineSegments(capEdgeGeo, keepM(new T.LineBasicMaterial({ color: AMBER, toneMapped: false })));
  frame.rotation.y = -Math.PI / 2;
  scene.add(frame);

  /* --- 状態 --- */
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;
  let running = false;
  let raf = 0;
  let last = 0;
  let firstFrame = true;
  let cutTarget = opts.cut;
  // 動いてよいなら、無傷の立体から切り込んでいく
  let cutShown = motionOK ? 0 : opts.cut;
  let selected = opts.selected;
  let yaw = YAW0;
  let elev = ELEV0;
  let yawTarget = YAW0;
  let elevTarget = ELEV0;
  let width = 1;
  let height = 1;
  let dist = 3;
  let rects: Rect[] = [];

  const applyCut = () => {
    const c = cutShown;
    plane.constant = -wx(c);
    rects = sectionAt(c);
    rects.forEach((r, i) => {
      const cap = getCap(i);
      cap.mesh.visible = true;
      cap.mesh.material = capMats[r.layer];
      cap.mesh.userData.layer = r.layer;
      cap.mesh.position.set(wx(c) - 0.0004, wy((r.y0 + r.y1) / 2), wz((r.z0 + r.z1) / 2));
      cap.mesh.scale.set((r.z1 - r.z0) / 1000, (r.y1 - r.y0) / 1000, 1);
      cap.line.material = r.layer === selected ? pickLine : LAYER[r.layer].void ? voidLine : capLine;
      cap.line.renderOrder = r.layer === selected ? 2 : 1;
    });
    for (let i = rects.length; i < caps.length; i++) caps[i].mesh.visible = false;
    const gw = c / 1000;
    ghost.visible = gw > 0.002;
    ghost.scale.set(Math.max(gw, 0.0001), H / 1000, D / 1000);
    ghost.position.set(wx(c / 2), wy(H / 2), 0);
    frame.position.set(wx(c) - 0.0008, wy(H / 2), 0);
    frame.scale.set((D + 80) / 1000, (H + 80) / 1000, 1);
  };

  const anchor = new T.Vector3();
  const placeLabel = () => {
    let best: Rect | null = null;
    for (const r of rects) if (r.layer === selected && (!best || (r.y1 - r.y0) * (r.z1 - r.z0) > (best.y1 - best.y0) * (best.z1 - best.z0))) best = r;
    if (!best) {
      label.style.opacity = "0";
      return;
    }
    anchor.set(wx(cutShown), wy((best.y0 + best.y1) / 2), wz((best.z0 + best.z1) / 2)).project(camera);
    const x = ((anchor.x + 1) / 2) * width;
    const y = ((1 - anchor.y) / 2) * height;
    label.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -150%)`;
    label.style.opacity = "1";
  };

  const placeCamera = () => {
    // 注視点を残った側へ半分だけ寄せる(切り進めても立体が画面の端へ逃げない)
    target.x = wx((cutShown + W) / 2) * 0.5;
    camera.position.set(
      target.x - Math.cos(elev) * Math.cos(yaw) * dist,
      target.y + Math.sin(elev) * dist,
      target.z + Math.cos(elev) * Math.sin(yaw) * dist,
    );
    camera.lookAt(target);
  };

  const renderOnce = () => {
    applyCut();
    placeCamera();
    renderer.render(scene, camera);
    placeLabel();
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  const resize = () => {
    const rect = host.getBoundingClientRect();
    width = Math.max(1, Math.round(rect.width));
    height = Math.max(1, Math.round(rect.height));
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
    // 立体を包む球(半径約0.7)が縦横どちらでも収まる距離
    const vt = Math.tan((camera.fov * Math.PI) / 360);
    const half = Math.atan(Math.min(vt, vt * camera.aspect));
    dist = 0.5 / Math.sin(half);
    renderer.setSize(width, height, false);
  };

  const settle = () => {
    cutShown = cutTarget;
    yaw = yawTarget;
    elev = elevTarget;
    renderOnce();
  };

  function stop() {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  }

  const frameLoop = (now: number) => {
    raf = requestAnimationFrame(frameLoop);
    if (!last) last = now;
    const dt = Math.min((now - last) / 1000, 0.06);
    last = now;
    const k = 1 - Math.pow(0.004, dt);
    cutShown += (cutTarget - cutShown) * k;
    yaw += (yawTarget - yaw) * k;
    elev += (elevTarget - elev) * k;
    const done = Math.abs(cutTarget - cutShown) < 0.4 && Math.abs(yawTarget - yaw) < 1e-3 && Math.abs(elevTarget - elev) < 1e-3;
    if (done) {
      stop();
      settle();
      return;
    }
    renderOnce();
  };

  /** 変化があったときに呼ぶ。動かせない状況なら最終状態を1枚だけ描く */
  const kick = () => {
    if (!running || !motionOK) {
      stop();
      settle();
      return;
    }
    if (raf) return;
    last = 0;
    raf = requestAnimationFrame(frameLoop);
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    if (!motionOK) {
      stop();
      settle();
    }
  };
  motionQuery.addEventListener("change", onMotionChange);

  /* --- ポインタ: クリックで切り口の層を選ぶ・ドラッグで回す --- */
  const raycaster = new T.Raycaster();
  const ndc = new T.Vector2();
  const pickAt = (clientX: number, clientY: number): LayerId | null => {
    const r = canvas.getBoundingClientRect();
    ndc.set(((clientX - r.left) / r.width) * 2 - 1, -((clientY - r.top) / r.height) * 2 + 1);
    raycaster.setFromCamera(ndc, camera);
    const hit = raycaster.intersectObjects(caps.filter((c) => c.mesh.visible).map((c) => c.mesh), false)[0];
    return hit ? (hit.object.userData.layer as LayerId) : null;
  };

  let pointerId: number | null = null;
  let downX = 0;
  let downY = 0;
  let lastX = 0;
  let lastY = 0;
  let dragged = false;
  const clampYaw = (v: number) => Math.max(0.12, Math.min(1.45, v));
  const clampElev = (v: number) => Math.max(0.08, Math.min(1.05, v));

  const onPointerDown = (e: PointerEvent) => {
    if (pointerId !== null) return;
    pointerId = e.pointerId;
    downX = lastX = e.clientX;
    downY = lastY = e.clientY;
    dragged = false;
    canvas.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e: PointerEvent) => {
    if (pointerId === null) {
      if (e.pointerType === "mouse") canvas.style.cursor = pickAt(e.clientX, e.clientY) ? "pointer" : "grab";
      return;
    }
    if (e.pointerId !== pointerId) return;
    if (!dragged && Math.hypot(e.clientX - downX, e.clientY - downY) < 5) return;
    dragged = true;
    canvas.style.cursor = "grabbing";
    const k = 2.6 / Math.max(width, 1);
    yawTarget = yaw = clampYaw(yawTarget - (e.clientX - lastX) * k);
    elevTarget = elev = clampElev(elevTarget + (e.clientY - lastY) * k);
    lastX = e.clientX;
    lastY = e.clientY;
    if (!raf) renderOnce();
  };
  const onPointerUp = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return;
    pointerId = null;
    if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
    canvas.style.cursor = "grab";
    if (!dragged && e.type === "pointerup") {
      const id = pickAt(e.clientX, e.clientY);
      if (id) onPick(id);
    }
  };
  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", onPointerUp);
  canvas.addEventListener("pointercancel", onPointerUp);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  // canvas の寸法を書き換えると中身が消えるので、止まっていても必ず1枚描き直す
  const ro = new ResizeObserver(() => {
    resize();
    renderOnce();
  });
  ro.observe(host);
  resize();
  renderOnce();

  return {
    setCut: (c) => {
      cutTarget = c;
      kick();
    },
    setSelected: (id) => {
      selected = id;
      if (!raf) renderOnce();
    },
    nudge: (dYaw, dElev) => {
      yawTarget = clampYaw(yawTarget + dYaw);
      elevTarget = clampElev(elevTarget + dElev);
      kick();
    },
    recenter: () => {
      yawTarget = YAW0;
      elevTarget = ELEV0;
      kick();
    },
    setRunning: (next) => {
      running = next;
      if (!next) stop();
      else kick();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 2D(SVG) */

/** 断面の塗り。模様のある層は塗りの上に模様を重ねる */
function Fill({ layer, uid, ...rest }: { layer: LayerId; uid: string } & SVGProps<SVGPolygonElement>) {
  const l = LAYER[layer];
  return (
    <>
      <polygon {...rest} fill={l.color} />
      {l.pattern !== "none" && <polygon {...rest} fill={`url(#${l.pattern}-${uid})`} />}
    </>
  );
}

function Patterns({ uid }: { uid: string }) {
  return (
    <defs>
      <pattern id={`hatch-${uid}`} width="14" height="14" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
        <path d="M0 0v14" stroke="#b45309" strokeOpacity="0.55" strokeWidth="2.4" />
      </pattern>
      <pattern id={`dots-${uid}`} width="16" height="16" patternUnits="userSpaceOnUse">
        <circle cx="4" cy="4" r="1.8" fill="#57534e" fillOpacity="0.45" />
        <circle cx="12" cy="11" r="1.3" fill="#57534e" fillOpacity="0.35" />
      </pattern>
      <pattern id={`air-${uid}`} width="18" height="18" patternUnits="userSpaceOnUse">
        <circle cx="9" cy="9" r="1.4" fill="#a8a29e" />
      </pattern>
    </defs>
  );
}

const pts = (list: [number, number][]) => list.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(" ");

/** 断面図(切り口を正面から見る。横が奥行き・右が手前) */
function SectionDrawing({ rects, selected, uid, cut }: { rects: Rect[]; selected: LayerId; uid: string; cut: number }) {
  const sy = (y: number) => 330 - y;
  const ticks = [0, 90, 230, H];
  return (
    <svg viewBox="-92 0 748 374" role="img" aria-label={`左端から${cut}mmの断面図`} className={`${montserrat.className} block h-auto w-full`}>
      <Patterns uid={uid} />
      <rect x="-24" y={sy(H)} width={D + 48} height={H} fill="#fafaf9" />
      {rects.map((r, i) => {
        const p = pts([
          [r.z0, sy(r.y1)],
          [r.z1, sy(r.y1)],
          [r.z1, sy(r.y0)],
          [r.z0, sy(r.y0)],
        ]);
        const on = r.layer === selected;
        return (
          <g key={i}>
            <Fill layer={r.layer} uid={uid} points={p} />
            <polygon
              points={p}
              fill="none"
              stroke={on ? "#d97706" : LAYER[r.layer].void ? "#a8a29e" : "#44403c"}
              strokeOpacity={on ? 1 : 0.55}
              strokeDasharray={LAYER[r.layer].void && !on ? "6 5" : undefined}
              strokeWidth={on ? 5 : 1.4}
            />
          </g>
        );
      })}
      {/* 層番号(入る大きさの区間だけ) */}
      {rects.map((r, i) =>
        r.y1 - r.y0 >= 34 && r.z1 - r.z0 >= 70 ? (
          <text
            key={`t${i}`}
            x={(r.z0 + r.z1) / 2}
            y={sy((r.y0 + r.y1) / 2) + 10}
            textAnchor="middle"
            fontSize="28"
            fontWeight="600"
            fill={r.layer === "mat" || r.layer === "joist" || r.layer === "pipe" ? "#ffffff" : "#292524"}
          >
            {LAYER[r.layer].no}
          </text>
        ) : null,
      )}
      {ticks.map((t) => (
        <g key={t}>
          <line x1="-34" x2="-26" y1={sy(t)} y2={sy(t)} stroke="#a8a29e" strokeWidth="1.4" />
          <text x="-40" y={sy(t) + 8} textAnchor="end" fontSize="24" fontWeight="500" fill="#78716c">
            {t}
          </text>
        </g>
      ))}
      <text x="0" y="364" fontSize="24" fontWeight="500" fill="#78716c">
        奥 0
      </text>
      <text x={D} y="364" textAnchor="end" fontSize="24" fontWeight="500" fill="#78716c">
        {D} 手前
      </text>
    </svg>
  );
}

/** 立面の透視図(手前から見て、中の部材を透かして描く)。切断位置の線と、切り取った側の覆いを重ねる */
function Elevation({ cut, selected, uid }: { cut: number; selected: LayerId; uid: string }) {
  const sy = (y: number) => H - y;
  const order: LayerId[] = ["slab", "joist", "insul", "air", "duct", "cable", "pipe", "water", "ply", "mat", "finish"];
  return (
    <svg viewBox={`0 -14 ${W} ${H + 28}`} aria-hidden="true" className="block h-auto w-full">
      <Patterns uid={`${uid}e`} />
      {order.flatMap((id) =>
        PRIMS.filter((p) => p.layer === id && !(p.kind === "box" && p.elev === false)).map((p, i) => {
          const on = id === selected;
          const stroke = on ? "#d97706" : "#78716c";
          const sw = on ? 4 : 1;
          if (p.kind === "box") {
            const q = pts([
              [p.x0, sy(p.y1)],
              [p.x1, sy(p.y1)],
              [p.x1, sy(p.y0)],
              [p.x0, sy(p.y0)],
            ]);
            return (
              <g key={`${id}${i}`}>
                <Fill layer={id} uid={`${uid}e`} points={q} />
                <polygon points={q} fill="none" stroke={stroke} strokeOpacity={on ? 1 : 0.5} strokeWidth={sw} strokeDasharray={LAYER[id].void && !on ? "8 6" : undefined} />
              </g>
            );
          }
          return (
            <circle
              key={`${id}${i}`}
              cx={p.cx}
              cy={sy(p.cy)}
              r={p.r}
              fill={LAYER[id].color}
              stroke={stroke}
              strokeOpacity={on ? 1 : 0.5}
              strokeWidth={sw}
            />
          );
        }),
      )}
      {/* 切り取った側(左)を白で覆い、切断位置を線で示す */}
      <rect x="0" y="-14" width={cut} height={H + 28} fill="#ffffff" fillOpacity="0.72" />
      <line x1={cut} x2={cut} y1="-14" y2={H + 14} stroke="#d97706" strokeWidth="5" />
    </svg>
  );
}

/** 斜投影図(3Dが使えないときの代替。切断位置から右の残りを、切り口を手前に向けて描く) */
function ObliqueView({ cut, rects, selected, uid }: { cut: number; rects: Rect[]; selected: LayerId; uid: string }) {
  // 奥行き d(手前 0)を左上へ流す
  const K = 0.42;
  const P = (x: number, y: number, z: number): [number, number] => {
    const d = D - z;
    return [x - d * K * Math.cos(0.62), -(y + d * K * Math.sin(0.62))];
  };
  const paper = (hex: string) => `color-mix(in srgb, ${hex} 45%, #f5f3ef)`;
  const front = PRIMS.filter((p) => p.kind === "box" && p.z1 >= D && p.x1 > cut) as Extract<Prim, { kind: "box" }>[];
  const xL = 0 - D * K * Math.cos(0.62);
  const yT = -(H + D * K * Math.sin(0.62));
  return (
    <svg viewBox={`${(xL - 40).toFixed(0)} ${(yT - 40).toFixed(0)} ${(W - xL + 80).toFixed(0)} ${(-yT + 80).toFixed(0)}`} aria-hidden="true" className="block h-auto w-full">
      <Patterns uid={`${uid}o`} />
      {/* 切り取った部分の輪郭(破線) */}
      {cut > 0 && (
        <g fill="none" stroke="#a8a29e" strokeWidth="2" strokeDasharray="10 8">
          <polygon points={pts([P(0, 0, D), P(cut, 0, D), P(cut, H, D), P(0, H, D)])} />
          <polygon points={pts([P(0, H, D), P(cut, H, D), P(cut, H, 0), P(0, H, 0)])} />
          <polygon points={pts([P(0, 0, D), P(0, H, D), P(0, H, 0), P(0, 0, 0)])} />
        </g>
      )}
      {/* 上面 */}
      <polygon points={pts([P(cut, H, D), P(W, H, D), P(W, H, 0), P(cut, H, 0)])} fill={paper(LAYER.finish.color)} stroke="#78716c" strokeOpacity="0.5" strokeWidth="1.5" />
      {/* 手前の面 */}
      {front.map((p, i) => (
        <polygon
          key={i}
          points={pts([P(Math.max(p.x0, cut), p.y0, D), P(p.x1, p.y0, D), P(p.x1, p.y1, D), P(Math.max(p.x0, cut), p.y1, D)])}
          fill={paper(LAYER[p.layer].color)}
          stroke="#78716c"
          strokeOpacity="0.45"
          strokeWidth="1.2"
        />
      ))}
      {PRIMS.filter((p) => p.kind === "ring" && p.cx - p.r > cut).map((p, i) =>
        p.kind === "ring" ? (
          <g key={`r${i}`}>
            <circle cx={P(p.cx, p.cy, p.z1)[0]} cy={P(p.cx, p.cy, p.z1)[1]} r={p.r} fill={paper(LAYER.pipe.color)} stroke="#78716c" strokeOpacity="0.6" strokeWidth="1.2" />
            <circle cx={P(p.cx, p.cy, p.z1)[0]} cy={P(p.cx, p.cy, p.z1)[1]} r={p.rin} fill={paper(LAYER.water.color)} />
          </g>
        ) : null,
      )}
      {/* 切り口 */}
      {rects.map((r, i) => {
        const q = pts([P(cut, r.y0, r.z1), P(cut, r.y0, r.z0), P(cut, r.y1, r.z0), P(cut, r.y1, r.z1)]);
        const on = r.layer === selected;
        return (
          <g key={`c${i}`}>
            <Fill layer={r.layer} uid={`${uid}o`} points={q} />
            <polygon points={q} fill="none" stroke={on ? "#d97706" : LAYER[r.layer].void ? "#a8a29e" : "#44403c"} strokeOpacity={on ? 1 : 0.6} strokeWidth={on ? 5 : 1.2} />
          </g>
        );
      })}
      {/* 切断平面の枠 */}
      <polygon points={pts([P(cut, -30, D + 50), P(cut, -30, -50), P(cut, H + 30, -50), P(cut, H + 30, D + 50)])} fill="none" stroke="#d97706" strokeWidth="3" />
    </svg>
  );
}

/* ------------------------------------------------------------------ 本体 */

const STEP = (12 * Math.PI) / 180;

export default function SectionCutViewer() {
  const uid = useId().replace(/:/g, "");
  const headingId = `section-heading-${uid}`;
  const hintId = `section-hint-${uid}`;
  const sliderId = `section-slider-${uid}`;

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const apiRef = useRef<ViewerApi | null>(null);
  const elevRef = useRef<HTMLDivElement>(null);

  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [cut, setCut] = useState(INITIAL_CUT);
  const [selected, setSelected] = useState<LayerId>("pipe");
  const [announce, setAnnounce] = useState("");

  // 起動時の状態は ref 経由で渡す(3Dを作り直すのは live が立つときの1回だけ)
  const initialRef = useRef<{ cut: number; selected: LayerId }>({ cut: INITIAL_CUT, selected: "pipe" });
  useEffect(() => {
    initialRef.current = { cut, selected };
  }, [cut, selected]);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dライブラリを読み込まない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "160px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    const label = labelRef.current;
    if (!canvas || !host || !label) return;
    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、斜投影図のまま確定させる
      const probe = document.createElement("canvas");
      const gl = probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();
      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createViewer(T, {
        canvas,
        host,
        label,
        cut: initialRef.current.cut,
        selected: initialRef.current.selected,
        onPick: (id) => setSelected(id),
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });
    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  useEffect(() => {
    apiRef.current?.setCut(cut);
  }, [cut, ready]);

  useEffect(() => {
    apiRef.current?.setSelected(selected);
  }, [selected, ready]);

  const rects = sectionAt(cut);
  const sum = summarize(rects);
  const presentList = LAYERS.filter((l) => sum[l.id].present);
  const distinct = DISTINCT.filter((id) => sum[id].present).map((id) => LAYER[id].name);
  const valueText = `左端から${cut}ミリ。中段は${distinct.join("・") || "なし"}`;

  // 切り口の顔ぶれが変わったときだけ読み上げる(スライダーを動かしている間は待つ)
  const compositionKey = presentList.map((l) => l.id).join(",");
  const spokenRef = useRef(compositionKey);
  useEffect(() => {
    const t = window.setTimeout(() => {
      if (spokenRef.current === compositionKey) return;
      spokenRef.current = compositionKey;
      setAnnounce(`${cut}ミリの断面に現れる層: ${presentList.map((l) => l.name).join("、")}`);
    }, 600);
    return () => window.clearTimeout(t);
    // compositionKey が変わったときだけでよい(cut と presentList はそこから決まる)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [compositionKey]);

  const choose = (id: LayerId) => {
    setSelected(id);
    if (!sum[id].present) {
      const next = nearestCut(id, cut);
      setCut(next);
      // 移動先の顔ぶれは下の読み上げと重ねない
      spokenRef.current = LAYERS.filter((l) => sectionAt(next).some((r) => r.layer === l.id))
        .map((l) => l.id)
        .join(",");
      setAnnounce(`${LAYER[id].name}が現れる左端から${next}ミリへ移動しました`);
    }
  };

  // 立面図の上を押す・なぞると、その位置で切る(キーボードはスライダーが正)
  const dragging = useRef(false);
  const cutFromPointer = (clientX: number) => {
    const el = elevRef.current;
    if (!el) return;
    const r = el.getBoundingClientRect();
    const v = Math.round((((clientX - r.left) / r.width) * W) / CUT_STEP) * CUT_STEP;
    setCut(Math.max(0, Math.min(CUT_MAX, v)));
  };
  const onElevDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    dragging.current = true;
    e.currentTarget.setPointerCapture(e.pointerId);
    cutFromPointer(e.clientX);
  };
  const onElevMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (dragging.current) cutFromPointer(e.clientX);
  };
  const onElevUp = (e: ReactPointerEvent<HTMLDivElement>) => {
    dragging.current = false;
    if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const api = apiRef.current;
    if (!api) return;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [STEP, 0],
      ArrowRight: [-STEP, 0],
      ArrowUp: [0, STEP / 2],
      ArrowDown: [0, -STEP / 2],
    };
    if (e.key in moves) {
      e.preventDefault();
      api.nudge(...moves[e.key]);
    } else if (e.key === "Home") {
      e.preventDefault();
      api.recenter();
    }
  };

  const show3d = ready && !failed;
  const sel = LAYER[selected];
  const selSum = sum[selected];

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 英字の小見出しは置かず、見出しと寸法の帯で開く */}
      <div className="grid gap-6 @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end">
        <div>
          <h2 id={headingId} className="text-[clamp(1.35rem,3.4vw,2.05rem)] leading-[1.35] font-bold tracking-tight">
            外からは見えない層を、
            <span className="whitespace-nowrap">切り口で読む</span>
          </h2>
          <p className="mt-4 max-w-xl text-[14px] leading-[1.9] font-medium text-gray-600">
            床の一部を平らな面で切り、切断位置を左右に動かして中を見せます。切り口には層ごとの色を塗り、どの層がどの高さに、どれだけの面積で現れるかを断面図と表に並べます。
          </p>
        </div>
        <dl className="grid grid-cols-3 border-y border-gray-200 @2xl:w-[19rem]">
          {[
            { term: "幅", value: String(W) },
            { term: "奥行き", value: String(D) },
            { term: "高さ", value: String(H) },
          ].map((s, i) => (
            <div key={s.term} className={`py-3 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
              <dt className="text-[11px] font-bold tracking-wide whitespace-nowrap text-gray-500">{s.term}</dt>
              <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                <span className={`${montserrat.className} text-[1.3rem] leading-none font-semibold tabular-nums`}>{s.value}</span>
                <span className="text-[11px] text-gray-500">mm</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @3xl:grid-cols-[minmax(0,1fr)_17.5rem] @3xl:gap-9">
        <div className="min-w-0">
          <div
            ref={stageRef}
            tabIndex={show3d ? 0 : -1}
            role="group"
            aria-roledescription="3Dプレビュー"
            aria-label={`床の断面の3Dプレビュー。左端から${cut}ミリで切断`}
            aria-describedby={hintId}
            onKeyDown={onStageKey}
            className="relative aspect-[4/3] w-full @md:aspect-[16/10] overflow-hidden border border-gray-200 bg-[radial-gradient(60%_60%_at_55%_55%,rgba(217,119,6,0.05)_0%,rgba(255,255,255,0)_70%),linear-gradient(180deg,#ffffff_0%,#f7f5f1_100%)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            {/* 斜投影図: 3Dが立ち上がるまで・縮小プレビュー・WebGL非対応のときの表示。同じ切断位置と層を持つ */}
            <div
              className={`absolute inset-0 flex flex-col items-center justify-center px-4 py-3 transition-opacity duration-700 motion-reduce:transition-none ${
                show3d ? "pointer-events-none opacity-0" : "opacity-100"
              }`}
            >
              <div className="w-full max-w-[34rem]">
                <ObliqueView cut={cut} rects={rects} selected={selected} uid={uid} />
              </div>
              {failed && (
                <p className="absolute bottom-2.5 left-3 bg-white/85 px-1.5 text-[10.5px] leading-[1.6] text-gray-500">3Dが使えない環境のため、同じ切り口を斜めから見た図で表示中</p>
              )}
            </div>

            <canvas
              ref={canvasRef}
              aria-hidden="true"
              className={`absolute inset-0 block size-full cursor-grab touch-pan-y transition-opacity duration-700 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "pointer-events-none opacity-0"
              }`}
            />

            {/* 選んだ層のラベル(位置は3D側が transform で書き換える) */}
            <div aria-hidden="true" className={`pointer-events-none absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${show3d ? "opacity-100" : "opacity-0"}`}>
              <span
                ref={labelRef}
                className="absolute top-0 left-0 border border-amber-600 bg-white/90 px-1.5 py-0.5 text-[11px] leading-none font-bold whitespace-nowrap text-amber-700 opacity-0"
              >
                {sel.no} {sel.name}
              </span>
            </div>

            <p
              id={hintId}
              className={`pointer-events-none absolute bottom-2.5 left-3 bg-white/85 px-1.5 text-[10.5px] leading-[1.6] text-gray-500 transition-opacity duration-500 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            >
              切り口をクリックで層を選択・ドラッグで回転
              <span className="sr-only @md:not-sr-only">(矢印キーでも回転・Homeで元の向き)</span>
            </p>
          </div>

          {/* 切断位置: 立面の透視図(押す・なぞるでも切れる)+ネイティブのスライダー(入力の正) */}
          <div className="mt-6">
            <div className="flex items-baseline justify-between gap-3">
              <label htmlFor={sliderId} className="text-[12px] font-bold tracking-wide text-gray-500">
                切断位置(左端から)
              </label>
              <p className="flex items-baseline gap-1 whitespace-nowrap" aria-hidden="true">
                <span className={`${montserrat.className} text-[1.5rem] leading-none font-bold tracking-tight tabular-nums`}>{cut}</span>
                <span className="text-[12px] font-bold text-gray-500">mm</span>
              </p>
            </div>
            <div
              ref={elevRef}
              onPointerDown={onElevDown}
              onPointerMove={onElevMove}
              onPointerUp={onElevUp}
              onPointerCancel={onElevUp}
              className="mt-3 cursor-ew-resize touch-pan-y border-y border-gray-200 py-1.5 select-none"
            >
              <Elevation cut={cut} selected={selected} uid={uid} />
            </div>
            <input
              id={sliderId}
              type="range"
              min={0}
              max={CUT_MAX}
              step={CUT_STEP}
              value={cut}
              onChange={(e) => setCut(Number(e.target.value))}
              aria-valuetext={valueText}
              className="mt-2 block h-6 w-full cursor-pointer accent-amber-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
            />
            <div className={`${montserrat.className} mt-1 flex justify-between text-[10.5px] font-medium text-gray-500 tabular-nums`} aria-hidden="true">
              <span>0</span>
              <span>{W / 2}</span>
              <span>{W}</span>
            </div>
          </div>
          <div className="mt-6 border-t border-gray-200 pt-4">
            <p className="text-[13px] font-bold">
              <span className={`${montserrat.className} mr-1.5 text-[11px] font-semibold text-amber-700 tabular-nums`}>{sel.no}</span>
              {sel.name}
            </p>
            <p className="mt-1 text-[12px] leading-[1.8] text-gray-600">
              {sel.note}
              <span className="block text-gray-500">
                {selSum.present
                  ? `この切り口では高さ ${mm(selSum.y0)}〜${mm(selSum.y1)}mm、面積の ${pct(selSum.share)}%`
                  : `この切り口には現れません(左端から ${RUNS[selected].map(([a, b]) => `${a}〜${b}`).join("・")}mm で現れます)`}
              </span>
            </p>
          </div>

        </div>

        {/* 切り口の読み取り(断面図・面積比・層の表) */}
        <div className="min-w-0 @3xl:border-l @3xl:border-gray-200 @3xl:pl-9">
          <p className="text-[12px] font-bold tracking-wide text-gray-500">切り口を正面から</p>
          <div className="mt-3">
            <SectionDrawing rects={rects} selected={selected} uid={uid} cut={cut} />
          </div>

          <p className="mt-5 text-[12px] font-bold tracking-wide text-gray-500">面積の内訳</p>
          <div className="mt-2 flex h-2.5 w-full border border-gray-200" aria-hidden="true">
            {presentList.map((l) => (
              <span
                key={l.id}
                className={`block h-full ${l.id === selected ? "outline-2 -outline-offset-1 outline-amber-600" : ""}`}
                style={{ width: `${sum[l.id].share * 100}%`, backgroundColor: l.color === "#ffffff" ? "#f5f5f4" : l.color }}
              />
            ))}
          </div>

          <ol className="mt-4 border-t border-gray-200">
            {LAYERS.map((l) => {
              const s = sum[l.id];
              const on = l.id === selected;
              return (
                <li key={l.id} className="border-b border-gray-200">
                  <button
                    type="button"
                    aria-pressed={on}
                    onClick={() => choose(l.id)}
                    className={`grid w-full cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2.5 px-1 py-[0.4rem] text-left transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none ${
                      on ? "bg-amber-50/70" : "hover:bg-gray-50"
                    }`}
                  >
                    <span
                      aria-hidden="true"
                      className={`size-3 flex-none border ${s.present ? "border-gray-400" : "border-dashed border-gray-300 opacity-40"}`}
                      style={{ backgroundColor: l.color }}
                    />
                    <span className={`min-w-0 truncate text-[12.5px] ${on ? "font-bold text-amber-800" : s.present ? "font-medium text-gray-900" : "text-gray-500"}`}>
                      <span className={`${montserrat.className} mr-1.5 text-[10px] font-semibold tabular-nums ${on ? "text-amber-700" : "text-gray-500"}`}>{l.no}</span>
                      {l.name}
                    </span>
                    <span className={`${montserrat.className} text-right text-[11px] font-medium whitespace-nowrap tabular-nums ${s.present ? "text-gray-600" : "text-gray-500"}`}>
                      {s.present ? (
                        <>
                          {mm(s.y0)}–{mm(s.y1)}
                          <span className="ml-2 inline-block w-[2.6rem] text-gray-900">{pct(s.share)}%</span>
                        </>
                      ) : (
                        <span className="font-sans">ここでは切れない</span>
                      )}
                    </span>
                  </button>
                </li>
              );
            })}
          </ol>
          <p className="mt-2 text-[11px] leading-[1.7] text-gray-500">数値は床からの高さ(mm)と、切り口に占める面積の割合です。切れない層を押すと、その層が現れる位置まで切断位置を動かします</p>

          <p aria-live="polite" className="sr-only">
            {announce}
          </p>
        </div>
      </div>

      <p className="mt-8 border-t border-gray-200 pt-6 text-[12px] leading-[1.9] text-gray-500">
        寸法と層の構成は、切り口の見せ方を示すための見本です。自社の製品・建物の部材表に差し替えてお使いください。部材は箱と円柱の組み合わせで書けば、切り口・面積・高さは同じ計算から出ます。
      </p>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/section-cut-viewer.json