Files
오문성andClaude Haiku 5.5 9068b6c7f2 공개용 정리본: 1차 제출본(도면 좌표·과업명 제거)으로 새 이력 시작 (SPEC 34장)
- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋.
- 옛 81커밋은 로컬에만 보존. 기록은 evidence/gitlog_v1.txt.
- F13 기준 변경: .gitignore 에 private/*.dwg·private/full.dxf 제외 (SPEC 34장 끝 문장).
- check.sh 결과: 결과: PASS 151 / FAIL 0 / SKIP 3 (검사 범위 F01~F46)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-27 14:27:50 +09:00

923 lines
41 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const viewPane = document.getElementById('viewPane');
const drawPane = document.getElementById('drawPane');
const drawMsg = document.getElementById('drawMsg');
const splitter = document.getElementById('splitter');
const drawStage = document.getElementById('drawStage');
const drawImg = document.getElementById('drawing');
const hotspots = document.getElementById('hotspots');
const partLabel = document.getElementById('partLabel');
// 도면 클릭으로 고른 부재 id 들 (없으면 빈 배열)
let SELECTED = [];
// 모델 중심(벽 중심, 높이 가운데)과 중심에서 가장 먼 점까지 거리(mm, 대략). 처음 화면이 모델 전체를 담게 한다
const MODEL_CENTER = [0, 0, -35150];
const MODEL_R = 40000;
// 카메라 처음 값: 등각 (좌우 45도, 기울기 35도)
const CAM_HOME = { yaw: 45 * Math.PI / 180, pitch: 35 * Math.PI / 180 };
// 카메라: target 은 보는 중심(mm), 처음에는 모델 중심
const CAM = { ...CAM_HOME, target: [...MODEL_CENTER] };
// 빛 방향 (화면 기준: 오른쪽, 위, 보는 사람 쪽)
const LIGHT = [-0.4, 0.7, 0.6];
let SCALE = 1;
// 휠 배율: 1 = 창에 맞춘 크기, 0.5 ~ 40 사이에서만 바뀐다
let ZOOM = 1;
const ZOOM_MIN = 0.5, ZOOM_MAX = 40;
function resize() {
canvas.width = viewPane.clientWidth;
canvas.height = viewPane.clientHeight;
draw();
}
// 세계 좌표(mm) → 화면 기준 좌표 [오른쪽, 위, 멀수록 큰 값]
function rotatePoint(p) {
const c = Math.cos(CAM.yaw), s = Math.sin(CAM.yaw);
const x1 = p[0] * c - p[1] * s;
const y1 = p[0] * s + p[1] * c;
const z1 = p[2];
const cp = Math.cos(CAM.pitch), sp = Math.sin(CAM.pitch);
return [x1, y1 * sp + z1 * cp, y1 * cp - z1 * sp];
}
// 화면 기준 벡터 → 세계 좌표 벡터 (rotatePoint 의 역. 화면 이동량을 세계 좌표로 바꿀 때 쓴다)
function unrotateVector(v) {
const cp = Math.cos(CAM.pitch), sp = Math.sin(CAM.pitch);
const y1 = v[1] * sp + v[2] * cp;
const z1 = v[1] * cp - v[2] * sp;
const c = Math.cos(CAM.yaw), s = Math.sin(CAM.yaw);
return [v[0] * c + y1 * s, -v[0] * s + y1 * c, z1];
}
// 화면 기준 좌표 → 캔버스 픽셀 (보는 중심 CAM.target 을 화면 가운데에 둔다)
function project(v) {
const c = rotatePoint(CAM.target);
return [canvas.width / 2 + (v[0] - c[0]) * SCALE, canvas.height / 2 - (v[1] - c[1]) * SCALE];
}
// 배율 z 일 때 mm 당 픽셀
function scaleFor(z) {
return Math.min(canvas.width, canvas.height) / (2 * MODEL_R) * z;
}
// 직육면체 6면. 각 면은 점 4개, 바깥 방향 법선, 색, alpha
function facesBox(x0, y0, z0, x1, y1, z1, color, alpha) {
const c = [
[x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],
[x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],
];
const quads = [
[[0, 1, 2, 3], [0, 0, -1]],
[[4, 5, 6, 7], [0, 0, 1]],
[[0, 1, 5, 4], [0, -1, 0]],
[[2, 3, 7, 6], [0, 1, 0]],
[[1, 2, 6, 5], [1, 0, 0]],
[[3, 0, 4, 7], [-1, 0, 0]],
];
return quads.map(([idx, n]) => ({ pts: idx.map(i => c[i]), n, color, alpha }));
}
// 세운 원판 (32각형): 윗면·아랫면 뚜껑 2장 + 옆면 32장
function facesDisc(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const ring = z => Array.from({ length: N }, (_, i) => {
const a = 2 * Math.PI * i / N;
return [p.cx + p.r * Math.cos(a), p.cy + p.r * Math.sin(a), z];
});
const top = ring(p.z1), bot = ring(p.z0);
const faces = [
{ pts: top, n: [0, 0, 1], color: p.color, alpha },
{ pts: bot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const a = 2 * Math.PI * (i + 0.5) / N;
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [Math.cos(a), Math.sin(a), 0], color: p.color, alpha });
}
return faces;
}
// 구멍 모양 하나를 점 목록으로 (SPEC 17.1): 점 목록이면 그대로, {circle: [x, y, r]} 면 16각형
function holePolygon(h) {
if (!h.circle) return h;
const [x, y, r] = h.circle;
return Array.from({ length: 16 }, (_, i) => {
const a = 2 * Math.PI * i / 16;
return [x + r * Math.cos(a), y + r * Math.sin(a)];
});
}
// 구멍 난 뚜껑·옆면 (SPEC 15.1, 17.1): 윗면·아랫면에 구멍 고리를 넣고, 구멍 둘레마다 세로 옆면을 더한다. holes 는 절대 좌표 점 목록
function addHoles(faces, holes, z0, z1, color, alpha) {
faces[0].holes = holes.map(poly => poly.map(q => [q[0], q[1], z1]));
faces[1].holes = holes.map(poly => poly.map(q => [q[0], q[1], z0]));
for (const poly of holes) {
// 넓이가 양수(반시계)면 왼쪽 법선이 구멍 안을 가리킨다. 아니면 뒤집는다
const area = poly.reduce((s, q, i) => {
const r = poly[(i + 1) % poly.length];
return s + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const top = poly.map(q => [q[0], q[1], z1]), bot = poly.map(q => [q[0], q[1], z0]);
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const dx = poly[j][0] - poly[i][0], dy = poly[j][1] - poly[i][1];
const len = Math.hypot(dx, dy);
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [sign * -dy / len, sign * dx / len, 0], color, alpha });
}
}
}
// 구멍 난 세운 원판 (SPEC 15.1): facesDisc 에 구멍을 얹는다. 구멍 점을 부재 위치(cx, cy)로 옮긴 뒤 addHoles
function facesDiscHoles(p) {
const faces = facesDisc(p);
const holes = (p.holes || []).map(h => holePolygon(h).map(q => [p.cx + q[0], p.cy + q[1]]));
if (holes.length) addHoles(faces, holes, p.z0, p.z1, p.color, p.alpha ?? 1);
return faces;
}
// 세운 속 빈 원통 (32각형): 바깥면·안쪽면·윗고리·아랫고리, 각 32장씩
function facesRing(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const ring = (r, z) => Array.from({ length: N }, (_, i) => {
const a = 2 * Math.PI * i / N;
return [p.cx + r * Math.cos(a), p.cy + r * Math.sin(a), z];
});
const outTop = ring(p.r_out, p.z1), outBot = ring(p.r_out, p.z0);
const inTop = ring(p.r_in, p.z1), inBot = ring(p.r_in, p.z0);
const faces = [
{ pts: outTop, n: [0, 0, 1], color: p.color, alpha },
{ pts: outBot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const a = 2 * Math.PI * (i + 0.5) / N;
const c = Math.cos(a), s = Math.sin(a);
faces.push({ pts: [outBot[i], outBot[j], outTop[j], outTop[i]], n: [c, s, 0], color: p.color, alpha });
faces.push({ pts: [inBot[j], inBot[i], inTop[i], inTop[j]], n: [-c, -s, 0], color: p.color, alpha });
faces.push({ pts: [outTop[i], outTop[j], inTop[j], inTop[i]], n: [0, 0, 1], color: p.color, alpha });
faces.push({ pts: [outBot[i], inBot[i], inBot[j], outBot[j]], n: [0, 0, -1], color: p.color, alpha });
}
return faces;
}
// 누운 속 빈 원통 (32각형): 축 시작점(x, y, z)에서 angle_deg 방향으로 length 만큼
function facesPipe(p) {
const N = 32;
const alpha = p.alpha ?? 1;
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a), 0];
const u = [-Math.sin(a), Math.cos(a), 0];
// 축 위치 t 에서, 반지름 r, 각도 th 인 점 (u 는 수평 직각 방향, 위는 z)
const pt = (t, r, th) => [
p.x + d[0] * t + r * Math.cos(th) * u[0],
p.y + d[1] * t + r * Math.cos(th) * u[1],
p.z + r * Math.sin(th),
];
const ring = (t, r) => Array.from({ length: N }, (_, i) => pt(t, r, 2 * Math.PI * i / N));
const outA = ring(0, p.r_out), outB = ring(p.length, p.r_out);
const inA = ring(0, p.r_in), inB = ring(p.length, p.r_in);
const faces = [];
for (let i = 0; i < N; i++) {
const j = (i + 1) % N;
const th = 2 * Math.PI * (i + 0.5) / N;
const n = [Math.cos(th) * u[0], Math.cos(th) * u[1], Math.sin(th)];
faces.push({ pts: [outA[i], outA[j], outB[j], outB[i]], n, color: p.color, alpha });
faces.push({ pts: [inB[i], inB[j], inA[j], inA[i]], n: n.map(x => -x), color: p.color, alpha });
faces.push({ pts: [outA[i], outA[j], inA[j], inA[i]], n: [-d[0], -d[1], 0], color: p.color, alpha });
faces.push({ pts: [outB[i], inB[i], inB[j], outB[j]], n: [d[0], d[1], 0], color: p.color, alpha });
}
return faces;
}
// 터널 안 채움: 원을 수평선 v0·v1 로 자른 단면을 축 방향으로 늘인다. half_width 가 0 이 아니면 그 폭 안으로만
function facesFill(p) {
const N = 8;
const alpha = p.alpha ?? 1;
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a), 0];
const u = [-Math.sin(a), Math.cos(a), 0];
// 높이 v 에서의 반폭 (원 안쪽, half_width 가 있으면 그 안)
const half = v => {
const w = Math.sqrt(Math.max(0, p.r * p.r - v * v));
return p.half_width > 0 ? Math.min(w, p.half_width) : w;
};
// 단면 윤곽 (u, v), 반시계: 오른쪽 옆을 위로, 왼쪽 옆을 아래로. 아랫선·윗선은 두 점을 잇는다
const vs = Array.from({ length: N + 1 }, (_, k) => p.v0 + (p.v1 - p.v0) * k / N);
const prof = [
...vs.map(v => [half(v), v]),
...vs.slice().reverse().map(v => [-half(v), v]),
];
// 겹치는 점 (아랫선 폭 0 등) 은 뺀다. 길이 0 인 면은 법선을 못 구한다
const poly = prof.filter((q, i) => {
const r = prof[(i + prof.length - 1) % prof.length];
return Math.hypot(q[0] - r[0], q[1] - r[1]) > 1e-9;
});
// 축 위치 t 에서 단면 한 바퀴의 점들
const ring = t => poly.map(([uu, vv]) => [
p.x + d[0] * t + uu * u[0],
p.y + d[1] * t + uu * u[1],
p.z + vv,
]);
const A = ring(0), B = ring(p.length);
const faces = [
{ pts: A, n: [-d[0], -d[1], 0], color: p.color, alpha },
{ pts: B, n: [d[0], d[1], 0], color: p.color, alpha },
];
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const du = poly[j][0] - poly[i][0], dv = poly[j][1] - poly[i][1];
const len = Math.hypot(du, dv);
// 단면 윤곽의 바깥 법선 (반시계 기준), 수평 성분은 u 방향으로 펼친다
const nu = dv / len, nv = -du / len;
faces.push({ pts: [A[i], A[j], B[j], B[i]], n: [nu * u[0], nu * u[1], nv], color: p.color, alpha });
}
return faces;
}
// 다각형을 z0~z1 로 세운 기둥 (SPEC 16.1 prism): 윗면·아랫면 뚜껑 2장 + 변마다 옆면. 면의 바깥 법선은 다각형 방향(넓이 부호)으로 정한다
function facesPrism(p) {
const alpha = p.alpha ?? 1;
const poly = p.polygon;
const area = poly.reduce((s, q, i) => {
const r = poly[(i + 1) % poly.length];
return s + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const top = poly.map(q => [q[0], q[1], p.z1]);
const bot = poly.map(q => [q[0], q[1], p.z0]);
const faces = [
{ pts: top, n: [0, 0, 1], color: p.color, alpha },
{ pts: bot, n: [0, 0, -1], color: p.color, alpha },
];
for (let i = 0; i < poly.length; i++) {
const j = (i + 1) % poly.length;
const dx = poly[j][0] - poly[i][0], dy = poly[j][1] - poly[i][1];
const len = Math.hypot(dx, dy);
faces.push({ pts: [bot[i], bot[j], top[j], top[i]], n: [sign * -dy / len, sign * dx / len, 0], color: p.color, alpha });
}
if (p.holes) addHoles(faces, p.holes.map(holePolygon), p.z0, p.z1, p.color, alpha);
return faces;
}
// 원통(반지름 r) 안에서 x_min ≤ X ≤ x_max 인 덩어리 (SPEC 16.1 zone): 위아래 호를 따라 점을 찍어 prism 으로 세운다 (활꼴 기둥)
function facesZone(p) {
const N = 24;
const xs = Array.from({ length: N + 1 }, (_, k) => p.x_min + (p.x_max - p.x_min) * k / N);
const up = xs.map(x => [x, Math.sqrt(Math.max(0, p.r * p.r - x * x))]);
const low = xs.slice().reverse().map(x => [x, -Math.sqrt(Math.max(0, p.r * p.r - x * x))]);
return facesPrism({ ...p, polygon: [...up, ...low] });
}
// 사다리 (SPEC 16.1 ladder): 기둥 2개(X x0~x1, Y 는 yc ± width/2 를 가운데로 두께 40), 가로대(rung 간격, 두께 25, Y 는 width 폭)
function facesLadder(p) {
const faces = [];
const half = p.width / 2;
for (const c of [p.yc - half, p.yc + half]) {
faces.push(...facesBox(p.x0, c - 20, p.z0, p.x1, c + 20, p.z1, p.color, p.alpha ?? 1));
}
for (let z = p.z0 + p.rung; z + 12.5 <= p.z1; z += p.rung) {
faces.push(...facesBox(p.x0, p.yc - half, z - 12.5, p.x1, p.yc + half, z + 12.5, p.color, p.alpha ?? 1));
}
// angle_deg 가 있으면 원점 (0, 0) 기준으로 돌린다 (SPEC 26장). 없으면 0 이라 그대로. 법선도 같이 돈다
const a = (p.angle_deg ?? 0) * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const turn = v => [v[0] * cs - v[1] * sn, v[0] * sn + v[1] * cs, v[2]];
return faces.map(f => ({ ...f, pts: f.pts.map(turn), n: turn(f.n) }));
}
// 돌린 직육면체 (SPEC 17.2 obox): 상자를 (s, t, z) 로 만든 뒤 angle_deg 만큼 X축 반시계로 돌려 (cx, cy) 에 놓는다. 법선도 같이 돈다
function facesObox(p) {
const a = p.angle_deg * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
const turn = v => [v[0] * cs - v[1] * sn, v[0] * sn + v[1] * cs, v[2]];
return facesBox(p.s0, p.t0, p.z0, p.s1, p.t1, p.z1, p.color, p.alpha ?? 1).map(f => ({
...f,
pts: f.pts.map(q => { const r = turn(q); return [r[0] + p.cx, r[1] + p.cy, r[2]]; }),
n: turn(f.n),
}));
}
// 방호 난간 (SPEC 17.3 railing): 꺾은선 paths 의 각 구간을 두께 40 의 얇은 판으로 z 부터 z+h 까지 세운다. 판마다 옆 2·끝 2·윗면·아랫면 6장
function facesRailing(p) {
const T = 20; // 판 두께의 절반 (mm)
const alpha = p.alpha ?? 1;
const zb = p.z, zt = p.z + p.h;
const faces = [];
for (const path of p.paths) {
for (let i = 0; i + 1 < path.length; i++) {
const [ax, ay] = path[i], [bx, by] = path[i + 1];
const len = Math.hypot(bx - ax, by - ay);
if (len === 0) continue;
const dx = (bx - ax) / len, dy = (by - ay) / len;
// 판 두께 방향 단위 벡터
const ux = -dy, uy = dx;
// 구간 양 끝에서 두께 방향으로 ±T 간 네 모서리 (0: A+u, 1: B+u, 2: B-u, 3: A-u)
const q = [[ax + ux * T, ay + uy * T], [bx + ux * T, by + uy * T], [bx - ux * T, by - uy * T], [ax - ux * T, ay - uy * T]];
const up = k => [q[k][0], q[k][1], zt];
const dn = k => [q[k][0], q[k][1], zb];
faces.push({ pts: [dn(0), dn(1), up(1), up(0)], n: [ux, uy, 0], color: p.color, alpha });
faces.push({ pts: [dn(2), dn(3), up(3), up(2)], n: [-ux, -uy, 0], color: p.color, alpha });
faces.push({ pts: [dn(1), dn(2), up(2), up(1)], n: [dx, dy, 0], color: p.color, alpha });
faces.push({ pts: [dn(3), dn(0), up(0), up(3)], n: [-dx, -dy, 0], color: p.color, alpha });
faces.push({ pts: [up(0), up(1), up(2), up(3)], n: [0, 0, 1], color: p.color, alpha });
faces.push({ pts: [dn(0), dn(3), dn(2), dn(1)], n: [0, 0, -1], color: p.color, alpha });
}
}
return faces;
}
// 콘크리트 계단 한 런의 단면 (SPEC 18.2): [s, z] 점 목록. s 는 x_top 에서 내려가는 방향 거리
// 마지막 점은 첫 점으로 되돌아오지 않는다 (다각형은 저절로 닫힌다)
function stairProfile(p) {
const n = p.risers, zh = p.z_high, zl = p.z_low;
const pts = [[0, zh]];
for (let j = 0; j <= n - 2; j++) {
const z = zh - p.riser * (j + 1);
pts.push([j * p.tread, z], [(j + 1) * p.tread, z]);
}
pts.push([(n - 1) * p.tread, zl]);
const sBottom = (zh - zl - 400) * 1.5;
pts.push([sBottom, zl], [0, zh - 400]);
return pts;
}
// 계단 한 런: 단면을 Y 띠(y0~y1) 폭만큼 늘인다. 옆면 2개 + 둘레 변마다 사각형
function facesStair(p) {
const alpha = p.alpha ?? 1;
const X = s => p.x_top + p.dir * s;
const prof = stairProfile(p).map(([s, z]) => [X(s), z]);
const area = prof.reduce((acc, q, i) => {
const r = prof[(i + 1) % prof.length];
return acc + q[0] * r[1] - r[0] * q[1];
}, 0);
const sign = area > 0 ? 1 : -1;
const yLo = Math.min(p.y0, p.y1), yHi = Math.max(p.y0, p.y1);
const faces = [
{ pts: prof.map(([x, z]) => [x, yLo, z]), n: [0, -1, 0], color: p.color, alpha },
{ pts: prof.map(([x, z]) => [x, yHi, z]), n: [0, 1, 0], color: p.color, alpha },
];
for (let i = 0; i < prof.length; i++) {
const j = (i + 1) % prof.length;
const dx = prof[j][0] - prof[i][0], dz = prof[j][1] - prof[i][1];
const len = Math.hypot(dx, dz);
const n = [sign * dz / len, 0, sign * -dx / len];
faces.push({
pts: [[prof[i][0], yLo, prof[i][1]], [prof[j][0], yLo, prof[j][1]],
[prof[j][0], yHi, prof[j][1]], [prof[i][0], yHi, prof[i][1]]],
n, color: p.color, alpha,
});
}
return faces;
}
// 모든 부재의 면을 한 줄로 모은다 (disc, ring, pipe, stair, fill, prism, zone, ladder, obox, railing)
function buildFaces(parts) {
const faces = [];
for (const p of parts) {
if (p.type === 'disc') faces.push(...facesDiscHoles(p));
else if (p.type === 'ring') faces.push(...facesRing(p));
else if (p.type === 'pipe') faces.push(...facesPipe(p));
else if (p.type === 'stair') faces.push(...facesStair(p));
else if (p.type === 'fill') faces.push(...facesFill(p));
else if (p.type === 'prism') faces.push(...facesPrism(p));
else if (p.type === 'zone') faces.push(...facesZone(p));
else if (p.type === 'ladder') faces.push(...facesLadder(p));
else if (p.type === 'obox') faces.push(...facesObox(p));
else if (p.type === 'railing') faces.push(...facesRailing(p));
}
return faces;
}
// 면 밝기 계수: 법선이 빛 쪽을 향할수록 밝다 (0.4 ~ 1.0)
function faceBrightness(n) {
const v = rotatePoint(n);
const nv = [v[0], v[1], -v[2]];
const len = Math.hypot(LIGHT[0], LIGHT[1], LIGHT[2]);
const d = (nv[0] * LIGHT[0] + nv[1] * LIGHT[1] + nv[2] * LIGHT[2]) / len;
return 0.4 + 0.6 * Math.max(0, d);
}
// '#rrggbb' 색을 계수 k 만큼 밝기 조절
function shadeColor(hex, k) {
const n = parseInt(hex.slice(1), 16);
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255]
.map(x => Math.min(255, Math.round(x * k)));
return `rgb(${ch[0]}, ${ch[1]}, ${ch[2]})`;
}
// 먼 면부터 (화가 알고리즘): 면 가운데 점의 깊이가 큰 것부터
function sortFaces(faces) {
for (const f of faces) {
const depths = f.pts.map(p => rotatePoint(p)[2]);
f.depth = depths.reduce((a, b) => a + b, 0) / depths.length;
}
return faces.sort((a, b) => b.depth - a.depth);
}
function draw() {
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, canvas.width, canvas.height);
SCALE = scaleFor(ZOOM);
// 선택된 부재는 노란색·불투명으로 그린다
const parts = MODEL.parts.map(p => SELECTED.includes(p.id) ? { ...p, color: '#ffd400', alpha: 1 } : p);
// 사람 두 명은 멈춰 있어도 마지막 위치에 그린다 (계단이 없으면 경로가 없어 사람을 그리지 않는다)
const team = HAS_WALK ? teamAt(WALK_T) : [];
const people = team.flatMap((pose, i) => facesPerson(pose.pos, pose.h, WALK_COLORS[i]));
const faces = sortFaces([...buildFaces(parts), ...people]);
for (const f of faces) {
ctx.beginPath();
// 구멍 고리는 바깥 고리와 같은 경로에 넣고 evenodd 로 채워 구멍을 뚫는다
for (const ring of [f.pts, ...(f.holes || [])]) {
ring.forEach((p, i) => {
const [x, y] = project(rotatePoint(p));
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
}
ctx.globalAlpha = f.alpha;
ctx.fillStyle = shadeColor(f.color, faceBrightness(f.n));
ctx.fill(f.holes ? 'evenodd' : 'nonzero');
}
ctx.globalAlpha = 1;
}
// 드래그 궤도 회전: 좌우 = Z축 둘레(yaw), 위아래 = 기울기(pitch)
const ROT_RATE = 0.01; // 픽셀당 라디안
const PITCH_LIMIT = 89 * Math.PI / 180;
let dragging = false, panning = false, lastX = 0, lastY = 0;
// 왼쪽 드래그 = 회전. 오른쪽 버튼 또는 Shift + 왼쪽 = 화면 이동 (SPEC 15.3)
function onMouseDown(e) {
panning = e.button === 2 || e.shiftKey;
dragging = !panning;
lastX = e.clientX;
lastY = e.clientY;
}
function onMouseMove(e) {
if (!dragging && !panning) return;
if (panning) {
panView(e.clientX - lastX, e.clientY - lastY);
} else {
CAM.yaw += (e.clientX - lastX) * ROT_RATE;
CAM.pitch = Math.max(-PITCH_LIMIT, Math.min(PITCH_LIMIT, CAM.pitch + (e.clientY - lastY) * ROT_RATE));
}
lastX = e.clientX;
lastY = e.clientY;
draw();
}
function onMouseUp() {
dragging = false;
panning = false;
}
// 화면 이동: 드래그한 만큼 그림이 따라오게 보는 중심(target)을 화면 평면에서 옮긴다 (픽셀 → mm)
function panView(dx, dy) {
const d = unrotateVector([-dx / SCALE, dy / SCALE, 0]);
CAM.target = CAM.target.map((x, i) => x + d[i]);
}
// 마우스 아래의 점이 그대로 머물게 배율을 step 배 바꾼다 (배율 0.5~40). 보는 중심을 옮겨서 맞춘다
function zoomAt(clientX, clientY, step) {
const rect = canvas.getBoundingClientRect();
const mx = clientX - rect.left, my = clientY - rect.top;
const old = scaleFor(ZOOM);
ZOOM = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, ZOOM * step));
// 화면 가운데에서 마우스까지의 거리(픽셀)가 배율에 따라 달라지는 만큼 보는 중심을 옮긴다
const k = 1 / old - 1 / scaleFor(ZOOM);
const d = unrotateVector([(mx - canvas.width / 2) * k, (canvas.height / 2 - my) * k, 0]);
CAM.target = CAM.target.map((x, i) => x + d[i]);
}
// 처음 화면: 등각, 배율 1, 보는 중심 = 모델 중심 (더블클릭 또는 「처음 화면」 버튼)
function resetView() {
Object.assign(CAM, CAM_HOME, { target: [...MODEL_CENTER] });
ZOOM = 1;
draw();
}
// 휠: 위로 굴리면 확대, 아래로 굴리면 축소 (한 칸당 10%, 마우스 아래 점 고정)
function onWheel(e) {
e.preventDefault();
zoomAt(e.clientX, e.clientY, e.deltaY < 0 ? 1.1 : 1 / 1.1);
draw();
}
// 가운데 막대 끌기: 왼쪽 도면 칸 폭을 창 폭의 15%~85% 로. 바뀔 때마다 3D 캔버스를 다시 맞춘다
let splitDragging = false;
function onSplitDown(e) {
splitDragging = true;
e.preventDefault();
}
function onSplitMove(e) {
if (!splitDragging) return;
const frac = Math.max(0.15, Math.min(0.85, e.clientX / window.innerWidth));
drawPane.style.width = (frac * 100) + '%';
resize();
}
function onSplitUp() {
splitDragging = false;
}
// 도면 확대·이동: 1 = 도면 SVG 원래 크기(443px), 0.5 ~ 40 사이에서만 바뀐다
const DRAW_SIZE = 443;
const DRAW_ZOOM_MIN = 0.5, DRAW_ZOOM_MAX = 40;
// 도면 칸 안에서의 배율(s)과 이동(tx, ty, 칸 픽셀)
let DRAW = { s: 1, tx: 0, ty: 0 };
let drawDragging = false, drawLastX = 0, drawLastY = 0;
// 이번 드래그에서 실제로 움직였는지 (움직였으면 클릭으로 치지 않는다)
let drawMoved = false;
// 배율은 크기(width/height)에 넣어 브라우저가 벡터를 다시 그리게 한다. 위치만 translate
function applyDrawView() {
const px = DRAW_SIZE * DRAW.s + 'px';
for (const el of [drawImg, hotspots]) {
el.style.width = px;
el.style.height = px;
}
drawStage.style.transform = `translate(${DRAW.tx}px, ${DRAW.ty}px)`;
}
// 처음 화면: 도면 fit 상자(MODEL.drawing.fit)가 왼쪽 칸에 꽉 차게. fit 이 아직 없으면 도면 전체
function fitDrawing() {
const [u0, v0, u1, v1] = (MODEL.drawing && MODEL.drawing.fit) || [0, 0, DRAW_SIZE, DRAW_SIZE];
const W = drawPane.clientWidth, H = drawPane.clientHeight;
const s = Math.min(W / (u1 - u0), H / (v1 - v0));
DRAW = { s, tx: W / 2 - s * (u0 + u1) / 2, ty: H / 2 - s * (v0 + v1) / 2 };
applyDrawView();
}
// 휠: 마우스 아래의 도면 점이 그대로 머물게 확대/축소
function onDrawWheel(e) {
e.preventDefault();
const step = e.deltaY < 0 ? 1.1 : 1 / 1.1;
const s = Math.max(DRAW_ZOOM_MIN, Math.min(DRAW_ZOOM_MAX, DRAW.s * step));
const rect = drawPane.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const k = s / DRAW.s;
DRAW = { s, tx: mx - (mx - DRAW.tx) * k, ty: my - (my - DRAW.ty) * k };
applyDrawView();
}
// 드래그: 도면 이동
function onDrawDown(e) {
drawDragging = true;
drawMoved = false;
drawLastX = e.clientX;
drawLastY = e.clientY;
e.preventDefault();
}
function onDrawMove(e) {
if (!drawDragging) return;
drawMoved = true;
DRAW.tx += e.clientX - drawLastX;
DRAW.ty += e.clientY - drawLastY;
drawLastX = e.clientX;
drawLastY = e.clientY;
applyDrawView();
}
function onDrawUp() {
drawDragging = false;
}
// 도면 클릭 영역 (부재 종류별 순서: 계단 → 벽 → 터널 → 판. 겹치면 뒤에 그린 것이 위)
const HOT_ORDER = { stair: 0, wall: 1, tunnel: 2, slab: 3, pad: 3, bottom: 3, lean: 3 };
// 영역 하나: 부재 id 들과 그 영역의 다각형들. 다각형이 없으면 영역도 없다
const region = (ids, polys) => (ids.length && polys.length ? [{ ids, polys }] : []);
// 다각형 점 배열 → SVG path 의 d (닫힌 선)
const pathD = poly => 'M' + poly.map(q => q.join(',')).join('L') + 'Z';
// 상자(obox) 의 바닥 윤곽 네 꼭짓점 (mm). s, t 를 angle_deg 만큼 돌리고 cx, cy 에 놓는다. facesObox 와 같은 회전
function obox4(p) {
const a = p.angle_deg * Math.PI / 180;
const cs = Math.cos(a), sn = Math.sin(a);
return [[p.s0, p.t0], [p.s1, p.t0], [p.s1, p.t1], [p.s0, p.t1]].map(([s, t]) => [p.cx + s * cs - t * sn, p.cy + s * sn + t * cs]);
}
// 부재가 덮는 X 범위 (mm). 계단은 ±4850, 집수조·침사조는 x_min~x_max, 사다리는 x0~x1, 월류벽·맨홀 목은 다각형의 X 범위, 상자는 꼭짓점의 X 범위
function xSpan(p) {
if (p.kind === 'stair') return [-4850, 4850];
if (p.kind === 'pit') return [p.x_min, p.x_max];
if (p.type === 'ladder') return [p.x0, p.x1];
if (p.kind === 'weir' || p.kind === 'neck') {
const xs = p.polygon.map(q => q[0]);
return [Math.min(...xs), Math.max(...xs)];
}
if (p.type === 'obox') {
const xs = obox4(p).map(q => q[0]);
return [Math.min(...xs), Math.max(...xs)];
}
return [p.cx - p.r, p.cx + p.r];
}
// 단면 그림에서 부재 하나의 영역 (도면 u = x0 + X/1000, v = y0 − z/1000, mm 입력). 다각형 배열을 돌려준다
// section_break 는 생략 구간(z_upper_min ~ z_lower_max)을 뺀다: 벽은 위·아래 조각 두 개, 나머지는 전부 위거나 전부 아래일 때만 그린다
function sectionPoly(p, view) {
const k = MODEL.drawing.section.mm_per_unit;
// 가로 u0~u1(mm), 세로 z0~z1(mm) 사각형, 기준선 y0
const rect = (y0, u0, u1, z0, z1) => [
[view.x0 + u0 / k, y0 - z0 / k], [view.x0 + u1 / k, y0 - z0 / k],
[view.x0 + u1 / k, y0 - z1 / k], [view.x0 + u0 / k, y0 - z1 / k],
];
if (p.kind === 'wall') {
// 벽은 두 띠 (안쪽 r_in ~ 바깥 r_out)
const bands = (y0, z0, z1) => [
rect(y0, p.cx - p.r_out, p.cx - p.r_in, z0, z1),
rect(y0, p.cx + p.r_in, p.cx + p.r_out, z0, z1),
];
if (view.type !== 'section_break') return bands(view.y0, p.z0, p.z1);
return [...bands(view.y0_upper, view.z_upper_min, p.z1), ...bands(view.y0_lower, p.z0, view.z_lower_max)];
}
// 계단은 윗단에서 한 단 낮은 곳(z_high − 300)까지만
const [uA, uB] = xSpan(p);
const [zA, zB] = p.kind === 'stair' ? [p.z_low, p.z_high - 300] : [p.z0, p.z1];
if (view.type !== 'section_break') return [rect(view.y0, uA, uB, zA, zB)];
if (zA >= view.z_upper_min) return [rect(view.y0_upper, uA, uB, zA, zB)];
if (zB <= view.z_lower_max) return [rect(view.y0_lower, uA, uB, zA, zB)];
return [];
}
// 평면 그림의 원 (32각형). 중심은 원점, r 은 mm
function circlePoly(view, r) {
const k = MODEL.drawing.plan.mm_per_unit;
return Array.from({ length: 32 }, (_, i) => {
const a = 2 * Math.PI * i / 32;
return [view.cx + r * Math.cos(a) / k, view.cy - r * Math.sin(a) / k];
});
}
// 평면 그림에서 부재 하나의 영역 (u = cx + x/1000, v = cy − y/1000, mm 입력). 벽은 고리(두 원, evenodd 로 가운데가 빈다), 터널은 사각형
function planPoly(p, view) {
if (p.kind === 'wall') return [circlePoly(view, p.r_out), circlePoly(view, p.r_in)];
// 축 시작점에서 angle_deg 방향으로 length, 폭 ±r_out 인 사각형
const a = p.angle_deg * Math.PI / 180;
const d = [Math.cos(a), Math.sin(a)], u = [-Math.sin(a), Math.cos(a)];
const at = (t, s) => planXY(view, p.x + d[0] * t + u[0] * s, p.y + d[1] * t + u[1] * s);
return [[at(0, -p.r_out), at(p.length, -p.r_out), at(p.length, p.r_out), at(0, p.r_out)]];
}
// 그림 한 장의 클릭 영역 목록 (영역 = { ids, polys }). 뒤에 나온 영역이 위에 온다
function viewHotspots(view) {
const ordered = MODEL.parts
.filter(p => p.kind in HOT_ORDER)
.sort((a, b) => HOT_ORDER[a.kind] - HOT_ORDER[b.kind]);
if (view.type === 'section' || view.type === 'section_break') {
return ordered.filter(p => p.kind !== 'tunnel')
.flatMap(p => region([p.id], sectionPoly(p, view)));
}
if (view.type === 'plan') {
return ordered.filter(p => p.kind === 'wall' || (p.kind === 'tunnel' && view.tunnel))
.flatMap(p => region([p.id], planPoly(p, view)));
}
if (view.type === 'slabplan') {
// 안쪽 원판: z1 상면이 같은 kind 부재 전부, 그 상면 위에 놓인 패드 (19B 패드는 19B 슬래브 상면에 놓인다)
const onTop = MODEL.parts.filter(p => (p.kind === view.kind && view.z1.some(z => Math.abs(p.z1 - z) < 1))
|| (p.kind === 'pad' && view.z1.some(z => Math.abs(p.z0 - z) < 1)));
const wall = MODEL.parts.find(p => p.kind === 'wall');
const tunnel = MODEL.parts.find(p => p.kind === 'tunnel');
return [
...region(onTop.map(p => p.id), [circlePoly(view, wall.r_in)]),
...region([wall.id], planPoly(wall, view)),
...(view.tunnel ? region([tunnel.id], planPoly(tunnel, view)) : []),
];
}
return [];
}
// 평면 그림의 점 하나 (X, Y 는 mm, 중심 기준). planPoly 와 같은 규칙
// 그림의 rot_deg 만큼 먼저 돌린다 (평면도 1·2 는 −30.3°, 없으면 0)
const planXY = (view, X, Y) => {
const a = (view.rot_deg || 0) * Math.PI / 180;
const Xr = X * Math.cos(a) - Y * Math.sin(a), Yr = X * Math.sin(a) + Y * Math.cos(a);
return [view.cx + Xr / MODEL.drawing.plan.mm_per_unit, view.cy - Yr / MODEL.drawing.plan.mm_per_unit];
};
// 하부 슬래브 평면에서 바닥부 부재 하나의 영역. 집수조·침사조는 원을 X 범위로 자른 활꼴(위 호 → 아래 호 되돌아오기)
function bottomPlanPoly(p, view) {
if (p.kind === 'weir') return [p.polygon.map(([X, Y]) => planXY(view, X, Y))];
if (p.kind === 'ladder') {
const h = p.width / 2;
return [[[p.x0, p.yc - h], [p.x1, p.yc - h], [p.x1, p.yc + h], [p.x0, p.yc + h]].map(([X, Y]) => planXY(view, X, Y))];
}
const xs = Array.from({ length: 17 }, (_, i) => p.x_min + (p.x_max - p.x_min) * i / 16);
const arc = sign => xs.map(X => planXY(view, X, sign * Math.sqrt(p.r * p.r - X * X)));
return [[...arc(1), ...arc(-1).reverse()]];
}
// 바닥부 클릭 영역 (SPEC 16.2). 단면 A-A 두 그림은 X·z 사각형, 하부 슬래브 평면(slab20)은 평면 규칙, 19B 평면(slab19)은 침사구 자리를 사다리로
// 순서: 집수조 → 침사조 → 월류벽 → 사다리 (뒤에 나온 것이 위에 온다)
function bottomHotspots(view) {
const ofKind = k => MODEL.parts.filter(p => p.kind === k);
const bottom = [...ofKind('pit'), ...ofKind('weir'), ...ofKind('ladder')];
if (view.id === 'secA' || view.id === 'secA2') return bottom.flatMap(p => region([p.id], sectionPoly(p, view)));
if (view.id === 'slab20') return bottom.flatMap(p => region([p.id], bottomPlanPoly(p, view)));
if (view.id === 'slab19') {
// 19B 슬래브 구멍 두 개 중 침사구(X 3000 너머)
const pit = MODEL.parts.find(p => p.id === 'slab_19B').holes.find(h => h[0][0] > 3000);
return region(['ladder_19B'], [pit.map(([X, Y]) => planXY(view, X, Y))]);
}
return [];
}
// 상부 부재(SPEC 17.4)의 클릭 영역. 단면 A-A 두 그림은 X·z 사각형(상부 슬래브는 원판 규칙), 평면도 1·2 는 원판(r 4850) → 맨홀 목 → 분기구·환기구 바닥 윤곽 순서
// 벽 고리·터널(viewHotspots)은 이 뒤에 와서 위에 온다
// 분기구·환기구는 같은 kind 전체를 한 덩어리로 선택한다 (어느 조각을 눌러도 같은 id 목록). 목·사다리는 하나씩
const KIND_LABEL = { branch: '분기구', vent: '환기구(강제)' };
function upperHotspots(view) {
const ofKind = k => MODEL.parts.filter(p => p.kind === k);
const group = p => (p.kind in KIND_LABEL ? ofKind(p.kind).map(q => q.id) : [p.id]);
if (view.id === 'secA' || view.id === 'secA2') {
const upper = [...ofKind('topslab'), ...ofKind('neck'), ...ofKind('branch'), ...ofKind('vent'), ...ofKind('ladder_up')];
return upper.flatMap(p => region(group(p), sectionPoly(p, view)));
}
if (view.id === 'plan1' || view.id === 'plan2') {
return [
...region(ofKind('topslab').map(p => p.id), [circlePoly(view, 4850)]),
...ofKind('neck').flatMap(p => region([p.id], [p.polygon.map(([X, Y]) => planXY(view, X, Y))])),
...[...ofKind('branch'), ...ofKind('vent')].flatMap(p => region(group(p), [obox4(p).map(([X, Y]) => planXY(view, X, Y))])),
];
}
return [];
}
// 모든 그림의 클릭 영역을 svg#hotspots 에 path 로 그린다 (fill 은 투명, 구멍은 evenodd)
// 순서: 상부 → 벽·터널 → 바닥부 (뒤에 나온 것이 위에 온다)
function buildHotspots() {
hotspots.innerHTML = MODEL.drawing.views.flatMap(view => [...upperHotspots(view), ...viewHotspots(view), ...bottomHotspots(view)].map(r =>
`<path data-ids="${r.ids.join(' ')}" data-view="${view.label}" fill-rule="evenodd" d="${r.polys.map(pathD).join('')}"/>`)).join('');
}
// 부재 여러 개 선택 (id 배열): 도면 영역 칠하기, 3D 노란색, 「선택: 그림 이름 — 부재 이름」. 빈 배열이면 해제
function selectPart(ids, viewLabel) {
SELECTED = ids;
for (const el of hotspots.querySelectorAll('path')) {
const mine = el.getAttribute('data-ids').split(' ');
el.classList.toggle('on', mine.some(id => ids.includes(id)));
}
const names = ids.map(id => MODEL.parts.find(p => p.id === id).name);
// 여럿이면 처음 둘과 마지막만 (예: 1B 슬래브, 3B 슬래브, … 17B 슬래브)
const list = names.length > 3 ? `${names[0]}, ${names[1]}, … ${names[names.length - 1]}` : names.join(', ');
// 분기구·환기구 덩어리는 kind 이름으로 (예: 「선택: 평면도 — 분기구」)
const first = ids.length ? MODEL.parts.find(p => p.id === ids[0]) : null;
const whole = first && first.kind in KIND_LABEL && ids.length === MODEL.parts.filter(p => p.kind === first.kind).length;
partLabel.textContent = names.length ? `선택: ${viewLabel} — ${whole ? KIND_LABEL[first.kind] : list}` : '';
draw();
}
// 도면 파일이 있으면 안내 글자를 지우고 도면을 처음 화면에 맞춘다 (없으면 안내 글자 그대로)
drawImg.onload = () => {
drawMsg.style.display = 'none';
drawStage.style.display = 'block';
fitDrawing();
};
// 도면 파일 경로는 모델의 drawing.src 가 정한다 (없으면 private/drawing.svg)
drawImg.onerror = () => {
drawMsg.textContent = `도면 파일 없음 — ${drawImg.getAttribute('src')} 를 넣으세요`;
};
if (MODEL.drawing) drawImg.src = MODEL.drawing.src || 'private/drawing.svg';
// 클릭: 영역 위면 그 영역의 부재들 선택, 영역 밖이면 해제 (드래그 뒤의 클릭은 무시)
hotspots.addEventListener('click', e => {
if (drawMoved) return;
const ids = e.target.getAttribute('data-ids');
selectPart(ids ? ids.split(' ') : [], e.target.getAttribute('data-view'));
});
drawPane.addEventListener('wheel', onDrawWheel, { passive: false });
drawPane.addEventListener('mousedown', onDrawDown);
window.addEventListener('mousemove', onDrawMove);
window.addEventListener('mouseup', onDrawUp);
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('dblclick', resetView);
document.getElementById('resetView').addEventListener('click', resetView);
splitter.addEventListener('mousedown', onSplitDown);
window.addEventListener('mousemove', onSplitMove);
window.addEventListener('mouseup', onSplitUp);
window.addEventListener('resize', resize);
// 사람 애니메이션 (SPEC 15.4): 계단을 내려갔다 올라오기를 쉬지 않고 반복한다
const WALK_SPEED = 1000; // mm/s
const WALK_COLORS = ['#ff8c1a', '#e07010']; // 첫 사람, 두 번째 사람
const TEAM_LAG = 2000; // 두 번째 사람은 첫 사람보다 2초 뒤에 같은 길을 간다 (SPEC 18.1)
// 움직인 시간(ms). 멈추면 늘지 않는다
let WALK_T = 0;
let walking = true;
let lastNow = 0, lastDraw = 0;
const walkToggle = document.getElementById('walkToggle');
// 계단 18런으로 경로 한 바퀴를 만든다: 내려가는 길 다음에 같은 길을 되짚어 올라오는 길. 구간마다 시작·끝 점과 진행 방향(h)을 가진다
function buildWalkPath() {
const runs = MODEL.parts.filter(p => p.type === 'stair').sort((a, b) => b.z_high - a.z_high);
const down = [];
for (const p of runs) {
const n = p.risers, yc = (p.y0 + p.y1) / 2;
// 입구 앞 300 지점, 디딤판마다 가운데 점 (위 단부터), 아래 슬래브에 내려선 점
down.push([p.x_top - p.dir * 300, yc, p.z_high]);
for (let k = n - 1; k >= 1; k--) {
down.push([p.x_top + p.dir * 300 * (n - 1 - k + 0.5), yc, p.z_low + p.first_riser + p.riser * (k - 1)]);
}
down.push([p.x_top + p.dir * 300 * n, yc, p.z_low]);
}
// 올라올 때는 내려간 점을 거꾸로 (양 끝 점은 한 번만)
const pts = [...down, ...down.slice(1, -1).reverse()];
const segs = [];
let total = 0;
pts.forEach((a, i) => {
const b = pts[(i + 1) % pts.length];
const len = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
segs.push({ a, b, start: total, len, h: Math.atan2(b[1] - a[1], b[0] - a[0]) });
total += len;
});
return { segs, total };
}
const WALK = buildWalkPath();
// 계단이 하나도 없으면 (추출 모델) 걸을 경로가 없다 → 사람 그리기와 버튼을 모두 건너뛴다
const HAS_WALK = WALK.segs.length > 0;
// 시각 t(ms) 에서 발 위치 pos [x, y, z] 와 진행 방향 h (라디안). 경로 끝에 오면 처음으로 돌아간다
function walkerAt(t) {
// 음수 시각(두 번째 사람의 처음 2초)도 경로 안으로 접는다
const s = (((t * WALK_SPEED / 1000) % WALK.total) + WALK.total) % WALK.total;
const seg = WALK.segs.find(g => s < g.start + g.len) || WALK.segs[WALK.segs.length - 1];
const f = seg.len ? (s - seg.start) / seg.len : 0;
return { pos: seg.a.map((v, i) => v + (seg.b[i] - v) * f), h: seg.h };
}
// 두 사람 (SPEC 18.1): [첫 사람, 두 번째 사람] 의 발 위치·진행 방향. 두 번째는 TEAM_LAG 뒤 시각
function teamAt(t) {
return [walkerAt(t), walkerAt(t - TEAM_LAG)];
}
// 사람 한 명 (SPEC 15.4): 다리 2·몸통·머리 상자 4개를 진행 방향(h)에 맞춰 돌린 면들. 색은 불투명 color (SPEC 18.1)
function facesPerson(pos, h, color) {
const c = Math.cos(h), s = Math.sin(h);
// 몸 기준 (a 앞쪽, b 왼쪽, z 위) → 세계 좌표
const W = (a, b, z) => [pos[0] + a * c - b * s, pos[1] + a * s + b * c, pos[2] + z];
// 상자 하나: 몸 기준 범위 a0~a1, b0~b1, z0~z1 (법선도 같이 돌린다)
const box = (a0, a1, b0, b1, z0, z1) => [
{ pts: [W(a0, b0, z1), W(a1, b0, z1), W(a1, b1, z1), W(a0, b1, z1)], n: [0, 0, 1] },
{ pts: [W(a0, b0, z0), W(a1, b0, z0), W(a1, b1, z0), W(a0, b1, z0)], n: [0, 0, -1] },
{ pts: [W(a1, b0, z0), W(a1, b1, z0), W(a1, b1, z1), W(a1, b0, z1)], n: [c, s, 0] },
{ pts: [W(a0, b0, z0), W(a0, b1, z0), W(a0, b1, z1), W(a0, b0, z1)], n: [-c, -s, 0] },
{ pts: [W(a0, b1, z0), W(a1, b1, z0), W(a1, b1, z1), W(a0, b1, z1)], n: [-s, c, 0] },
{ pts: [W(a0, b0, z0), W(a1, b0, z0), W(a1, b0, z1), W(a0, b0, z1)], n: [s, -c, 0] },
].map(f => ({ ...f, color, alpha: 1 }));
return [
...box(-75, 75, 50, 200, 0, 800), // 다리 (왼쪽, 두 다리 사이 100)
...box(-75, 75, -200, -50, 0, 800), // 다리 (오른쪽)
...box(-125, 125, -200, 200, 800, 1400), // 몸통 (앞뒤 250, 좌우 400)
...box(-110, 110, -110, 110, 1450, 1670), // 머리 220
];
}
// 움직이는 동안 requestAnimationFrame 으로 시간을 더하고, 초당 30번 이하로 다시 그린다
function animate(now) {
if (!walking) return;
WALK_T += now - lastNow;
lastNow = now;
if (now - lastDraw >= 1000 / 30) {
lastDraw = now;
draw();
}
requestAnimationFrame(animate);
}
// 움직임 켜기·끄기. 버튼 글자도 바꾼다 (움직일 때 「❚❚」, 멈췄을 때 「▶」)
function setWalking(on) {
walking = on;
walkToggle.textContent = on ? '사람 ❚❚' : '사람 ▶';
if (on) {
lastNow = performance.now();
requestAnimationFrame(animate);
}
}
walkToggle.addEventListener('click', () => setWalking(!walking));
// 도면 연결 데이터(MODEL.drawing)가 없는 모델(model_extracted.js)이면 왼쪽 칸은 안내 글자만 둔다
if (MODEL.drawing) buildHotspots();
if (!HAS_WALK) walkToggle.hidden = true;
setWalking(HAS_WALK);
resize();