- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋. - 옛 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>
923 lines
41 KiB
JavaScript
923 lines
41 KiB
JavaScript
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();
|