diff --git a/src/core/abut/bearing-table.ts b/src/core/abut/bearing-table.ts index cf6ad1b..b4ad9b5 100644 --- a/src/core/abut/bearing-table.ts +++ b/src/core/abut/bearing-table.ts @@ -55,6 +55,11 @@ export interface AbutElevationRow { B: number; /** 블럭 끝에서 가장 가까운 계단 경계까지 (m). 경계가 없으면 null. */ stepGapM: number | null; + /** 받침 방위각 (deg): CAD (엑셀 값, 동 = 0° 반시계) / 진북 (북에서 시계). 입력이 없으면 null. */ + azimuthCadDeg: number | null; + azimuthNorthDeg: number | null; + /** 블럭 평면 회전 (deg), 교대축 직각 기준. */ + rotDeg: number; } export function abutElevationRows(model: AbutModel, rule: AbutBearingRule): AbutElevationRow[] { @@ -78,6 +83,9 @@ export function abutElevationRows(model: AbutModel, rule: AbutBearingRule): Abut A: ABUT_BEARING_A_MM, B: ABUT_BEARING_B_MM, stepGapM: gaps.length ? Math.min(...gaps) : null, + azimuthCadDeg: b.azimuthDeg === undefined ? null : (((90 - b.azimuthDeg) % 360) + 360) % 360, + azimuthNorthDeg: b.azimuthDeg === undefined ? null : ((b.azimuthDeg % 360) + 360) % 360, + rotDeg: b.rotDeg, }; }); } diff --git a/src/core/abut/geometry.ts b/src/core/abut/geometry.ts index 2eba984..f5aa696 100644 --- a/src/core/abut/geometry.ts +++ b/src/core/abut/geometry.ts @@ -28,6 +28,8 @@ export interface AbutBearing { z: number; /** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */ seatEl?: number; + /** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */ + azimuthDeg?: number; } /** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */ @@ -79,6 +81,10 @@ export interface AbutBearingModel { seatEl: number; /** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */ blockHeightMm: number; + /** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */ + azimuthDeg?: number; + /** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */ + rotDeg: number; } export interface AbutModel { @@ -112,6 +118,20 @@ const AW01_TOLERANCE_DEG = 1; export const ABUT_PAD_MM = 50; const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n }); + +/** + * 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front), + * folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing. + */ +export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number { + if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0; + const r = (azimuthDeg * Math.PI) / 180; + const d = { e: Math.sin(r), n: Math.cos(r) }; + let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI; + if (phi > 90) phi -= 180; + if (phi < -90) phi += 180; + return phi; +} const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n; const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e; @@ -140,7 +160,7 @@ export function buildAbut(input: AbutInput): AbutModel { const s = dot(sub(b.pos, input.ip1), u); const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ); const blockHeightMm = Math.round((b.z - seatEl) * 1000); - return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm }; + return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) }; }); const sorted = [...bearings].sort((a, b) => a.s - b.s); diff --git a/src/core/abut/parse.ts b/src/core/abut/parse.ts index 6215d06..7dca362 100644 --- a/src/core/abut/parse.ts +++ b/src/core/abut/parse.ts @@ -7,6 +7,7 @@ */ import * as XLSX from 'xlsx'; import { parseDms } from '../dms'; +import { swapXYAzimuth } from '../coordinate-frame'; import type { Issue } from '../types'; import type { AbutInput, AbutPoint, AbutType, WingForm } from './geometry'; import type { AbutShape } from './shape'; @@ -24,6 +25,8 @@ export interface AbutRecordBearing { seatEl?: number; /** 이 받침에 적힌 받침높이 (mm). */ bearingHeightMm: number; + /** 받침 방위각 (deg, model-xy, 교각과 같은 swapXYAzimuth 변환). 비었거나 읽을 수 없으면 없음. */ + azimuthDeg?: number; row: string; } @@ -162,12 +165,19 @@ export function parseAbutments(wb: XLSX.WorkBook): AbutParseResult { continue; } const h = num(c[8]); + if (!isBlank(c[5]) && parseDms(String(c[5])) === null) { + issues.push({ code: 'AP08', level: 'W', pier: ab.name, sheet: rec.sheet, message: `${rec.sheet} ${rec.row}행: 받침 방위각을 읽을 수 없습니다. 예: 75°56'55.9" 형식. 블럭은 교대축에 맞춥니다.` }); + } ab.bearings.push({ no: num(c[1]), pos: { e: num(c[3]), n: num(c[2]) }, z: num(c[4]), seatEl: isBlank(c[7]) ? undefined : num(c[7]), bearingHeightMm: h, + azimuthDeg: (() => { + const deg = isBlank(c[5]) ? null : parseDms(String(c[5])); + return deg === null ? undefined : swapXYAzimuth(deg); + })(), row: `${rec.sheet} ${rec.row}행`, }); } @@ -193,7 +203,7 @@ export function toAbutInput(rec: AbutRecord): AbutInput { ip1: rec.ip1, ip2: rec.ip2, ip3: rec.ip3, - bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl })), + bearings: rec.bearings.map((b) => ({ no: b.no, pos: b.pos, z: b.z, seatEl: b.seatEl, azimuthDeg: b.azimuthDeg })), skewDeg: rec.skewDeg, slopeI: rec.slopeI, wings: rec.wings, diff --git a/src/core/project.ts b/src/core/project.ts index 7dc7d73..ad9c3a3 100644 --- a/src/core/project.ts +++ b/src/core/project.ts @@ -139,7 +139,7 @@ export function checkStoredAbutments(list: unknown): void { if (!forms.includes(a.wings.left) || !forms.includes(a.wings.right)) throw new Error(`${who}: 날개벽 형식이 맞지 않습니다.`); } for (const b of a.bearings) { - if (!b || !finite(b.no) || !isPoint(b.pos) || !finite(b.z) || (b.seatEl !== undefined && !finite(b.seatEl))) { + if (!b || !finite(b.no) || !isPoint(b.pos) || !finite(b.z) || (b.seatEl !== undefined && !finite(b.seatEl)) || (b.azimuthDeg !== undefined && !finite(b.azimuthDeg))) { throw new Error(`${who}: 받침 ${String(b?.no)}의 값이 맞지 않습니다.`); } } diff --git a/src/main.ts b/src/main.ts index fea4f8d..b7baca7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -793,10 +793,12 @@ function fillAbutBearingCard( box.append(el('p', '받침 ELEVATION TABLE (mm, EL·위치는 m) · 거리는 IP3(① 쪽)에서 · h3·h4는 가정 (QA-15)', 'lbl')); box.append( table( - ['No.', '위치 s', '구간', '교좌면 EL', 'h1 블럭', 'h2 패드', 'z = EL.X', 'h3 받침', 'h4 솔플', 'E', '솔플 상면 EL', 'A', 'B', '블럭 ~ 계단 경계'], + ['No.', '위치 s', '구간', '교좌면 EL', 'h1 블럭', 'h2 패드', 'z = EL.X', 'h3 받침', 'h4 솔플', 'E', '솔플 상면 EL', 'A', 'B', '블럭 ~ 계단 경계', '방위각 CAD / 진북 (°)', '블럭 회전 (°)'], abutElevationRows(model, rule).map((r) => [ String(r.no), m3(r.sFromIp3M), r.seg, m3(r.seatEl), String(r.h1), String(r.h2), m3(r.z), String(r.h3), String(r.h4), String(r.E), m3(r.solplateTopEl), String(r.A), String(r.B), r.stepGapM === null ? '—' : m3(r.stepGapM), + r.azimuthCadDeg === null || r.azimuthNorthDeg === null ? '입력 없음' : `${r.azimuthCadDeg.toFixed(3)} / ${r.azimuthNorthDeg.toFixed(3)}`, + r.rotDeg.toFixed(2), ]), ), ); diff --git a/src/view/abut-scene.ts b/src/view/abut-scene.ts index 6519acf..b554477 100644 --- a/src/view/abut-scene.ts +++ b/src/view/abut-scene.ts @@ -224,11 +224,14 @@ export function buildAbutGroup( // 받침블럭 (spec §4.2, §6.6): concrete 1200 × 1200 from the 교좌면 to 50 below z, and the 몰탈패드 1000 × 1000 × 50 up to z. // The plan follows the 교대축 (받침 방위각 = 축 방향). t is the 전면 +, so the bearing's lateral offset is taken with the front side. + // The block turns by the bearing azimuth (rotDeg, from the 방위각 input) about its own centre. for (const b of model.bearings) { const t = model.frontSide * b.lateral; const blockTop = b.z - 0.05; - add('blocks', prism([[b.s - 0.6, b.seatEl], [b.s + 0.6, b.seatEl], [b.s + 0.6, blockTop], [b.s - 0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, t - 0.6), ex, ey, ez), 0xa9b4bf); - add('blocks', prism([[b.s - 0.5, blockTop], [b.s + 0.5, blockTop], [b.s + 0.5, b.z], [b.s - 0.5, b.z]], 1.0, new THREE.Vector3(0, 0, t - 0.5), ex, ey, ez), 0x6e7a86); + const turn = (b.rotDeg * Math.PI) / 180; + const placed = (g: THREE.BufferGeometry) => g.rotateY(turn).translate(b.s, 0, t); + add('blocks', placed(prism([[-0.6, b.seatEl], [0.6, b.seatEl], [0.6, blockTop], [-0.6, blockTop]], 1.2, new THREE.Vector3(0, 0, -0.6), ex, ey, ez)), 0xa9b4bf); + add('blocks', placed(prism([[-0.5, blockTop], [0.5, blockTop], [0.5, b.z], [-0.5, b.z]], 1.0, new THREE.Vector3(0, 0, -0.5), ex, ey, ez)), 0x6e7a86); } // 접속슬래브 (spec §4.8): bottom on the 브라켓 top (흉벽 상단 − 0.85), 5 mm behind the 흉벽 back face, 7 m back, falling 4 % away from the wall. diff --git a/tests/abut-bearing-azimuth.test.ts b/tests/abut-bearing-azimuth.test.ts new file mode 100644 index 0000000..55d8f6b --- /dev/null +++ b/tests/abut-bearing-azimuth.test.ts @@ -0,0 +1,57 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import * as XLSX from 'xlsx'; +import * as THREE from 'three'; +import { describe, expect, it } from 'vitest'; +import { bearingRotDeg, buildAbut } from '../src/core/abut/geometry'; +import { parseAbutments, toAbutInput } from '../src/core/abut/parse'; +import { buildMembers } from '../src/core/abut/members'; +import { buildWings } from '../src/core/abut/wings'; +import { buildAbutGroup } from '../src/view/abut-scene'; +import { abutElevationRows, resolveBearingRule } from '../src/core/abut/bearing-table'; + +const RAG = fileURLToPath(new URL('../../../RAG/운곡교P1입력.xlsx', import.meta.url)); +const rec = parseAbutments(XLSX.read(readFileSync(RAG), { type: 'buffer' })).abutments[0]; +const model = buildAbut(toAbutInput(rec)); + +describe('교대 받침 방위각 (엑셀 받침 시트 6번째 열)', () => { + it('reads the 방위각 of every bearing (75°56′55.9″ … 75°58′08.4″ in the workbook frame)', () => { + expect(rec.bearings.every((b) => b.azimuthDeg !== undefined && Number.isFinite(b.azimuthDeg))).toBe(true); + // model-xy = 90 − workbook value: 75.94886° → 14.05114° + expect(rec.bearings.find((b) => b.no === 1)!.azimuthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6); + }); + + it('turns each block like the pier rule (≈ 4.42° from the axis normal); against the wing (skew) line the rest is 0.63° (시안 AW01)', () => { + const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide }; + // 날개벽 방향: CAD 각 lineDeg → 진북 = 90 − CAD (시안: 진북 194.68°, CAD 255.32°). + const wingRot = bearingRotDeg(90 - model.wing!.lineDeg, model.u, front); + for (const b of model.bearings) { + expect(Math.abs(b.rotDeg)).toBeGreaterThan(4.3); + expect(Math.abs(b.rotDeg)).toBeLessThan(4.5); + } + const first = model.bearings.find((b) => b.no === 1)!; + expect(Math.abs(first.rotDeg - wingRot)).toBeCloseTo(0.63, 1); + }); + + it('without the 방위각 the blocks stay on the axis (rotation 0)', () => { + const flat = buildAbut({ ...toAbutInput(rec), bearings: toAbutInput(rec).bearings.map((b) => ({ ...b, azimuthDeg: undefined })) }); + expect(flat.bearings.every((b) => b.rotDeg === 0)).toBe(true); + }); + + it('the 3D block turns with the bearing: its footprint is no longer axis-aligned', () => { + const input = toAbutInput(rec); + const members = buildMembers(model, { bottomEl: input.foundationBottomEl, pileLengthMm: input.pileLengthMm }); + const group = buildAbutGroup(model, members, buildWings(model, members)); + const block = (group.getObjectByName('blocks') as THREE.Group).children[0] as THREE.Mesh; + const box = new THREE.Box3().setFromObject(block); + // a turned 1.2 m square is wider than 1.2 m along s + expect(box.max.x - box.min.x).toBeGreaterThan(1.2 + 1e-4); + }); + + it('the ELEVATION table shows the 방위각 (CAD = the workbook value, 진북) and the block turn', () => { + const [r] = abutElevationRows(model, resolveBearingRule()).filter((x) => x.no === 1); + expect(r.azimuthCadDeg).toBeCloseTo(75 + 56 / 60 + 55.9 / 3600, 6); + expect(r.azimuthNorthDeg).toBeCloseTo(90 - (75 + 56 / 60 + 55.9 / 3600), 6); + expect(r.rotDeg).toBeCloseTo(model.bearings.find((b) => b.no === 1)!.rotDeg, 9); + }); +});