Files

1161 lines
40 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.wingwallNameSprites = [];
this.dimensionGroup = null;
this.dimensionSprites = [];
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);
// Dimension group for Step 2 selected wingwall measurements
this.dimensionGroup = new THREE.Group();
this.scene.add(this.dimensionGroup);
this.dimensionSprites = [];
// 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();
this.clearWingwallNameSprites();
this.clearDimensionAnnotations();
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);
}
// Build wingwall 3D structure name sprites (Requirement 3.2.2 & 3.3)
this.buildWingwallNameSprites();
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;
if (stepNumber === 3) {
this.clearDimensionAnnotations();
}
this.applyStepVisualState();
}
applyStepVisualState() {
const isStep3 = (this.stepMode === 3);
// Show/hide 3D rebar group root
if (this.rebarGroupRoot) {
this.rebarGroupRoot.visible = isStep3;
}
// Section markers and labels hidden to keep 3D model clean (Requirement 2.2.2 & 2.2.3)
for (const line of this.sectionMarkers) {
line.visible = false;
}
for (const sp of this.labelSprites) {
sp.visible = false;
}
for (const sp of this.rebarLabelSprites) {
sp.visible = isStep3;
}
this.updateWingwallNameSpritesVisibility();
// 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, targetWingwallId) {
const wwId = targetWingwallId || this.selectedWingwallFocus;
this.selectedRebarMark = mark;
this.selectedRebarWingwall = wwId;
this.clearRebarLabels();
for (const sp of this.wingwallNameSprites) sp.visible = false;
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
const mat = u.mat;
const isTargetWingwall = (u.wingwallId === wwId);
const isTargetBar = isTargetWingwall && (u.mark === mark);
if (isTargetBar) {
// 오직 선택된 날개벽에 속한 철근만 정상 색상 및 하이라이트 활성화
mat.transparent = false;
mat.opacity = 1.0;
mat.color.setHex(0xfacc15); // 밝은 골드 하이라이트
mat.emissive.setHex(0xf59e0b);
mat.emissiveIntensity = 0.65;
// 선택된 날개벽의 해당 철근 위치에 3D 텍스트 레이블 생성
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 wwName = (wwId === 'WW_END_LEFT') ? '종점좌' : '종점우';
const labelText = `[${u.mark}] ${u.dia} (${wwName})`;
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.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (isTargetWingwall) {
// 선택된 날개벽 내 다른 철근은 정상 선명도 유지
mat.transparent = false;
mat.opacity = 1.0;
mat.color.setHex(u.baseColor);
} else {
// 다른 날개벽의 철근(동일 철근기호 포함)은 비활성화(흐릿한 0.10 불투명도)
mat.transparent = true;
mat.opacity = 0.10;
mat.color.setHex(0x64748b);
}
}
}
}
// 좌측 바에서 날개벽/암거 이름을 클릭한 경우에만 기본 구도 변경 (Requirement 3.5.2)
focusWingwall(wingwallId, updateCamera = false) {
this.selectedWingwallFocus = wingwallId;
if (updateCamera) {
if (wingwallId === 'CULVERT' || !wingwallId) {
this.fitView();
} else {
const targetMesh = this.meshes[wingwallId];
if (targetMesh && targetMesh.group) {
const box = new THREE.Box3().setFromObject(targetMesh.group);
if (!box.isEmpty()) {
const center = new THREE.Vector3();
const size = new THREE.Vector3();
box.getCenter(center);
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
this.controls.target.copy(center);
this.camera.position.set(center.x + maxDim * 1.5, center.y - maxDim * 1.4, center.z + maxDim * 1.1);
this.camera.lookAt(center);
this.controls.update();
}
}
}
}
this.applyWingwallVisuals(wingwallId);
}
// 카메라 구도를 변경하지 않고 콘크리트 및 철근의 시각적 활성/비활성 상태만 갱신
applyWingwallVisuals(wingwallId) {
const isCulvert = (wingwallId === 'CULVERT' || !wingwallId);
// 1. 콘크리트 솔리드 외형
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (isCulvert) {
mat.transparent = true;
mat.opacity = (id === 'CULVERT') ? Math.min(0.20, this.concreteOpacity * 0.7) : this.concreteOpacity;
mat.color.setHex((id === 'CULVERT') ? 0x94a3b8 : (id === 'WW_END_LEFT' ? 0x38bdf8 : 0xfbbf24));
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
} else {
if (id === wingwallId) {
mat.transparent = true;
mat.opacity = this.concreteOpacity;
mat.color.setHex(id === 'WW_END_LEFT' ? 0x38bdf8 : 0xfbbf24);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.50;
}
} else {
// 비선택 구조물: 옅고 흐릿하게 비활성화 (위치 파악용 유지)
mat.transparent = true;
mat.opacity = 0.08;
mat.color.setHex(0x475569);
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.12;
}
}
}
}
// 2. 철근군
for (const grpObj of this.rebarMeshes) {
const u = grpObj.userData;
if (isCulvert) {
u.mat.transparent = false;
u.mat.opacity = 1.0;
if (u.mark !== this.selectedRebarMark) {
u.mat.color.setHex(u.baseColor);
}
} else {
if (u.wingwallId === wingwallId) {
u.mat.transparent = false;
u.mat.opacity = 1.0;
if (u.mark !== this.selectedRebarMark) {
u.mat.color.setHex(u.baseColor);
}
} else {
// 다른 날개벽 철근 비활성화
u.mat.transparent = true;
u.mat.opacity = 0.10;
u.mat.color.setHex(0x64748b);
}
}
}
// 3. 날개벽 이름 텍스트 라벨 (Requirement 3.2.2 & 3.3)
this.updateWingwallNameSpritesVisibility();
}
// 화면 클릭이나 선택 해제 시 카메라 시점을 유지한 채 철근 하이라이트만 초기화 (Requirement 3.5.1)
clearRebarSelection() {
this.selectedRebarMark = null;
this.selectedRebarWingwall = null;
this.clearRebarLabels();
this.applyWingwallVisuals(this.selectedWingwallFocus || 'CULVERT');
}
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: 2 });
const line = new THREE.Line(geom, mat);
line.visible = false;
this.scene.add(line);
this.sectionMarkers.push(line);
// Requirement 2.2.3: 단면 0-0 텍스트 표기 삭제 (스프라이트 미생성)
}
}
addAnnotations(objects) {
// Requirement 2.2.2: 암거본체 선택 시 날개벽 구조물 이름만 표출 (불필요한 EL/스테이션 텍스트 미생성)
}
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.72, 1);
sprite.userData.aspect = 320 / 64; // 5.0
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 = [];
}
// Requirement 3.2.2 & 3.3: 암거본체 선택 시 날개벽 구조물 이름만 3D 모델 위에 표시
buildWingwallNameSprites() {
this.clearWingwallNameSprites();
const wwItems = [
{ id: 'WW_END_LEFT', name: '종점부 좌측 날개벽', color: 0x38bdf8 },
{ id: 'WW_END_RIGHT', name: '종점부 우측 날개벽', color: 0xfbbf24 }
];
for (const item of wwItems) {
const meshObj = this.meshes[item.id] || this.meshes[item.id.toLowerCase()];
if (!meshObj || !meshObj.group) continue;
const box = new THREE.Box3().setFromObject(meshObj.group);
if (box.isEmpty()) continue;
const center = new THREE.Vector3();
box.getCenter(center);
const sprite = this.createTextSprite(item.name, item.color);
sprite.position.set(center.x, center.y, box.max.z + 1.25);
sprite.userData.wingwallId = item.id;
sprite.visible = false;
this.scene.add(sprite);
this.wingwallNameSprites.push(sprite);
}
}
clearWingwallNameSprites() {
for (const sp of this.wingwallNameSprites) {
this.scene.remove(sp);
}
this.wingwallNameSprites = [];
}
updateWingwallNameSpritesVisibility() {
if (this.stepMode !== 2 && this.stepMode !== 3) {
for (const sp of this.wingwallNameSprites) sp.visible = false;
return;
}
const currentFocus = (this.stepMode === 3) ? this.selectedWingwallFocus : this.selectedObjectId;
const isCulvert = (!currentFocus || currentFocus === 'CULVERT');
for (const sp of this.wingwallNameSprites) {
if (isCulvert) {
// 암거본체 선택 시 날개벽 구조물의 이름만 표시 (Requirement 2.2.2 & 3.2.2)
sp.visible = true;
} else {
// 특정 날개벽 선택 시: 2단계에서는 치수선 가독성을 위해 숨김, 3단계에서는 선택된 날개벽 이름 표시
sp.visible = (this.stepMode === 3 && sp.userData.wingwallId === currentFocus);
}
}
}
selectObject(objectId) {
this.selectedObjectId = objectId;
if (this.stepMode === 3) return;
const isCulvert = (!objectId || objectId === 'CULVERT');
if (isCulvert) {
// 2.1 암거 본체 선택: 메인 화면에 암거 본체 및 모든 구조물을 정상 진하게 표시
this.clearDimensionAnnotations();
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
mat.color.setHex(m.baseColor);
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.35;
}
}
} else {
// 2.2 날개벽 선택: 선택된 날개벽은 진하게, 나머지 구조물은 흐리게 표시
this.clearDimensionAnnotations();
for (const id in this.meshes) {
const m = this.meshes[id];
const mat = m.group.userData.mat;
const edgesMat = m.group.userData.edgesMat;
if (id === objectId) {
// 선택된 날개벽: 진하게 선명하게 강조
mat.transparent = false;
mat.opacity = 1.0;
mat.depthWrite = true;
mat.color.setHex(m.baseColor);
mat.emissive.setHex((id === 'WW_END_LEFT' || id === 'ww_left') ? 0x0284c7 : 0xd97706);
mat.emissiveIntensity = 0.30;
if (edgesMat) {
edgesMat.transparent = false;
edgesMat.opacity = 0.85;
}
} else {
// 나머지 구조물: 흐리게 (위치와 형상은 파악 가능한 반투명)
mat.transparent = true;
mat.opacity = 0.18;
mat.depthWrite = false;
mat.color.setHex(0x64748b);
mat.emissive.setHex(0x000000);
mat.emissiveIntensity = 0;
if (edgesMat) {
edgesMat.transparent = true;
edgesMat.opacity = 0.18;
}
}
}
// 3. 선택된 날개벽의 주요 치수(시작단면 높이, 종점단면 높이, 날개벽 연장) 표시
this.showWingwallDimensions(objectId);
}
this.updateWingwallNameSpritesVisibility();
}
// Requirement 1 & 2 & 3 & 4: 선택된 날개벽 치수(시작단면 높이, 종점단면 높이, 날개벽 수평연장) 흰색 3D 치수선 및 간소화 라벨 표출
showWingwallDimensions(wingwallId) {
this.clearDimensionAnnotations();
if (!wingwallId || wingwallId === 'CULVERT') return;
const objects = this.modelData?.objects || {};
const isLeft = (wingwallId === 'WW_END_LEFT' || wingwallId === 'ww_left' || wingwallId === 'ww_end_left');
const wwData = isLeft
? (objects.ww_end_left || objects.ww_left)
: (objects.ww_end_right || objects.ww_right);
let pStartBot, pStartTop, pEndBot, pEndTop;
const sec = wwData?.sections_3d || {};
if (isLeft) {
const secA = sec['A-A'];
const secB = sec['B-B'];
if (secA && secA.length >= 6) {
pStartBot = new THREE.Vector3(secA[5][0], secA[5][1], secA[0][2]);
pStartTop = new THREE.Vector3(secA[5][0], secA[5][1], secA[4][2]);
} else {
pStartBot = new THREE.Vector3(15.731, 42.650, 33.404);
pStartTop = new THREE.Vector3(15.731, 42.650, 37.104);
}
if (secB && secB.length >= 6) {
pEndBot = new THREE.Vector3(secB[5][0], secB[5][1], secB[0][2]);
pEndTop = new THREE.Vector3(secB[5][0], secB[5][1], secB[4][2]);
} else {
pEndBot = new THREE.Vector3(18.759, 45.773, 33.523);
pEndTop = new THREE.Vector3(18.759, 45.773, 35.023);
}
} else {
const secC = sec['C-C'];
const secD = sec['D-D'];
if (secC && secC.length >= 6) {
pStartBot = new THREE.Vector3(secC[5][0], secC[5][1], secC[0][2]);
pStartTop = new THREE.Vector3(secC[5][0], secC[5][1], secC[4][2]);
} else {
pStartBot = new THREE.Vector3(16.287, 40.650, 33.404);
pStartTop = new THREE.Vector3(16.287, 40.650, 37.104);
}
if (secD && secD.length >= 6) {
pEndBot = new THREE.Vector3(secD[5][0], secD[5][1], secD[0][2]);
pEndTop = new THREE.Vector3(secD[5][0], secD[5][1], secD[4][2]);
} else {
pEndBot = new THREE.Vector3(21.004, 38.022, 33.523);
pEndTop = new THREE.Vector3(21.004, 38.022, 35.023);
}
}
// 실제 3D 모델링 기하 좌표로부터 치수값 정밀 산출 (Requirement 5: 임의 고정값 배제)
const hStartVal = Math.abs(pStartTop.z - pStartBot.z);
const hEndVal = Math.abs(pEndTop.z - pEndBot.z);
// 시작단면과 종점단면 사이의 수평거리를 날개벽 연장으로 정의 (Requirement 2)
const lengthVal = Math.hypot(pEndTop.x - pStartTop.x, pEndTop.y - pStartTop.y);
// 벽체 연장 축 (XY 평면)
const vWall = new THREE.Vector3(pEndTop.x - pStartTop.x, pEndTop.y - pStartTop.y, 0);
const uWall = vWall.clone().normalize();
// 벽체 외측 법선 벡터 (XY 평면)
const nWall = isLeft
? new THREE.Vector3(-uWall.y, uWall.x, 0).normalize()
: new THREE.Vector3(uWall.y, -uWall.x, 0).normalize();
// 1. 시작단면 높이 치수선 (외측 법선 방향 배치, H=00.0m) - Requirement 3 & 4
this.createDimension3D(
pStartBot,
pStartTop,
nWall,
0.60,
`H=${hStartVal.toFixed(2)}m`
);
// 2. 종점단면 높이 치수선 (종점 축 외측 방향 배치, H=00.0m) - Requirement 3 & 4
this.createDimension3D(
pEndBot,
pEndTop,
uWall,
0.60,
`H=${hEndVal.toFixed(2)}m`
);
// 3. 날개벽 연장 수평 치수선 (경사가 아닌 수평 방향 배치, L=00.0m) - Requirement 2 & 3 & 4
this.createHorizontalDimension3D(
pStartTop,
pEndTop,
nWall,
0.20,
`L=${lengthVal.toFixed(2)}m`
);
}
// Requirement 2: 날개벽 연장 수평 치수선 (동일 Z 높이 수평 배치로 경사/기울어짐 방지)
createHorizontalDimension3D(p1, p2, normalDir, normalDist, text) {
const zDim = Math.max(p1.z, p2.z) + 0.60;
const offH = (normalDir && normalDist)
? normalDir.clone().normalize().multiplyScalar(normalDist)
: new THREE.Vector3(0, 0, 0);
const startBase = new THREE.Vector3(p1.x + offH.x, p1.y + offH.y, p1.z);
const endBase = new THREE.Vector3(p2.x + offH.x, p2.y + offH.y, p2.z);
// 치수선의 양 끝단 Z 좌표를 zDim으로 일치시켜 완벽한 수평 보장
const d1 = new THREE.Vector3(startBase.x, startBase.y, zDim);
const d2 = new THREE.Vector3(endBase.x, endBase.y, zDim);
const linePoints = [];
// 보조선 (Extension Lines): 각 단면 상단 기준점에서 수평 치수선 높이까지 수직 연장
const overshoot = 0.15;
linePoints.push(startBase.clone(), new THREE.Vector3(d1.x, d1.y, zDim + overshoot));
linePoints.push(endBase.clone(), new THREE.Vector3(d2.x, d2.y, zDim + overshoot));
// 치수선 (Horizontal Dimension Line)
linePoints.push(d1.clone(), d2.clone());
// 끝단 틱 마커 (End Ticks): 수평 치수선에 직교하는 수직 틱
const tickLen = 0.14;
linePoints.push(
new THREE.Vector3(d1.x, d1.y, zDim - tickLen),
new THREE.Vector3(d1.x, d1.y, zDim + tickLen)
);
linePoints.push(
new THREE.Vector3(d2.x, d2.y, zDim - tickLen),
new THREE.Vector3(d2.x, d2.y, zDim + tickLen)
);
// 치수선 및 텍스트 렌더링
const mid = new THREE.Vector3().addVectors(d1, d2).multiplyScalar(0.5);
const spritePos = new THREE.Vector3(mid.x, mid.y, zDim + 0.28);
this._renderDimensionEntry(linePoints, spritePos, text);
}
// 높이 측정용 3D 치수선 생성 (Requirement 3 & 4: 흰색 치수선 및 간소화 표기)
createDimension3D(p1, p2, offsetDir, offsetDist, text) {
const off = offsetDir.clone().normalize().multiplyScalar(offsetDist);
const d1 = p1.clone().add(off);
const d2 = p2.clone().add(off);
const linePoints = [];
// 보조선 (Extension Lines)
const extOver = offsetDir.clone().normalize().multiplyScalar(0.15);
linePoints.push(p1.clone(), d1.clone().add(extOver));
linePoints.push(p2.clone(), d2.clone().add(extOver));
// 치수선 (Main Dimension Line)
linePoints.push(d1.clone(), d2.clone());
// 끝단 틱 표시 (End Ticks)
const dimDir = new THREE.Vector3().subVectors(d2, d1).normalize();
let tickDir = new THREE.Vector3().crossVectors(dimDir, offsetDir).normalize();
if (tickDir.lengthSq() < 0.01) {
tickDir = new THREE.Vector3(0, 0, 1);
}
const tickLen = 0.14;
linePoints.push(d1.clone().addScaledVector(tickDir, tickLen), d1.clone().addScaledVector(tickDir, -tickLen));
linePoints.push(d2.clone().addScaledVector(tickDir, tickLen), d2.clone().addScaledVector(tickDir, -tickLen));
// 치수값 텍스트 라벨 (치수선 외측 중앙에 명확히 대응되도록 배치)
const mid = new THREE.Vector3().addVectors(d1, d2).multiplyScalar(0.5);
const spritePos = mid.clone().addScaledVector(offsetDir.clone().normalize(), 0.32);
this._renderDimensionEntry(linePoints, spritePos, text);
}
// 치수선 LineSegments 및 텍스트 Sprite 씬 등록 공통 헬퍼
_renderDimensionEntry(linePoints, spritePos, text) {
const geom = new THREE.BufferGeometry().setFromPoints(linePoints);
const mat = new THREE.LineBasicMaterial({
color: 0xffffff, // Requirement 4: 모든 치수선 흰색
linewidth: 2,
depthTest: false,
transparent: true,
opacity: 0.95
});
const lineSegs = new THREE.LineSegments(geom, mat);
lineSegs.renderOrder = 999;
this.dimensionGroup.add(lineSegs);
const sprite = this.createDimensionTextSprite(text);
sprite.position.copy(spritePos);
sprite.renderOrder = 1000;
this.scene.add(sprite);
this.dimensionSprites.push(sprite);
}
// Requirement 3 & 4: 사각형 테이블(배경 박스 및 테두리) 삭제, 순수 흰색 치수값 텍스트 생성
createDimensionTextSprite(text) {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
// 투명 배경 (사각형 박스 및 테두리 완전 삭제)
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.font = 'bold 22px "Segoe UI", Roboto, -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 3D 뷰포트에서 배경/모델 색상과 무관하게 글자가 또렷이 식별되도록 글자 자체에 미세한 다크 외곽선 적용
ctx.strokeStyle = 'rgba(15, 23, 42, 0.90)';
ctx.lineWidth = 3.5;
ctx.lineJoin = 'round';
ctx.miterLimit = 2;
ctx.strokeText(text, 128, 32);
// 치수값 흰색 표시
ctx.fillStyle = '#ffffff';
ctx.fillText(text, 128, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const spriteMat = new THREE.SpriteMaterial({
map: texture,
depthTest: false,
transparent: true
});
const sprite = new THREE.Sprite(spriteMat);
sprite.scale.set(2.8, 0.7, 1);
sprite.userData.aspect = 256 / 64; // 4.0
return sprite;
}
clearDimensionAnnotations() {
if (this.dimensionGroup) {
while (this.dimensionGroup.children.length > 0) {
const obj = this.dimensionGroup.children[0];
if (obj.geometry) obj.geometry.dispose();
if (obj.material) obj.material.dispose();
this.dimensionGroup.remove(obj);
}
}
for (const sp of this.dimensionSprites) {
this.scene.remove(sp);
if (sp.material?.map) sp.material.map.dispose();
if (sp.material) sp.material.dispose();
}
this.dimensionSprites = [];
}
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;
}
}
}
// Requirement 2.4: 3D 모델이 메인 화면 표시 영역의 약 80%를 차지하도록 초기 표시 크기(카메라 거리) 조정
fitView(coverage = 0.80) {
const box = new THREE.Box3();
for (const id in this.meshes) {
if (this.meshes[id] && this.meshes[id].group) {
box.expandByObject(this.meshes[id].group);
}
}
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const sphere = box.getBoundingSphere(new THREE.Sphere());
const radius = Math.max(sphere.radius, 1.0);
const fov = this.camera.fov * (Math.PI / 180);
const aspect = this.camera.aspect || ((this.container && this.container.clientHeight) ? (this.container.clientWidth / this.container.clientHeight) : 1.6);
const halfFovV = fov / 2;
const halfFovH = Math.atan(Math.tan(halfFovV) * aspect);
const limitingHalfFov = Math.min(halfFovV, halfFovH);
// 거리 산출: 바운딩 구가 뷰포트의 제한 치수 대비 약 80%를 차지하도록 배치
const targetDist = (radius / coverage) / Math.sin(limitingHalfFov);
const dir = new THREE.Vector3(1.0, -1.0, 0.78).normalize();
const camPos = center.clone().add(dir.multiplyScalar(targetDist));
this.controls.target.copy(center);
this.camera.position.copy(camPos);
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) {
let chosenHit = null;
for (const h of hits) {
const hitWwId = h.object.userData.wingwallId;
if (this.selectedWingwallFocus && this.selectedWingwallFocus !== 'CULVERT') {
if (hitWwId === this.selectedWingwallFocus) {
chosenHit = h;
break;
}
} else {
chosenHit = h;
break;
}
}
if (chosenHit) {
const hitObj = chosenHit.object;
const mark = hitObj.userData.mark || hitObj.parent?.userData?.mark;
const hitWwId = hitObj.userData.wingwallId || hitObj.parent?.userData?.wingwallId;
// Requirement 3.4.1: 선택된 날개벽에 속한 철근만 선택 허용
if (this.selectedWingwallFocus && this.selectedWingwallFocus !== 'CULVERT') {
if (hitWwId !== this.selectedWingwallFocus) {
return;
}
}
if (mark && hitWwId) {
this.selectRebarMark(mark, hitWwId);
if (this.onSelectRebar) this.onSelectRebar(mark, hitWwId);
return;
}
}
}
// Requirement 3.5.1: 빈 바닥 클릭 시 카메라 구도를 절대 초기화하지 않고 철근 선택만 해제
this.clearRebarSelection();
if (this.onDeselectRebar) this.onDeselectRebar();
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();
// Requirement 2.3 & 3.3: 줌 확대/축소 시에도 3D 텍스트 크기 일정 유지 (Screen-space constant size)
const sprites = [...this.rebarLabelSprites, ...this.labelSprites, ...this.wingwallNameSprites, ...this.dimensionSprites];
for (const sp of sprites) {
if (sp && sp.visible) {
const dist = this.camera.position.distanceTo(sp.position);
const baseScale = dist * 0.040;
const aspect = sp.userData.aspect || 5.0;
sp.scale.set(baseScale * aspect, baseScale, 1);
}
}
this.renderer.render(this.scene, this.camera);
}
}