Files
wingwall_3D_drawing/static/js/viewer3d.js
T

640 lines
20 KiB
JavaScript

class Viewer3D {
constructor(containerId, onSelectCallback, onSelectRebarCallback) {
this.container = document.getElementById(containerId);
this.onSelect = onSelectCallback;
this.onSelectRebar = onSelectRebarCallback;
this.meshes = {};
this.rebarMeshes = [];
this.rebarGroupRoot = null;
this.sectionMarkers = [];
this.labelSprites = [];
this.rebarLabelSprites = [];
this.displayMode = 'solid'; // 'solid', 'wireframe', 'transparent'
this.stepMode = 2; // 2: Exterior only, 3: Exterior (ghosted) + 3D Rebars
this.selectedObjectId = null;
this.selectedRebarMark = null;
this.concreteOpacity = 0.25;
this.rebarScale = 1.4;
this.rebarFilters = {
wingwall: 'ALL', // 'ALL', 'WW_END_LEFT', 'WW_END_RIGHT'
zones: new Set([
'우각부 보강근 (암거접합부)',
'벽체 전면 철근',
'벽체 배면 철근',
'저판 상면 철근',
'저판 하면 철근',
'헌치 및 전단보강근'
]),
dias: new Set(['H19', 'H16', 'H13']),
marks: new Set() // populated on load
};
this.init();
}
init() {
const width = this.container.clientWidth || 800;
const height = this.container.clientHeight || 600;
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x0e1118);
// Camera (Z is up)
this.camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
this.camera.up.set(0, 0, 1);
this.camera.position.set(35, 20, 50);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(width, height);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
// Controls
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.target.set(16, 41, 35);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.70);
this.scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.85);
dirLight1.position.set(30, -20, 60);
dirLight1.castShadow = true;
dirLight1.shadow.mapSize.width = 2048;
dirLight1.shadow.mapSize.height = 2048;
this.scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x93c5fd, 0.40);
dirLight2.position.set(-20, 30, 40);
this.scene.add(dirLight2);
this.rebarGroupRoot = new THREE.Group();
this.scene.add(this.rebarGroupRoot);
// Ground Grid & Axes
this.setupGridAndAxes();
// Raycaster for object & rebar picking
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onPointerDown(e));
window.addEventListener('resize', () => this.onWindowResize());
this.animate();
}
setupGridAndAxes() {
this.gridHelper = new THREE.GridHelper(50, 50, 0x3b82f6, 0x1e293b);
this.gridHelper.rotation.x = Math.PI / 2;
this.gridHelper.position.set(16, 41, 33.0);
this.scene.add(this.gridHelper);
this.axesGroup = new THREE.Group();
this.axesGroup.position.set(5, 30, 33.0);
const axes = new THREE.AxesHelper(5);
this.axesGroup.add(axes);
this.scene.add(this.axesGroup);
}
loadModel(modelData) {
this.modelData = modelData;
// Clear previous concrete meshes
for (const key in this.meshes) {
this.scene.remove(this.meshes[key].group);
}
this.meshes = {};
this.clearSectionMarkers();
const objects = modelData.objects || {};
const botEl = objects.culvert?.dimensions?.bot_el ?? 33.404;
if (this.gridHelper) this.gridHelper.position.set(16, 41, botEl - 0.35);
if (this.axesGroup) this.axesGroup.position.set(5, 30, botEl - 0.35);
const colorMap = {
'CULVERT': { base: 0xa1a1aa, edge: 0x52525b },
'WW_END_LEFT': { base: 0x38bdf8, edge: 0x0284c7 },
'WW_END_RIGHT': { base: 0xfbbf24, edge: 0xd97706 },
'WW_START_LEFT': { base: 0x34d399, edge: 0x059669 },
'WW_START_RIGHT': { base: 0xa78bfa, edge: 0x7c3aed },
'culvert': { base: 0xa1a1aa, edge: 0x52525b },
'ww_left': { base: 0x38bdf8, edge: 0x0284c7 },
'ww_right': { base: 0xfbbf24, edge: 0xd97706 }
};
const renderedIds = new Set();
const candidateList = [];
if (objects.culvert) candidateList.push(objects.culvert);
if (Array.isArray(objects.wingwalls)) {
candidateList.push(...objects.wingwalls);
} else {
for (const k in objects) {
const o = objects[k];
if (o && o.vertices && o.faces && o.id) {
candidateList.push(o);
}
}
}
for (const obj of candidateList) {
if (!obj || !obj.vertices || !obj.faces || renderedIds.has(obj.id)) continue;
renderedIds.add(obj.id);
const col = colorMap[obj.id] || colorMap[obj.name] || { base: 0x94a3b8, edge: 0x475569 };
const meshGroup = this.createMeshGroup(obj, col);
this.scene.add(meshGroup);
this.meshes[obj.id] = {
group: meshGroup,
data: obj,
baseColor: col.base,
edgeColor: col.edge
};
}
// Add Section Cut Markers & EL Labels
this.addSectionMarkers(objects);
this.addAnnotations(objects);
// Load 3D Rebars if present
if (modelData.rebars) {
this.buildRebarMeshes(modelData.rebars);
}
this.applyStepVisualState();
this.fitView();
}
createMeshGroup(obj, col) {
const group = new THREE.Group();
group.userData = { id: obj.id, name: obj.name };
const geom = new THREE.BufferGeometry();
const vertices = new Float32Array(obj.vertices.flat());
const indices = [];
for (const f of obj.faces) {
indices.push(f[0], f[1], f[2]);
}
geom.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
geom.setIndex(indices);
geom.computeVertexNormals();
const isCulvert = (obj.id === 'CULVERT');
const mat = new THREE.MeshStandardMaterial({
color: col.base,
roughness: 0.6,
metalness: 0.1,
side: isCulvert ? THREE.DoubleSide : THREE.FrontSide,
polygonOffset: true,
polygonOffsetFactor: 1,
polygonOffsetUnits: 1
});
const mesh = new THREE.Mesh(geom, mat);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData = { id: obj.id, parentGroup: group };
group.add(mesh);
const edgesGeom = new THREE.EdgesGeometry(geom, 25);
const edgesMat = new THREE.LineBasicMaterial({ color: col.edge, linewidth: 1.5 });
const edgeLines = new THREE.LineSegments(edgesGeom, edgesMat);
group.add(edgeLines);
group.userData.mesh = mesh;
group.userData.edgeLines = edgeLines;
group.userData.mat = mat;
group.userData.edgesMat = edgesMat;
return group;
}
buildRebarMeshes(rebarData) {
// Clear existing rebar meshes
while (this.rebarGroupRoot.children.length > 0) {
const child = this.rebarGroupRoot.children[0];
this.rebarGroupRoot.remove(child);
}
this.rebarMeshes = [];
this.clearRebarLabels();
const diaColors = {
'H19': 0xf43f5e, // Rose Red
'H16': 0x38bdf8, // Sky Blue
'H13': 0x10b981 // Emerald Green
};
this.rebarFilters.marks.clear();
const unitCylGeom = new THREE.CylinderGeometry(1, 1, 1, 7, 1, false);
const sphereGeom = new THREE.SphereGeometry(1.05, 6, 6);
const yAxis = new THREE.Vector3(0, 1, 0);
for (const [wwId, ww] of Object.entries(rebarData.wingwalls || {})) {
for (const grp of ww.groups || []) {
this.rebarFilters.marks.add(grp.mark);
const baseColor = diaColors[grp.dia] || 0xeab308;
const mat = new THREE.MeshStandardMaterial({
color: baseColor,
roughness: 0.35,
metalness: 0.45
});
const groupContainer = new THREE.Group();
groupContainer.userData = {
isRebarGroup: true,
wingwallId: wwId,
mark: grp.mark,
dia: grp.dia,
zone: grp.zone,
baseColor: baseColor,
mat: mat,
groupData: grp
};
const radiusMeters = (grp.dia_mm / 2000.0) * this.rebarScale;
for (const poly of grp.polylines || []) {
for (let i = 0; i < poly.length - 1; i++) {
const p1 = new THREE.Vector3(poly[i][0], poly[i][1], poly[i][2]);
const p2 = new THREE.Vector3(poly[i + 1][0], poly[i + 1][1], poly[i + 1][2]);
const dir = new THREE.Vector3().subVectors(p2, p1);
const len = dir.length();
if (len < 1e-4) continue;
const mid = new THREE.Vector3().addVectors(p1, p2).multiplyScalar(0.5);
const segMesh = new THREE.Mesh(unitCylGeom, mat);
segMesh.position.copy(mid);
segMesh.quaternion.setFromUnitVectors(yAxis, dir.clone().normalize());
segMesh.scale.set(radiusMeters, len, radiusMeters);
segMesh.userData = {
isRebar: true,
mark: grp.mark,
wingwallId: wwId,
parentRebarGroup: groupContainer
};
groupContainer.add(segMesh);
if (i > 0) {
const joint = new THREE.Mesh(sphereGeom, mat);
joint.position.copy(p1);
joint.scale.setScalar(radiusMeters);
groupContainer.add(joint);
}
}
}
this.rebarGroupRoot.add(groupContainer);
this.rebarMeshes.push(groupContainer);
}
}
this.updateRebarVisibility();
}
setRebarThicknessScale(scaleVal) {
this.rebarScale = scaleVal;
if (this.modelData && this.modelData.rebars) {
this.buildRebarMeshes(this.modelData.rebars);
if (this.selectedRebarMark) {
this.selectRebarMark(this.selectedRebarMark);
}
}
}
setConcreteOpacity(opacityVal) {
this.concreteOpacity = opacityVal;
if (this.stepMode === 3) {
this.applyStepVisualState();
}
}
setStepMode(stepNumber) {
this.stepMode = stepNumber;
this.applyStepVisualState();
}
applyStepVisualState() {
const isStep3 = (this.stepMode === 3);
// Show/hide 3D rebar group root
if (this.rebarGroupRoot) {
this.rebarGroupRoot.visible = isStep3;
}
// Section markers only in Step 2
for (const line of this.sectionMarkers) {
line.visible = !isStep3;
}
for (const sp of this.labelSprites) {
sp.visible = !isStep3;
}
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
}
// Concrete appearance
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (isStep3) {
// In Step 3, wingwalls are semi-transparent ghosted shells so internal rebars shine through
mat.wireframe = false;
mat.transparent = true;
mat.depthWrite = false;
mat.opacity = (id === 'CULVERT') ? Math.min(0.18, this.concreteOpacity * 0.7) : this.concreteOpacity;
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
} else {
mat.depthWrite = true;
if (edgesMat) {
edgesMat.transparent = false;
edgesMat.opacity = 1.0;
}
this.setDisplayMode(this.displayMode);
this.selectObject(this.selectedObjectId);
}
}
if (isStep3) {
this.updateRebarVisibility();
}
}
updateRebarVisibility() {
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const matchWw = (this.rebarFilters.wingwall === 'ALL' || u.wingwallId === this.rebarFilters.wingwall);
const matchZone = this.rebarFilters.zones.has(u.zone);
const matchDia = this.rebarFilters.dias.has(u.dia);
const matchMark = this.rebarFilters.marks.has(u.mark);
grpObj.visible = matchWw && matchZone && matchDia && matchMark;
}
}
selectRebarMark(mark) {
this.selectedRebarMark = mark;
this.clearRebarLabels();
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const mat = u.mat;
if (u.mark === mark) {
mat.color.setHex(0xfacc15); // Bright highlight gold
mat.emissive.setHex(0xf59e0b);
mat.emissiveIntensity = 0.65;
// Add floating 3D callout sprite at midpoint of first bar in group
const polys = u.groupData.polylines;
if (polys && polys.length > 0) {
const midPoly = polys[Math.floor(polys.length / 2)];
const pt = midPoly[Math.floor(midPoly.length / 2)];
if (pt) {
const labelText = `[${u.mark}] ${u.dia} (${u.groupData.role})`;
const sp = this.createTextSprite(labelText, 0xfacc15);
sp.position.set(pt[0], pt[1], pt[2] + 0.55);
this.scene.add(sp);
this.rebarLabelSprites.push(sp);
}
}
} else {
mat.color.setHex(u.baseColor);
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
}
}
}
clearRebarLabels() {
for (const sp of this.rebarLabelSprites) {
this.scene.remove(sp);
}
this.rebarLabelSprites = [];
}
addSectionMarkers(objects) {
this.clearSectionMarkers();
const sections = [
{ name: '단면 A-A', pts: objects.ww_left?.sections_3d?.['A-A'], color: 0xef4444 },
{ name: '단면 B-B', pts: objects.ww_left?.sections_3d?.['B-B'], color: 0xf97316 },
{ name: '단면 C-C', pts: objects.ww_right?.sections_3d?.['C-C'], color: 0x10b981 },
{ name: '단면 D-D', pts: objects.ww_right?.sections_3d?.['D-D'], color: 0x8b5cf6 }
];
for (const s of sections) {
if (!s.pts) continue;
const points = s.pts.map(p => new THREE.Vector3(p[0], p[1], p[2]));
points.push(points[0].clone());
const geom = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: s.color, linewidth: 3 });
const line = new THREE.Line(geom, mat);
this.scene.add(line);
this.sectionMarkers.push(line);
const topPt = points[4];
if (topPt) {
const sprite = this.createTextSprite(s.name, s.color);
sprite.position.set(topPt.x, topPt.y, topPt.z + 0.6);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
}
}
addAnnotations(objects) {
const wwL = objects.ww_left;
const wwR = objects.ww_right;
if (!wwL || !wwR) return;
const ptA = wwL.sections_3d?.['A-A']?.[5] || [15.725, 42.650, wwL.dimensions.top_el_start];
const ptB = wwL.sections_3d?.['B-B']?.[5] || [18.75, 45.77, wwL.dimensions.top_el_end];
const ptD = wwR.sections_3d?.['D-D']?.[5] || [21.01, 38.02, wwR.dimensions.top_el_end];
const stName = this.modelData?.station || '종점부';
const elMarkers = [
{ text: `EL. ${wwL.dimensions.top_el_start.toFixed(3)} (암거 접합부)`, pos: ptA, color: 0x38bdf8 },
{ text: `EL. ${wwL.dimensions.top_el_end.toFixed(3)} (좌 말단 L=${wwL.dimensions.length}m)`, pos: ptB, color: 0x38bdf8 },
{ text: `EL. ${wwR.dimensions.top_el_end.toFixed(3)} (우 말단 L=${wwR.dimensions.length}m)`, pos: ptD, color: 0xfbbf24 },
{ text: `${stName} (종점부)`, pos: [16.0, 41.65, wwL.dimensions.top_el_start + 0.5], color: 0xffffff }
];
for (const m of elMarkers) {
const sprite = this.createTextSprite(m.text, m.color);
sprite.position.set(m.pos[0], m.pos[1], m.pos[2] + 0.4);
this.scene.add(sprite);
this.labelSprites.push(sprite);
}
}
createTextSprite(text, colorHex) {
const canvas = document.createElement('canvas');
canvas.width = 320;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(18, 21, 28, 0.88)';
ctx.roundRect ? ctx.roundRect(4, 4, 312, 56, 8) : ctx.fillRect(4, 4, 312, 56);
ctx.fill();
ctx.strokeStyle = '#' + new THREE.Color(colorHex).getHexString();
ctx.lineWidth = 2;
ctx.stroke();
ctx.font = 'bold 18px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 160, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({ map: texture, depthTest: false });
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(3.6, 0.75, 1);
return sprite;
}
clearSectionMarkers() {
for (const line of this.sectionMarkers) {
this.scene.remove(line);
}
this.sectionMarkers = [];
for (const sprite of this.labelSprites) {
this.scene.remove(sprite);
}
this.labelSprites = [];
}
selectObject(objectId) {
this.selectedObjectId = objectId;
if (this.stepMode === 3) return;
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
if (id === objectId) {
mat.emissive = new THREE.Color(0x2563eb);
mat.emissiveIntensity = 0.45;
} else {
mat.emissive = new THREE.Color(0x000000);
mat.emissiveIntensity = 0;
}
}
}
toggleObjectVisibility(objectId, visible) {
if (this.meshes[objectId]) {
this.meshes[objectId].group.visible = visible;
}
}
setDisplayMode(mode) {
this.displayMode = mode;
for (const id in this.meshes) {
const mat = this.meshes[id].group.userData.mat;
if (mode === 'wireframe') {
mat.wireframe = true;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
} else if (mode === 'transparent') {
mat.wireframe = false;
mat.transparent = true;
mat.opacity = 0.35;
mat.depthWrite = false;
} else {
mat.wireframe = false;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
}
}
}
fitView() {
const box = new THREE.Box3();
for (const id in this.meshes) {
box.expandByObject(this.meshes[id].group);
}
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.35, center.y - maxDim * 1.35, center.z + maxDim * 1.05);
this.camera.lookAt(center);
this.controls.update();
}
onPointerDown(event) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
if (this.stepMode === 3) {
const visibleRebarChildren = [];
for (const grp of this.rebarMeshes) {
if (grp.visible) {
visibleRebarChildren.push(...grp.children);
}
}
const hits = this.raycaster.intersectObjects(visibleRebarChildren, false);
if (hits.length > 0) {
const hitObj = hits[0].object;
const mark = hitObj.userData.mark || hitObj.parent?.userData?.mark;
if (mark) {
this.selectRebarMark(mark);
if (this.onSelectRebar) this.onSelectRebar(mark, hitObj.userData.wingwallId);
return;
}
}
}
const meshes = Object.values(this.meshes).map(m => m.group.userData.mesh);
const intersects = this.raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
const hitMesh = intersects[0].object;
const objId = hitMesh.userData.id;
this.selectObject(objId);
if (this.onSelect) this.onSelect(objId);
}
}
onWindowResize() {
if (!this.container) return;
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) return;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
}
animate() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}