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 => ``)).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();