Files
wingwall_3D_drawing/static/js/cad2d.js
T

317 lines
10 KiB
JavaScript

class CAD2DViewer {
constructor(canvasId, onSelectCallback) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
this.onSelect = onSelectCallback;
this.drawingData = null;
this.activeLayers = new Set();
this.highlightedView = null;
this.needsFit = true;
// View transform: pan & zoom
this.scale = 1.0;
this.offsetX = 0;
this.offsetY = 0;
this.isDragging = false;
this.startX = 0;
this.startY = 0;
this.initEvents();
}
initEvents() {
this.canvas.addEventListener('mousedown', (e) => {
this.isDragging = true;
this.startX = e.clientX - this.offsetX;
this.startY = e.clientY - this.offsetY;
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
this.offsetX = e.clientX - this.startX;
this.offsetY = e.clientY - this.startY;
this.render();
});
window.addEventListener('mouseup', () => {
this.isDragging = false;
});
this.canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = this.canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const zoomFactor = e.deltaY < 0 ? 1.15 : 0.87;
const newScale = Math.max(0.1, Math.min(this.scale * zoomFactor, 30.0));
this.offsetX = mouseX - (mouseX - this.offsetX) * (newScale / this.scale);
this.offsetY = mouseY - (mouseY - this.offsetY) * (newScale / this.scale);
this.scale = newScale;
this.render();
});
this.canvas.addEventListener('click', (e) => {
if (Math.abs(e.clientX - (this.startX + this.offsetX)) > 5) return;
this.handleClick(e);
});
window.addEventListener('resize', () => this.resizeCanvas());
}
resizeCanvas() {
const parent = this.canvas.parentElement;
if (!parent) return;
const w = parent.clientWidth;
const h = parent.clientHeight;
if (w === 0 || h === 0) return;
this.canvas.width = w;
this.canvas.height = h;
if (this.needsFit) {
this.fitView();
this.needsFit = false;
} else {
this.render();
}
}
loadDrawing(drawingData) {
if (!drawingData) return;
this.drawingData = drawingData;
this.activeLayers = new Set(Object.keys(drawingData.layers || {}));
this.highlightedView = null;
this.needsFit = true;
const parent = this.canvas.parentElement;
if (parent && parent.clientWidth > 0 && parent.clientHeight > 0) {
this.canvas.width = parent.clientWidth;
this.canvas.height = parent.clientHeight;
this.fitView();
this.needsFit = false;
}
}
setLayerVisibility(layerName, isVisible) {
if (isVisible) {
this.activeLayers.add(layerName);
} else {
this.activeLayers.delete(layerName);
}
this.render();
}
fitView() {
if (!this.drawingData || !this.drawingData.bounds) return;
if (this.canvas.width === 0 || this.canvas.height === 0) {
this.needsFit = true;
return;
}
const b = this.drawingData.bounds;
const dwgW = Math.max(10, b.max_x - b.min_x);
const dwgH = Math.max(10, b.max_y - b.min_y);
const pad = 44;
const scaleX = (this.canvas.width - pad * 2) / dwgW;
const scaleY = (this.canvas.height - pad * 2) / dwgH;
this.scale = Math.max(0.1, Math.min(scaleX, scaleY));
this.offsetX = (this.canvas.width - dwgW * this.scale) / 2 - b.min_x * this.scale;
this.offsetY = (this.canvas.height + dwgH * this.scale) / 2 + b.min_y * this.scale;
this.render();
}
zoomToView(viewId) {
this.highlightedView = viewId;
if (!this.drawingData || !this.drawingData.views || !this.drawingData.views[viewId]) {
this.render();
return;
}
if (this.canvas.width > 0 && this.canvas.height > 0) {
const [bx0, by0, bx1, by1] = this.drawingData.views[viewId].bbox;
const vw = Math.max(10, bx1 - bx0);
const vh = Math.max(10, by1 - by0);
const pad = 60;
const scaleX = (this.canvas.width - pad * 2) / vw;
const scaleY = (this.canvas.height - pad * 2) / vh;
this.scale = Math.max(0.2, Math.min(scaleX, scaleY, 12.0));
this.offsetX = (this.canvas.width - vw * this.scale) / 2 - bx0 * this.scale;
this.offsetY = (this.canvas.height + vh * this.scale) / 2 + by0 * this.scale;
}
this.render();
}
worldToScreen(x, y) {
return {
x: x * this.scale + this.offsetX,
y: -y * this.scale + this.offsetY
};
}
screenToWorld(sx, sy) {
return {
x: (sx - this.offsetX) / this.scale,
y: -(sy - this.offsetY) / this.scale
};
}
highlightView(viewId) {
this.highlightedView = viewId;
this.render();
}
handleClick(e) {
if (!this.drawingData || !this.drawingData.views) return;
const rect = this.canvas.getBoundingClientRect();
const world = this.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
for (const [vid, view] of Object.entries(this.drawingData.views)) {
const [bx0, by0, bx1, by1] = view.bbox;
if (world.x >= bx0 && world.x <= bx1 && world.y >= by0 && world.y <= by1) {
this.highlightedView = vid;
this.render();
if (view.wingwall && this.onSelect) {
this.onSelect(view.wingwall);
} else if (vid === 'PLAN' && this.onSelect) {
this.onSelect('WW_END_LEFT');
}
return;
}
}
}
render() {
const ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (!this.drawingData) return;
ctx.lineWidth = Math.max(1.0, 0.75 * this.scale);
for (const ent of this.drawingData.entities || []) {
if (!this.activeLayers.has(ent.layer)) continue;
ctx.strokeStyle = ent.color || '#cccccc';
ctx.fillStyle = ent.color || '#cccccc';
if (ent.type === 'LINE') {
const p1 = this.worldToScreen(ent.p1[0], ent.p1[1]);
const p2 = this.worldToScreen(ent.p2[0], ent.p2[1]);
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
} else if (ent.type === 'LWPOLYLINE') {
if (!ent.points || ent.points.length === 0) continue;
ctx.beginPath();
const p0 = this.worldToScreen(ent.points[0][0], ent.points[0][1]);
ctx.moveTo(p0.x, p0.y);
for (let i = 1; i < ent.points.length; i++) {
const pt = this.worldToScreen(ent.points[i][0], ent.points[i][1]);
ctx.lineTo(pt.x, pt.y);
}
if (ent.closed) ctx.closePath();
ctx.stroke();
} else if (ent.type === 'TEXT') {
const pos = this.worldToScreen(ent.pos[0], ent.pos[1]);
const fontSize = Math.max(7, ent.height * this.scale * 0.85);
if (fontSize < 3.5) continue;
ctx.font = `${Math.min(fontSize, 26)}px -apple-system, "Malgun Gothic", sans-serif`;
ctx.fillText(ent.text, pos.x, pos.y);
} else if (ent.type === 'ARC' || ent.type === 'CIRCLE') {
const c = this.worldToScreen(ent.center[0], ent.center[1]);
const r = ent.radius * this.scale;
ctx.beginPath();
if (ent.type === 'CIRCLE') {
ctx.arc(c.x, c.y, r, 0, Math.PI * 2);
} else {
const sAngle = -THREE.MathUtils.degToRad(ent.end_angle);
const eAngle = -THREE.MathUtils.degToRad(ent.start_angle);
ctx.arc(c.x, c.y, r, sAngle, eAngle);
}
ctx.stroke();
}
}
// Draw View Boundary Boxes & Labels
for (const [vid, view] of Object.entries(this.drawingData.views || {})) {
const [bx0, by0, bx1, by1] = view.bbox;
const pTopLeft = this.worldToScreen(bx0, by1);
const pBotRight = this.worldToScreen(bx1, by0);
const w = pBotRight.x - pTopLeft.x;
const h = pBotRight.y - pTopLeft.y;
const isHigh = (this.highlightedView === vid);
ctx.strokeStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : 'rgba(148, 163, 184, 0.45)');
ctx.lineWidth = isHigh ? 2.5 : 1.0;
ctx.setLineDash(view.is_excluded ? [4, 4] : (isHigh ? [] : [3, 3]));
ctx.strokeRect(pTopLeft.x, pTopLeft.y, w, h);
ctx.setLineDash([]);
if (isHigh) {
ctx.fillStyle = 'rgba(56, 189, 248, 0.06)';
ctx.fillRect(pTopLeft.x, pTopLeft.y, w, h);
}
ctx.fillStyle = isHigh ? '#38bdf8' : (view.is_excluded ? '#ef4444' : '#94a3b8');
ctx.font = 'bold 11px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText(view.name, pTopLeft.x + 6, pTopLeft.y + 15);
}
// Draw Plan View Culvert & Wingwall Quadrant Overlay if available
const planView = this.drawingData.views?.PLAN;
const det = this.drawingData.structuralParams?.detection || planView?.detection;
if (planView && det && det.culvert_bounds) {
const cb = det.culvert_bounds;
const midY = cb.center_y || 416.5;
const startX = cb.start_x || 106.91;
const endX = cb.end_x || 162.93;
const pC0 = this.worldToScreen(startX, cb.top_dashed_y || 426.5);
const pC1 = this.worldToScreen(endX, cb.bot_dashed_y || 406.5);
const cW = pC1.x - pC0.x;
const cH = pC1.y - pC0.y;
ctx.strokeStyle = 'rgba(56, 189, 248, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.strokeRect(pC0.x, pC0.y, cW, cH);
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(56, 189, 248, 0.08)';
ctx.fillRect(pC0.x, pC0.y, cW, cH);
ctx.fillStyle = '#38bdf8';
ctx.font = 'bold 10px -apple-system, "Malgun Gothic", sans-serif';
ctx.fillText('🏢 암거 판별 (내공 2.0m & 점선)', pC0.x + 4, pC0.y - 4);
// Label detected wingwalls
const quads = det.quadrants || {};
if (quads.END_LEFT?.exists) {
const pEL = this.worldToScreen(endX + 5, midY + 28);
ctx.fillStyle = '#38bdf8';
ctx.fillText('🟢 종점부 좌측 날개벽 (L=4.35m, 134°)', pEL.x, pEL.y);
}
if (quads.END_RIGHT?.exists) {
const pER = this.worldToScreen(endX + 5, midY - 32);
ctx.fillStyle = '#fbbf24';
ctx.fillText('🟢 종점부 우측 날개벽 (L=5.40m, 151°)', pER.x, pER.y);
}
if (!quads.START_LEFT?.exists) {
const pSL = this.worldToScreen(startX - 50, midY + 28);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 좌측 (미표기)', pSL.x, pSL.y);
}
if (!quads.START_RIGHT?.exists) {
const pSR = this.worldToScreen(startX - 50, midY - 32);
ctx.fillStyle = 'rgba(148, 163, 184, 0.6)';
ctx.fillText('⚪ 시점부 우측 (미표기)', pSR.x, pSR.y);
}
}
}
}