diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 2dbe05b..9f812de 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -66,6 +66,25 @@ export interface BlockModel { bottom: number; top: number; padTop: number; + /** Skew (deg, −90…90] of the bearing row against the perpendicular of the stretch axis; the block sides follow the row. */ + rowSkewDeg: number; +} + +/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */ +export function rowSkewDeg(bearingAzimuthDeg: number, u: Vec2): number { + if (Number.isNaN(bearingAzimuthDeg)) return 0; + const axisAz = (Math.atan2(u.e, u.n) * 180) / Math.PI; + const d = (((bearingAzimuthDeg - (axisAz + 90)) % 180) + 540) % 180; // 0…180 + return d > 90 ? d - 180 : d; +} + +/** + * (s, n) of a point (a along the block's s side, c along its n side) measured from the block centre, after the block turns + * by its row skew. Same turn as the 3D geometry (rotateY(−skew)). + */ +export function blockFramePoint(b: Pick, a: number, c: number): [number, number] { + const d = (b.rowSkewDeg * Math.PI) / 180; + return [b.s + a * Math.cos(d) - c * Math.sin(d), b.n + a * Math.sin(d) + c * Math.cos(d)]; } export interface ColumnModel { @@ -372,13 +391,15 @@ export function buildPier(input: PierInput): PierModel { } const blocks: BlockModel[] = []; + const bearingAz = new Map(input.bearings.map((r) => [r.no, r.azimuthDeg])); + const skewOf = (no: number, k: number) => rowSkewDeg(bearingAz.get(no) ?? NaN, axis.segments[k].u); frontPlaced.forEach((b) => { const bottom = segmentTop(segments, b.g); - blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z }); + blocks.push({ no: b.no, row: '전열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k) }); }); backPlaced.forEach((b) => { const bottom = b.z - h2; - blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z }); + blocks.push({ no: b.no, row: '후열', k: b.k, s: b.s, g: b.g, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z, rowSkewDeg: skewOf(b.no, b.k) }); }); const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom; diff --git a/src/main.ts b/src/main.ts index 215155c..73da512 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1242,6 +1242,12 @@ function renderPlan(): void { $('member-summary').textContent = `기본: ${defaultSummary(member.id)} · 선택: ${defaultSummary(member.id)}`; renderShapeOptions(member.id); $('member-note').textContent = member.note; + if (member.id === 'bearing' && pier?.model) { + // Row skew of the bearing blocks (계산값): the block sides follow the bearing row (승인 2026-10-23). + const front = pier.model.blocks.find((b) => b.row === '전열'); + const back = pier.model.blocks.find((b) => b.row === '후열'); + $('member-note').textContent += ` 받침 열 기울기(교축 직각 기준, 계산값): 전열 ${front?.rowSkewDeg.toFixed(2) ?? '—'}° · 후열 ${back?.rowSkewDeg.toFixed(2) ?? '—'}°.`; + } const regen = $('member-regen'); const fields = $('member-fields'); if (!pier) { diff --git a/src/view/rebar3d.ts b/src/view/rebar3d.ts index 02ed773..6511dc3 100644 --- a/src/view/rebar3d.ts +++ b/src/view/rebar3d.ts @@ -5,6 +5,7 @@ import { bottomPiece, MAIN, widthLayout } from '../core/rebar/coping-main'; import { C14_INSET, c14Bars, CAP, COPING_REBAR, SHEAR_LOOP_STEP, shearBars, shearBarsMulti, shearUHeight, copingSideBars, SIDE_HOOK_LEG, stairBars, undersideAt } from '../core/rebar/coping'; import { columnRebar, COLUMN_P1 } from '../core/rebar/column'; import { barsAcross, BLOCK_REBAR } from '../core/rebar/bearing-block'; +import { blockFramePoint } from '../core/geometry'; import { bendRadius, DEFAULT_MATERIAL, FckByMember, hookLength, Material, materialFor, NOMINAL_DIAMETER } from '../core/rebar/common'; import { diameterGroupIndex, DIAMETER_GROUPS, memberOfBar, type RebarMemberKey } from '../core/rebar/color'; import { stretchMatrix } from './scene'; @@ -468,18 +469,21 @@ export function buildRebar3D(m: PierModel, common: Material = DEFAULT_MATERIAL, const legM = BLOCK_REBAR.legLength / 1000; const offsets = Array.from({ length: across.count }, (_, i) => (across.firstEnd + i * across.spacing) / 1000 - BLOCK_REBAR.dim / 2000); const dBar = NOMINAL_DIAMETER.H19; + // The bars are drawn in the block's own frame (a along its s side, c along its n side) and turned with the block. for (const b of m.blocks) { const z2 = b.top - BLOCK_REBAR.depthS2 / 1000; const z1 = b.top - BLOCK_REBAR.depthS1 / 1000; + const P = (a: number, c: number, y: number): [number, number, number] => { + const [s, n] = blockFramePoint(b, a, c); + return [s, y, n]; + }; for (const off of offsets) { - const n2 = b.n + off; - addBar('S2', b.k, [b.s - half, z2, n2], [b.s + half, z2, n2], dBar, COLOR.block); - addBar('S2', b.k, [b.s - half, z2 - legM, n2], [b.s - half, z2, n2], dBar, COLOR.block); - addBar('S2', b.k, [b.s + half, z2, n2], [b.s + half, z2 - legM, n2], dBar, COLOR.block); - const s1 = b.s + off; - addBar('S1', b.k, [s1, z1, b.n - half], [s1, z1, b.n + half], dBar, COLOR.block); - addBar('S1', b.k, [s1, z1 - legM, b.n - half], [s1, z1, b.n - half], dBar, COLOR.block); - addBar('S1', b.k, [s1, z1, b.n + half], [s1, z1 - legM, b.n + half], dBar, COLOR.block); + addBar('S2', b.k, P(-half, off, z2), P(half, off, z2), dBar, COLOR.block); + addBar('S2', b.k, P(-half, off, z2 - legM), P(-half, off, z2), dBar, COLOR.block); + addBar('S2', b.k, P(half, off, z2), P(half, off, z2 - legM), dBar, COLOR.block); + addBar('S1', b.k, P(off, -half, z1), P(off, half, z1), dBar, COLOR.block); + addBar('S1', b.k, P(off, -half, z1 - legM), P(off, -half, z1), dBar, COLOR.block); + addBar('S1', b.k, P(off, half, z1), P(off, half, z1 - legM), dBar, COLOR.block); } } diff --git a/src/view/scene.ts b/src/view/scene.ts index cb8b4ff..640a2bf 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -125,11 +125,15 @@ export function buildPierScene(m: PierModel): PierScene { } for (const b of m.blocks) { + // Turn the block so its sides follow the bearing row (rowSkewDeg); the 3D turn is rotateY(−skew). + const turn = (-b.rowSkewDeg * Math.PI) / 180; const h = b.top - b.bottom; const g = new THREE.BoxGeometry(1.2, h, 1.2); + g.rotateY(turn); g.translate(b.s, b.bottom + h / 2, b.n); add('blocks', g, 1, b.k); const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0); + pad.rotateY(turn); pad.translate(b.s, b.padTop - 0.025, b.n); add('pads', pad, 1, b.k); } diff --git a/tests/bearing-skew.test.ts b/tests/bearing-skew.test.ts new file mode 100644 index 0000000..d7f2ba8 --- /dev/null +++ b/tests/bearing-skew.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier, blockFramePoint } from '../src/core/geometry'; +import { loadP1 } from './helpers'; + +const model = buildPier(loadP1()); +const front = model.blocks.filter((b) => b.row === '전열'); +const back = model.blocks.filter((b) => b.row === '후열'); + +describe('받침블럭 방위 (삽도 · 승인 2026-10-23)', () => { + it('P1 rows are skewed by the bearing azimuth against the axis perpendicular: front ≈ −4.42°, back ≈ −3.11°', () => { + expect(front.every((b) => Math.abs(b.rowSkewDeg - -4.42) < 0.02)).toBe(true); + expect(back.every((b) => Math.abs(b.rowSkewDeg - -3.11) < 0.02)).toBe(true); + }); + + it('a block side follows the bearing row: the n side of a block runs along (−sinδ, cosδ)', () => { + const b = front[0]; + const [s1, n1] = blockFramePoint(b, 0, -0.6); + const [s2, n2] = blockFramePoint(b, 0, 0.6); + const ds = s2 - s1; + const dn = n2 - n1; + const len = Math.hypot(ds, dn); + const d = (b.rowSkewDeg * Math.PI) / 180; + expect(ds / len).toBeCloseTo(-Math.sin(d), 9); + expect(dn / len).toBeCloseTo(Math.cos(d), 9); + }); + + it('turning keeps the block centre and the bearing spacing (the row is unchanged)', () => { + const b = front[0]; + const [s0, n0] = blockFramePoint(b, 0, 0); + expect(s0).toBeCloseTo(b.s, 12); + expect(n0).toBeCloseTo(b.n, 12); + }); + + it('the turned block corners stay inside the coping ends', () => { + const L = model.copingL; + for (const b of model.blocks) { + for (const [a, c] of [[-0.6, -0.6], [-0.6, 0.6], [0.6, -0.6], [0.6, 0.6]] as const) { + const [s] = blockFramePoint(b, a, c); + expect(s, `block ${b.no}`).toBeGreaterThan(0); + expect(s, `block ${b.no}`).toBeLessThan(L); + } + } + }); +});