diff --git a/src/core/axis.ts b/src/core/axis.ts new file mode 100644 index 0000000..b9b83f4 --- /dev/null +++ b/src/core/axis.ts @@ -0,0 +1,98 @@ +import type { Vec2 } from './geometry'; + +/** + * Coping axis as a polyline IP1 → (IP4) → IP3 (spec §4.1). Each stretch is straight and has its own frame. + * Coordinates: k = stretch index, s = arc length from IP1 (the same s for both stretches), n = offset to the right of u. + * The lateral offset uses the same convention as the straight model: nrm = (u.n, −u.e). + */ +export interface AxisSegment { + start: Vec2; + u: Vec2; + nrm: Vec2; + /** Arc length at the start of this stretch. */ + s0: number; +} + +export interface Axis { + segments: AxisSegment[]; + /** Total arc length L_IP. */ + length: number; + /** Bend point IP4, or null for a straight axis. */ + kink: Vec2 | null; + /** Mitre normal u1 + u2 (unit) at the kink; null for a straight axis. A point is on stretch 0 when (p − kink)·mitre ≤ 0. */ + mitre: Vec2 | null; +} + +export interface AxisPoint { + k: number; + s: number; + n: number; +} + +const dist = (a: Vec2, b: Vec2) => Math.hypot(b.e - a.e, b.n - a.n); +const sub = (a: Vec2, b: Vec2): Vec2 => ({ e: a.e - b.e, n: a.n - b.n }); +const dot = (a: Vec2, b: Vec2) => a.e * b.e + a.n * b.n; +const unit = (v: Vec2): Vec2 => { + const len = Math.hypot(v.e, v.n); + if (len === 0) throw new Error('축 길이가 0입니다.'); + return { e: v.e / len, n: v.n / len }; +}; + +function frame(start: Vec2, end: Vec2): { u: Vec2; nrm: Vec2 } { + const u = unit(sub(end, start)); + return { u, nrm: { e: u.n, n: -u.e } }; +} + +export function buildAxis(ip1: Vec2, ip3: Vec2, kink: Vec2 | null): Axis { + if (!kink) { + const f = frame(ip1, ip3); + return { segments: [{ start: ip1, ...f, s0: 0 }], length: dist(ip1, ip3), kink: null, mitre: null }; + } + const a = dist(ip1, kink); + const f1 = frame(ip1, kink); + const f2 = frame(kink, ip3); + return { + segments: [ + { start: ip1, ...f1, s0: 0 }, + { start: kink, ...f2, s0: a }, + ], + length: a + dist(kink, ip3), + kink, + mitre: unit({ e: f1.u.e + f2.u.e, n: f1.u.n + f2.u.n }), + }; +} + +/** Stretch and local coordinates of a world point. Points on the stretch-0 side of the mitre plane belong to stretch 0. */ +export function toAxis(axis: Axis, p: Vec2): AxisPoint { + const k = axis.mitre && axis.kink && dot(sub(p, axis.kink), axis.mitre) > 0 ? 1 : 0; + const seg = axis.segments[k]; + const d = sub(p, seg.start); + return { k, s: seg.s0 + dot(d, seg.u), n: dot(d, seg.nrm) }; +} + +/** World point of local coordinates (inverse of toAxis for the same k). */ +export function fromAxis(axis: Axis, q: AxisPoint): Vec2 { + const seg = axis.segments[q.k]; + const ds = q.s - seg.s0; + return { + e: seg.start.e + ds * seg.u.e + q.n * seg.nrm.e, + n: seg.start.n + ds * seg.u.n + q.n * seg.nrm.n, + }; +} + +/** Arc-length span of the coping of length L, centred on the axis (spec §4.2: centre at L_IP / 2). */ +export function copingSpan(axis: Axis, L: number): { sStart: number; sEnd: number } { + return { sStart: axis.length / 2 - L / 2, sEnd: axis.length / 2 + L / 2 }; +} + +/** Arc length of the kink, or null for a straight axis. */ +export function kinkArc(axis: Axis): number | null { + return axis.kink ? axis.segments[1].s0 : null; +} + +/** Positions (arc length) closer than minDistance to the kink. Used to refuse columns at the bend. */ +export function nearKink(axis: Axis, sPositions: number[], minDistance: number): number[] { + const a = kinkArc(axis); + if (a === null) return []; + return sPositions.filter((s) => Math.abs(s - a) < minDistance); +} diff --git a/tests/axis.test.ts b/tests/axis.test.ts new file mode 100644 index 0000000..f84fa76 --- /dev/null +++ b/tests/axis.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { DIM, solveIp4 } from '../src/core/geometry'; +import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from '../src/core/axis'; +import type { PierInput } from '../src/core/types'; +import { loadP1 } from './helpers'; + +const pt = (p: PierInput['ips'][number]) => ({ e: p.x, n: p.y }); +const ip = (p: PierInput, name: string) => pt(p.ips.find((q) => q.name === name)!); + +describe('straight axis (regression against spec §12)', () => { + const p1 = loadP1(); + const axis = buildAxis(ip(p1, 'IP1'), ip(p1, 'IP3'), null); + const bp1 = copingSpan(axis, p1.copingLengthMm / 1000).sStart; + + it('bearing s and n relative to BP1 match spec §12', () => { + const front = p1.bearings.filter((b) => b.row === '전열').sort((a, b) => a.no - b.no); + const back = p1.bearings.filter((b) => b.row === '후열').sort((a, b) => a.no - b.no); + const fs = front.map((b) => toAxis(axis, { e: b.x, n: b.y })); + const bs = back.map((b) => toAxis(axis, { e: b.x, n: b.y })); + expect(fs[0].s - bp1).toBeCloseTo(0.943, 3); + expect(fs[fs.length - 1].s - bp1).toBeCloseTo(10.965, 3); + expect(bs[0].s - bp1).toBeCloseTo(1.029, 3); + expect(bs[bs.length - 1].s - bp1).toBeCloseTo(11.05, 3); + expect(fs[0].n).toBeCloseTo(-0.698, 3); + expect(bs[0].n).toBeCloseTo(0.6, 3); + }); + + it('straight axis: every point is on stretch 0 and round-trips', () => { + for (const b of p1.bearings) { + const p = { e: b.x, n: b.y }; + const q = toAxis(axis, p); + expect(q.k).toBe(0); + const back = fromAxis(axis, q); + expect(back.e).toBeCloseTo(p.e, 9); + expect(back.n).toBeCloseTo(p.n, 9); + } + }); +}); + +// Synthetic bend: IP1 (0, 0), IP2 (1, 0), IP3 (6, 2), 60° right turn. The P1 sheet is straight (IP1, IP2, IP3 collinear), +// so P1 cannot exercise a bend. The kink falls at about s = 7.15 m, inside a 6 m coping centred on the axis. +describe('bent axis IP1 → IP4 → IP3', () => { + const p1 = loadP1(); + const ip1 = { e: 0, n: 0 }; + const ip2 = { e: 1, n: 0 }; + const ip3 = { e: 6, n: 2 }; + const kink = solveIp4(ip1, ip2, ip3, 60)!; + const axis = buildAxis(ip1, ip3, kink); + const a = kinkArc(axis)!; + + it('kink lies on the IP1→IP2 line, at s ≈ 7.15, and the arc length adds the two stretches', () => { + const cross = (kink.e - ip1.e) * (ip2.n - ip1.n) - (kink.n - ip1.n) * (ip2.e - ip1.e); + expect(cross).toBeCloseTo(0, 9); + expect(a).toBeCloseTo(7.15, 1); + const len2 = Math.hypot(ip3.e - kink.e, ip3.n - kink.n); + expect(axis.length).toBeCloseTo(a + len2, 9); + }); + + it('the kink is the continuity point: s = a on both stretches at n = 0', () => { + const from0 = fromAxis(axis, { k: 0, s: a, n: 0 }); + const from1 = fromAxis(axis, { k: 1, s: a, n: 0 }); + expect(from0.e).toBeCloseTo(kink.e, 9); + expect(from0.n).toBeCloseTo(kink.n, 9); + expect(from1.e).toBeCloseTo(kink.e, 9); + expect(from1.n).toBeCloseTo(kink.n, 9); + }); + + it('every bearing keeps its input world coordinates through toAxis → fromAxis', () => { + for (const b of p1.bearings) { + const p = { e: b.x, n: b.y }; + const back = fromAxis(axis, toAxis(axis, p)); + expect(back.e).toBeCloseTo(p.e, 9); + expect(back.n).toBeCloseTo(p.n, 9); + } + }); + + it('a point just before the mitre plane is on stretch 0, just after it on stretch 1', () => { + const u1 = axis.segments[0].u; + const u2 = axis.segments[1].u; + const before = { e: kink.e - 0.01 * u1.e, n: kink.n - 0.01 * u1.n }; + const after = { e: kink.e + 0.01 * u2.e, n: kink.n + 0.01 * u2.n }; + expect(toAxis(axis, before).k).toBe(0); + expect(toAxis(axis, after).k).toBe(1); + }); + + it('coping centred on the axis: span is L_IP/2 ± L/2 and the kink lies inside it', () => { + const L = 6; + const span = copingSpan(axis, L); + expect(span.sStart).toBeCloseTo(axis.length / 2 - L / 2, 9); + expect(span.sStart < a && a < span.sEnd).toBe(true); + }); + + it('columns within the Level half-width of the kink are reported', () => { + const half = DIM.levelWidth / 2; + const columns = [a - 0.5, a + 5]; + expect(nearKink(axis, columns, half)).toEqual([a - 0.5]); + expect(nearKink(axis, [a + 5], half)).toEqual([]); + }); +});