Section Cut Viewer (move a cutting plane to reveal the inside)
A 3D section viewer that slices a solid with a flat plane: slide the cut position left and right and the layers inside come into view. The sample is a piece of raised floor (1200 wide, 600 deep, 310 mm high) with eleven layers, from the finish board, acoustic mat and plywood base through joists, insulation, an air cavity, a water pipe, a cable duct and cables down to the concrete slab, and every cut face is painted in its layer's section color. As the plane moves, the set of layers on the cut face changes, and the section drawing, the area breakdown bar and the layer table (height from the floor and share of the cut area) update together. Pressing a layer that does not appear on the current cut moves the plane to the nearest position where it does. The solid is modelled only from boxes and cylinders, so each cut is computed analytically as a set of rectangles that serve as the 3D caps, the 2D drawing and the area figures at once, and they can never disagree. Embedded parts (such as the pipe passing through the rim joist) are subtracted from their host layer so caps never overlap. Only the body is clipped with clippingPlanes; caps use MeshBasicMaterial so they match the drawing's colors. The native range input (arrows, Page Up/Down, Home/End) and the layer buttons are the source of truth, so everything works by keyboard; in 3D you can click a cut face to select its layer and drag to turn the view. Rendering is on demand, and three is dynamically imported only when the block is on screen at full size; the scaled gallery card, browsers without WebGL and lost contexts get an oblique 2D drawing (SVG) of the same cut. With prefers-reduced-motion the cut is not tweened and each change draws the final state once. No small English kickers, trailing arrow links, rounded corners or scroll-in effects, just hairline rules and whitespace. Dimensions and layers are sample data.
- Added:
- 2026-10-03
- Dependencies:
- three
- tags
- #3d #cross-section #section-view #cutaway #webgl #clipping-plane #data-visualization #interactive #keyboard #no-image #dynamic-import
Preview
外からは見えない層を、切り口で読む
床の一部を平らな面で切り、切断位置を左右に動かして中を見せます。切り口には層ごとの色を塗り、どの層がどの高さに、どれだけの面積で現れるかを断面図と表に並べます。
- 幅
- 1200mm
- 奥行き
- 600mm
- 高さ
- 310mm
切り口をクリックで層を選択・ドラッグで回転(矢印キーでも回転・Homeで元の向き)
07給水管
外径100の樹脂管。端根太を貫くこの切り口では高さ 122〜222mm、面積の 7%
切り口を正面から
面積の内訳
数値は床からの高さ(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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/section-cut-viewer.json