1174 lines
40 KiB
JavaScript
1174 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 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 mid = new THREE.Vector3().addVectors(d1, d2).multiplyScalar(0.5);
|
|
const spritePos = new THREE.Vector3(mid.x, mid.y, zDim + 0.28);
|
|
|
|
const sprite = this.createDimensionTextSprite(text);
|
|
sprite.position.copy(spritePos);
|
|
sprite.renderOrder = 1000;
|
|
this.scene.add(sprite);
|
|
this.dimensionSprites.push(sprite);
|
|
}
|
|
|
|
// 높이 측정용 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 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 mid = new THREE.Vector3().addVectors(d1, d2).multiplyScalar(0.5);
|
|
const spritePos = mid.clone().addScaledVector(offsetDir.clone().normalize(), 0.32);
|
|
|
|
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);
|
|
}
|
|
}
|