From 5371932cca28b567d6d5d6ae6c7133f034780f13 Mon Sep 17 00:00:00 2001 From: ypshim Date: Thu, 22 Oct 2026 09:22:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(core,view):=20bent=20coping=20stage=202=20?= =?UTF-8?q?=E2=80=94=20mitred=20coping,=20stretch-local=20bearings,=20bloc?= =?UTF-8?q?ks=20and=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildPier builds on buildAxis: each object carries its stretch k and a local s from that stretch's base; coping parts are per stretch with the mitre plane trimming the overlap in the scene (renderer.localClippingEnabled). - Straight P1 output is unchanged (regression tests pass). - Bent coping is supported for 현장타설말뚝 only; footings and piles on a bent axis remain 미실시 until stage 3. Kink outside the coping and columns within the Level half-width of the kink are refused. - W03/W04 use coping coordinates (g). - Tests: constructed 60° bend — world coordinates of bearings, blocks and the column survive the stretch frames; mitre sides; scene mesh count; refusals. Co-Authored-By: Claude Haiku 5.5 --- src/core/geometry.ts | 179 +++++++++++++++++--------- src/core/validate.ts | 8 +- src/main.ts | 1 + src/view/scene.ts | 46 +++++-- tests/bent-coping.test.ts | 135 +++++++++++++++++++ tests/validate-and-foundation.test.ts | 4 +- 6 files changed, 295 insertions(+), 78 deletions(-) create mode 100644 tests/bent-coping.test.ts diff --git a/src/core/geometry.ts b/src/core/geometry.ts index 1ac410c..dec96df 100644 --- a/src/core/geometry.ts +++ b/src/core/geometry.ts @@ -1,3 +1,4 @@ +import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis'; import type { PierInput, Row } from './types'; /** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */ @@ -35,18 +36,31 @@ export interface Segment { top: number; } +/** Stretch k, local s from that stretch's base, g = coping-relative arc length, n = lateral offset. */ export interface BearingLocal { no: number; row: Row; + k: number; s: number; + g: number; n: number; z: number; } +export interface Stretch { + origin: Vec2; + u: Vec2; + nrm: Vec2; + /** Coping-relative arc length where this stretch starts (0 for k = 0, the kink for k = 1). */ + base: number; +} + export interface BlockModel { no: number; row: Row; + k: number; s: number; + g: number; n: number; bottom: number; top: number; @@ -55,6 +69,7 @@ export interface BlockModel { export interface ColumnModel { s: number; + k: number; top: number; bottom: number; diameter: number; @@ -78,6 +93,7 @@ export interface BlindingModel { export interface PileModel { s: number; + k: number; n: number; top: number; bottom: number; @@ -86,6 +102,7 @@ export interface PileModel { /** One coping slab across [nFrom, nTo]; profile is its (s, EL) outline, closed. */ export interface CopingPart { + k: number; nFrom: number; nTo: number; profile: [number, number][]; @@ -113,8 +130,13 @@ export interface PierModel { h1: number; h2: number; segments: Segment[]; - /** Coping split across the width (n range) by the lateral step line; one part when there is no step. */ + /** Coping parts per stretch, split across the width by the lateral step line (one part per stretch without a step). */ copingParts: CopingPart[]; + stretches: Stretch[]; + /** Bend point IP4, or null for a straight axis. */ + kink: Vec2 | null; + /** Mitre normal at the kink (unit). A point is on stretch 0 when (p − kink)·mitre ≤ 0. */ + mitre: Vec2 | null; levelBottom: number; endBottom: number; bearings: BearingLocal[]; @@ -152,15 +174,11 @@ export function solveIp4(ip1: Vec2, ip2: Vec2, ip3: Vec2, angleDeg: number): Vec return { e: ip1.e + t * d1.e, n: ip1.n + t * d1.n }; } -function toLocal(p: Vec2, origin: Vec2, u: Vec2, nrm: Vec2): { s: number; n: number } { - const dx = p.e - origin.e; - const dy = p.n - origin.n; - return { s: dx * u.e + dy * u.n, n: dx * nrm.e + dy * nrm.n }; -} - /** - * Builds the first-stage (shape only) model of a T-type pier from one input record. - * Pure function: same input always gives the same output. Throws for cases not implemented yet. + * Builds the first-stage model of a T-type pier from one input record. Pure function. + * Coordinates: g = arc length from the coping start (BP1), so g = 0 at BP1 and g = L at BP2. + * Each object carries its stretch k and a local s measured from that stretch's base (k = 0: BP1, k = 1: IP4). + * A bent coping (IP4 angle ≠ 0) is supported for 현장타설말뚝 only; footings and piles on a bent axis are stage 3. */ export function buildPier(input: PierInput): PierModel { const ip = (name: string) => { @@ -172,27 +190,42 @@ export function buildPier(input: PierInput): PierModel { const ip2 = ip('IP2'); const ip3 = ip('IP3'); const ip4 = ip('IP4'); - if (ip4.angleDeg !== 0) { - throw new Error('미실시: 꺾인 코핑(IP4 꺾임각 ≠ 0)의 형상은 아직 지원하지 않습니다. IP4 계산식(solveIp4)은 구현되어 있습니다.'); + const bent = ip4.angleDeg !== 0; + if (bent && input.foundationType !== '현장타설말뚝') { + throw new Error('미실시: 꺾인 코핑의 확대기초·말뚝은 3단계에서 구현합니다. 현재는 현장타설말뚝만 지원합니다.'); } - const dx = ip3.x - ip1.x; - const dy = ip3.y - ip1.y; - const lengthIp = Math.hypot(dx, dy); - const u: Vec2 = { e: dx / lengthIp, n: dy / lengthIp }; - const nrm: Vec2 = { e: u.n, n: -u.e }; - const azimuthDeg = (((Math.atan2(u.e, u.n) * 180) / Math.PI) + 360) % 360; - - // IP2 is a reference point at a 1:9 division of IP1–IP3 (intent.PR, spec §2.2). - const ip2Calc: Vec2 = { e: ip1.x + 0.1 * dx, n: ip1.y + 0.1 * dy }; - const ip2Input: Vec2 = { e: ip2.x, n: ip2.y }; - const L = input.copingLengthMm / 1000; const B = input.copingWidthMm / 1000; const planHalfB = B / 2; const ip1v: Vec2 = { e: ip1.x, n: ip1.y }; - const bp1: Vec2 = { e: ip1v.e + u.e * (lengthIp / 2 - L / 2), n: ip1v.n + u.n * (lengthIp / 2 - L / 2) }; - const bp2: Vec2 = { e: bp1.e + u.e * L, n: bp1.n + u.n * L }; + const ip2v: Vec2 = { e: ip2.x, n: ip2.y }; + const ip3v: Vec2 = { e: ip3.x, n: ip3.y }; + const kink = bent ? solveIp4(ip1v, ip2v, ip3v, ip4.angleDeg) : null; + const axis = buildAxis(ip1v, ip3v, kink); + const lengthIp = axis.length; + const sStart = copingSpan(axis, L).sStart; + const arcA = kinkArc(axis); + const kinkG = arcA === null ? null : arcA - sStart; + if (kinkG !== null && (kinkG <= 0 || kinkG >= L)) { + throw new Error('꺾임점이 코핑 범위 밖에 있습니다. 코핑 길이를 확인하세요.'); + } + const base = [0, kinkG ?? 0]; + const { u, nrm } = axis.segments[0]; + const azimuthDeg = (((Math.atan2(u.e, u.n) * 180) / Math.PI) + 360) % 360; + + // IP2 is a 1:9 reference point on a straight axis (intent.PR, spec §2.2). On a bent axis IP2 is on the first stretch, so no check. + const ip2Calc: Vec2 = kink ? ip2v : { e: ip1.x + 0.1 * (ip3.x - ip1.x), n: ip1.y + 0.1 * (ip3.y - ip1.y) }; + const ip2Input: Vec2 = ip2v; + + const pointAt = (g: number, n: number): Vec2 => { + const arc = sStart + g; + const k = kinkG !== null && g > kinkG ? 1 : 0; + return fromAxis(axis, { k, s: arc, n }); + }; + const bp1 = pointAt(0, 0); + const bp2 = pointAt(L, 0); + const pivot = pointAt(L / 2, 0); const front = input.bearings.filter((b) => b.row === '전열').sort((a, b) => a.no - b.no); const back = input.bearings.filter((b) => b.row === '후열').sort((a, b) => a.no - b.no); @@ -203,33 +236,43 @@ export function buildPier(input: PierInput): PierModel { const h1 = front[0].z - input.copingTopEl; const h2 = h1 + (back[0].z - front[0].z); - const frontLocal = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) })); - const backLocal = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...toLocal({ e: b.x, n: b.y }, bp1, u, nrm) })); + const place = (b: { x: number; y: number }) => { + const q = toAxis(axis, { e: b.x, n: b.y }); + const g = q.s - sStart; + return { k: q.k, g, s: g - base[q.k], n: q.n }; + }; + const frontPlaced = front.map((b) => ({ no: b.no, row: '전열' as Row, z: b.z, ...place(b) })); + const backPlaced = back.map((b) => ({ no: b.no, row: '후열' as Row, z: b.z, ...place(b) })); - // Step boundaries sit midway between consecutive front bearings (spec §4.3). + // Step boundaries sit midway between consecutive front bearings in coping coordinates (spec §4.3). const bounds = [0]; - for (let k = 0; k < frontLocal.length - 1; k++) { - bounds.push((frontLocal[k].s + frontLocal[k + 1].s) / 2); + for (let k = 0; k < frontPlaced.length - 1; k++) { + bounds.push((frontPlaced[k].g + frontPlaced[k + 1].g) / 2); } bounds.push(L); - const segments: Segment[] = frontLocal.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 })); + const segments: Segment[] = frontPlaced.map((b, k) => ({ s0: bounds[k], s1: bounds[k + 1], top: b.z - h1 })); // Lateral step (spec §4.3, Q1·Q2 추천안): front and back tops differ by 5 mm or more in some segment. - const backTops = backLocal.map((b) => b.z - h2); + const backTops = backPlaced.map((b) => b.z - h2); const lateralStep = backTops.some((top, k) => Math.abs(segments[k].top - top) >= DIM.lateralTolerance); - // Columns (spec §4.5). Multi-column positions are placed; haunch and separate-footing checks are not. - const colS = columnPositions(input.type, L); + // Columns (spec §4.5). Positions are coping coordinates; a column must stay a Level half-width away from the kink. + const colG = columnPositions(input.type, L); + if (kinkG !== null) { + const near = nearKink(axis, colG.map((g) => sStart + g), DIM.levelWidth / 2); + if (near.length) throw new Error('미실시: 꺾임점 근처(Level 반폭 이내)에 기둥이 있습니다. 기둥 위치를 확인하세요.'); + } + const colK = colG.map((g) => (kinkG !== null && g > kinkG ? 1 : 0)); // Coping underside: ends at (top − end height h1 = 1000), Level zone at (top − h1 − h2 taper height = 1500). const levelBottom = input.copingTopEl - DIM.copingH1 - DIM.copingH2; const endBottom = input.copingTopEl - DIM.copingH1; - const levelStart = Math.min(...colS) - DIM.levelWidth / 2; - const levelEnd = Math.max(...colS) + DIM.levelWidth / 2; + const levelStart = Math.min(...colG) - DIM.levelWidth / 2; + const levelEnd = Math.max(...colG) + DIM.levelWidth / 2; if (levelStart < 0 || levelEnd > L) throw new Error('코핑 길이가 Level 구간보다 짧습니다.'); // Underside from the right end to the left end. Between columns (2·3주형) it drops 300 below Level with 300 × 300 haunches (spec §4.5, Q3). const underside: [number, number][] = [[L, endBottom]]; - const levelZones = [...colS].sort((a, b) => b - a).map((s) => [s - DIM.levelWidth / 2, s + DIM.levelWidth / 2] as const); + const levelZones = [...colG].sort((a, b) => b - a).map((s) => [s - DIM.levelWidth / 2, s + DIM.levelWidth / 2] as const); levelZones.forEach(([zoneStart, zoneEnd], k) => { underside.push([zoneEnd, levelBottom], [zoneStart, levelBottom]); const next = levelZones[k + 1]; @@ -240,26 +283,40 @@ export function buildPier(input: PierInput): PierModel { underside.push([zoneStart - DIM.haunch, lowerEl], [next[1] + DIM.haunch, lowerEl], [next[1], levelBottom]); }); - // Elevation outline for one row of tops, closed by the shared underside. + // Elevation outline in coping coordinates for one row of tops, closed by the shared underside. const outline = (tops: number[]): [number, number][] => { const pts: [number, number][] = [[0, endBottom]]; segments.forEach((seg, k) => pts.push([seg.s0, tops[k]], [seg.s1, tops[k]])); pts.push(...underside); return pts; }; - // The lateral step line lies midway between the front and back bearing rows. - const meanN = (rows: BearingLocal[]) => rows.reduce((sum, b) => sum + b.n, 0) / rows.length; + // Each stretch uses the same outline shifted to its own base; the mitre plane trims the overlap at the kink. + const shifted = (pts: [number, number][], b: number): [number, number][] => pts.map(([s, z]) => [s - b, z]); + + const meanN = (rows: { n: number }[]) => rows.reduce((sum, r) => sum + r.n, 0) / rows.length; const frontTops = segments.map((seg) => seg.top); - const copingParts: CopingPart[] = lateralStep - ? [ - { nFrom: -planHalfB, nTo: (meanN(frontLocal) + meanN(backLocal)) / 2, profile: outline(frontTops) }, - { nFrom: (meanN(frontLocal) + meanN(backLocal)) / 2, nTo: planHalfB, profile: outline(backTops) }, - ] - : [{ nFrom: -planHalfB, nTo: planHalfB, profile: outline(frontTops) }]; + const copingParts: CopingPart[] = []; + const stretchCount = kinkG === null ? 1 : 2; + for (let k = 0; k < stretchCount; k++) { + const fRows = frontPlaced.filter((b) => b.k === k); + const bRows = backPlaced.filter((b) => b.k === k); + const nStep = fRows.length && bRows.length + ? (meanN(fRows) + meanN(bRows)) / 2 + : (meanN(frontPlaced) + meanN(backPlaced)) / 2; + if (lateralStep) { + copingParts.push( + { k, nFrom: -planHalfB, nTo: nStep, profile: shifted(outline(frontTops), base[k]) }, + { k, nFrom: nStep, nTo: planHalfB, profile: shifted(outline(backTops), base[k]) }, + ); + } else { + copingParts.push({ k, nFrom: -planHalfB, nTo: planHalfB, profile: shifted(outline(frontTops), base[k]) }); + } + } const footingTop = input.foundationType === '현장타설말뚝' ? input.footingBottomEl : input.footingBottomEl + DIM.footingH; - const columns: ColumnModel[] = colS.map((s) => ({ - s, + const columns: ColumnModel[] = colG.map((g, i) => ({ + s: g - base[colK[i]], + k: colK[i], top: levelBottom, bottom: footingTop, diameter: DIM.columnDiameter, @@ -272,11 +329,11 @@ export function buildPier(input: PierInput): PierModel { const pileTip = input.footingBottomEl - pileLength; if (input.foundationType === '현장타설말뚝') { - for (const s of colS) { - piles.push({ s, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: DIM.columnDiameter }); - } + columns.forEach((c) => { + piles.push({ s: c.s, k: c.k, n: 0, top: input.footingBottomEl, bottom: pileTip, diameter: DIM.columnDiameter }); + }); } else { - for (const g of footingGroups(colS)) { + for (const g of footingGroups(colG)) { footings.push({ s: g.s, top: footingTop, bottom: input.footingBottomEl, length: g.length, width: DIM.footingW }); blindings.push({ s: g.s, @@ -292,18 +349,18 @@ export function buildPier(input: PierInput): PierModel { } const blocks: BlockModel[] = []; - frontLocal.forEach((b) => { - const bottom = segmentTop(segments, b.s); - blocks.push({ no: b.no, row: '전열', s: b.s, n: b.n, bottom, top: b.z - DIM.padThickness, padTop: b.z }); + 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 }); }); - backLocal.forEach((b) => { + backPlaced.forEach((b) => { const bottom = b.z - h2; - blocks.push({ no: b.no, row: '후열', s: b.s, 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 }); }); const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom; const blockHeightBack = blocks.find((b) => b.row === '후열')!.top - blocks.find((b) => b.row === '후열')!.bottom; - const diffs = frontLocal.map((f, k) => f.z - backLocal[k].z); + const diffs = frontPlaced.map((f, k) => f.z - backPlaced[k].z); const frontBackDiffSpread = Math.max(...diffs) - Math.min(...diffs); return { @@ -318,7 +375,7 @@ export function buildPier(input: PierInput): PierModel { dIp2: (ip2Calc.e - bp1.e) * u.e + (ip2Calc.n - bp1.n) * u.n, bp1, bp2, - pivot: { e: bp1.e + u.e * (L / 2), n: bp1.n + u.n * (L / 2) }, + pivot, copingL: L, copingB: B, copingRefTop: input.copingTopEl, @@ -326,9 +383,12 @@ export function buildPier(input: PierInput): PierModel { h2, segments, copingParts, + stretches: axis.segments.map((seg, k) => ({ origin: k === 0 ? bp1 : seg.start, u: seg.u, nrm: seg.nrm, base: base[k] })), + kink, + mitre: axis.mitre, levelBottom, endBottom, - bearings: [...frontLocal, ...backLocal].map((b) => ({ no: b.no, row: b.row, s: b.s, n: b.n, z: b.z })), + bearings: [...frontPlaced, ...backPlaced].map((b) => ({ no: b.no, row: b.row, k: b.k, s: b.s, g: b.g, n: b.n, z: b.z })), blocks, columns, footings, @@ -405,6 +465,7 @@ export function pilesFor(s: number, topEl: number, length: number, footingLength for (let j = 0; j < countN; j++) { out.push({ s: s + (i - (countS - 1) / 2) * spacingS, + k: 0, n: (j - (countN - 1) / 2) * spacingN, top: topEl, bottom: topEl - length, diff --git a/src/core/validate.ts b/src/core/validate.ts index 2eb8d0e..009100b 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -6,7 +6,7 @@ const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3 /** Rules from spec §10 that are not implemented in this stage. Shown to the user, never silently skipped. */ export const NOT_IMPLEMENTED_RULES = [ 'E04 fck 미입력 (배근 단계, 3단계)', - '꺾인 코핑 형상 (IP4 꺾임각 ≠ 0; IP4 계산식만 구현)', + '꺾인 코핑의 확대기초·말뚝 (3단계; 현장타설말뚝만 지원)', 'W06 속성 복사 건너뜀 (2단계)', 'W08 솔플레이트 T1−T2 검사 (2단계)', ] as const; @@ -51,7 +51,7 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] const halfBlock = DIM.blockSize / 2; const stepBounds = model.segments.slice(1).map((seg) => seg.s0); for (const b of model.bearings) { - const nearest = Math.min(...stepBounds.map((s) => Math.abs(b.s - s))); + const nearest = Math.min(...stepBounds.map((s) => Math.abs(b.g - s))); if (nearest < halfBlock) { issues.push({ code: 'W03', @@ -65,8 +65,8 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] const planHalfB = model.copingB / 2; const outside = model.blocks.some( (b) => - b.s - halfBlock < 0 || - b.s + halfBlock > model.copingL || + b.g - halfBlock < 0 || + b.g + halfBlock > model.copingL || b.n - halfBlock < -planHalfB || b.n + halfBlock > planHalfB, ); diff --git a/src/main.ts b/src/main.ts index 68e61cf..9659d9b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -71,6 +71,7 @@ function setStatus(msg: string): void { // --- 3D renderer ------------------------------------------------------------- const container = $('canvas'); const renderer = new THREE.WebGLRenderer({ antialias: true }); +renderer.localClippingEnabled = true; // mitre planes on the bent coping renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); container.appendChild(renderer.domElement); const world = new THREE.Scene(); diff --git a/src/view/scene.ts b/src/view/scene.ts index c434652..334d663 100644 --- a/src/view/scene.ts +++ b/src/view/scene.ts @@ -30,14 +30,32 @@ const COLORS: Record = { * The scene uses y as up and places the first column centre at the origin (spec §1). * Basis vectors: X = (u.e, 0, -u.n), Z = (nrm.e, 0, -nrm.n). The determinant is +1, so faces keep their winding. */ -export function frameMatrix(m: PierModel): THREE.Matrix4 { - const X = new THREE.Vector3(m.u.e, 0, -m.u.n); +export function stretchMatrix(m: PierModel, k: number): THREE.Matrix4 { + const st = m.stretches[k]; + const X = new THREE.Vector3(st.u.e, 0, -st.u.n); const Y = new THREE.Vector3(0, 1, 0); - const Z = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n); - const origin = new THREE.Vector3(m.bp1.e - m.pivot.e, 0, -(m.bp1.n - m.pivot.n)); + const Z = new THREE.Vector3(st.nrm.e, 0, -st.nrm.n); + const origin = new THREE.Vector3(st.origin.e - m.pivot.e, 0, -(st.origin.n - m.pivot.n)); return new THREE.Matrix4().makeBasis(X, Y, Z).setPosition(origin); } +/** + * Mitre plane at the bend point. Stretch 0 keeps the side (p − kink)·mitre ≤ 0 and stretch 1 the other side, + * so each stretch's full-length slab is trimmed to its own half and the two meet at the mitred joint. + * Three.js keeps fragments with a non-negative distance from the plane, hence the sign flip for stretch 0. + */ +/** Straight-frame matrix (stretch 0). */ +export function frameMatrix(m: PierModel): THREE.Matrix4 { + return stretchMatrix(m, 0); +} + +export function mitrePlane(m: PierModel, k: number): THREE.Plane | undefined { + if (!m.kink || !m.mitre) return undefined; + const normal = new THREE.Vector3(m.mitre.e, 0, -m.mitre.n).multiplyScalar(k === 0 ? -1 : 1); + const point = new THREE.Vector3(m.kink.e - m.pivot.e, 0, -(m.kink.n - m.pivot.n)); + return new THREE.Plane().setFromNormalAndCoplanarPoint(normal, point); +} + export interface PierScene { root: THREE.Group; parts: Record; @@ -45,15 +63,15 @@ export interface PierScene { } export function buildPierScene(m: PierModel): PierScene { - const M = frameMatrix(m); + const Ms = m.stretches.map((_, k) => stretchMatrix(m, k)); const parts = {} as Record; for (const key of Object.keys(PART_LABELS) as PartName[]) { parts[key] = new THREE.Group(); parts[key].name = key; } - const add = (part: PartName, geometry: THREE.BufferGeometry, opacity = 1) => { - geometry.applyMatrix4(M); + const add = (part: PartName, geometry: THREE.BufferGeometry, opacity = 1, k = 0, clippingPlanes?: THREE.Plane[]) => { + geometry.applyMatrix4(Ms[k]); const material = new THREE.MeshStandardMaterial({ color: COLORS[part], roughness: 0.75, @@ -61,22 +79,24 @@ export function buildPierScene(m: PierModel): PierScene { transparent: opacity < 1, opacity, side: THREE.DoubleSide, + clippingPlanes, }); parts[part].add(new THREE.Mesh(geometry, material)); }; - // Coping: each part's elevation outline extruded across its width (no boolean operations). + // Coping: each part's elevation outline extruded across its width (no boolean operations), trimmed by the mitre plane. for (const part of m.copingParts) { const shape = new THREE.Shape(part.profile.map(([s, z]) => new THREE.Vector2(s, z))); const coping = new THREE.ExtrudeGeometry(shape, { depth: part.nTo - part.nFrom, bevelEnabled: false }); coping.translate(0, 0, part.nFrom); - add('coping', coping); + const plane = mitrePlane(m, part.k); + add('coping', coping, 1, part.k, plane ? [plane] : undefined); } for (const c of m.columns) { const g = new THREE.CylinderGeometry(c.diameter / 2, c.diameter / 2, c.top - c.bottom, 48); g.translate(c.s, (c.top + c.bottom) / 2, 0); - add('columns', g); + add('columns', g, 1, c.k); } for (const f of m.footings) { @@ -94,17 +114,17 @@ export function buildPierScene(m: PierModel): PierScene { for (const p of m.piles) { const g = new THREE.CylinderGeometry(p.diameter / 2, p.diameter / 2, p.top - p.bottom, 20); g.translate(p.s, (p.top + p.bottom) / 2, p.n); - add('piles', g); + add('piles', g, 1, p.k); } for (const b of m.blocks) { const h = b.top - b.bottom; const g = new THREE.BoxGeometry(1.2, h, 1.2); g.translate(b.s, b.bottom + h / 2, b.n); - add('blocks', g); + add('blocks', g, 1, b.k); const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0); pad.translate(b.s, b.padTop - 0.025, b.n); - add('pads', pad); + add('pads', pad, 1, b.k); } // Ground: a translucent plane at the ground EL, used only to judge soil cover (not a computed input). diff --git a/tests/bent-coping.test.ts b/tests/bent-coping.test.ts new file mode 100644 index 0000000..8d1ce3f --- /dev/null +++ b/tests/bent-coping.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import * as THREE from 'three'; +import { buildPier, solveIp4, type PierModel } from '../src/core/geometry'; +import { buildAxis, copingSpan, fromAxis, kinkArc } from '../src/core/axis'; +import { buildPierScene, mitrePlane } from '../src/view/scene'; +import type { PierInput } from '../src/core/types'; +import { loadP1 } from './helpers'; + +// Constructed bend: IP1 (0, 0), IP2 (1, 0), IP3 (6, 2), 60° right turn. The kink sits at arc ≈ 7.15 m, +// inside a 6 m coping centred on the axis, and a T형 column (arc ≈ 4.73) stays clear of it. +const IP1 = { e: 0, n: 0 }; +const IP3 = { e: 6, n: 2 }; +const ANGLE = 60; +const L = 6; +const B = 3.1; +const FRONT_N = -0.7; +const BACK_N = 0.6; +const GS = [0.8, 1.9, 3.0, 4.1, 5.2]; + +function bentInput(foundationType: PierInput['foundationType'] = '현장타설말뚝'): PierInput { + const base = loadP1(); + const axis = buildAxis(IP1, IP3, solveIp4(IP1, { e: 1, n: 0 }, IP3, ANGLE)); + const sStart = copingSpan(axis, L).sStart; + const z = base.copingTopEl + 0.15; + const bearingsAt = (row: '전열' | '후열', n: number) => + GS.map((g, i) => { + const w = world(axis, sStart + g, n); + return { no: i + 1, row, x: w.e, y: w.n, z, azimuthDeg: 0 }; + }); + return { + ...base, + type: 'T형', + columnCount: 1, + foundationType, + copingLengthMm: L * 1000, + copingWidthMm: B * 1000, + ips: [ + { name: 'IP1', x: IP1.e, y: IP1.n, angleDeg: 0 }, + { name: 'IP2', x: 1, y: 0, angleDeg: 0 }, + { name: 'IP3', x: IP3.e, y: IP3.n, angleDeg: 0 }, + { name: 'IP4', x: 0, y: 0, angleDeg: ANGLE }, + ], + bearings: [...bearingsAt('전열', FRONT_N), ...bearingsAt('후열', BACK_N)], + }; +} + +function world(axis: ReturnType, arc: number, n: number) { + const k = kinkArc(axis) !== null && arc > kinkArc(axis)! ? 1 : 0; + return fromAxis(axis, { k, s: arc, n }); +} + +/** World point of a model object: stretch origin + s·u + n·nrm (inverse of the scene placement). */ +function modelWorld(m: PierModel, k: number, s: number, n: number) { + const st = m.stretches[k]; + return { e: st.origin.e + s * st.u.e + n * st.nrm.e, n: st.origin.n + s * st.u.n + n * st.nrm.n }; +} + +const toScene = (m: PierModel, p: { e: number; n: number }) => new THREE.Vector3(p.e - m.pivot.e, 0, -(p.n - m.pivot.n)); + +describe('bent coping model (stage 2)', () => { + const input = bentInput(); + const m = buildPier(input); + const axis = buildAxis(IP1, IP3, solveIp4(IP1, { e: 1, n: 0 }, IP3, ANGLE)); + + it('two stretches split at the kink, inside the coping', () => { + expect(m.stretches).toHaveLength(2); + expect(m.kink).not.toBeNull(); + const a = kinkArc(axis)!; + const kinkG = a - copingSpan(axis, L).sStart; + expect(m.stretches[1].base).toBeCloseTo(kinkG, 9); + expect(kinkG).toBeGreaterThan(0); + expect(kinkG).toBeLessThan(L); + }); + + it('coping has one part per stretch (no lateral step in this input)', () => { + expect(m.copingParts.map((p) => p.k)).toEqual([0, 1]); + }); + + it('every bearing and block keeps its input world coordinates through its stretch frame', () => { + for (const b of m.bearings) { + const want = input.bearings.find((x) => x.row === b.row && x.no === b.no)!; + const got = modelWorld(m, b.k, b.s, b.n); + expect(got.e).toBeCloseTo(want.x, 6); + expect(got.n).toBeCloseTo(want.y, 6); + } + for (const blk of m.blocks) { + const want = input.bearings.find((x) => x.row === blk.row && x.no === blk.no)!; + const got = modelWorld(m, blk.k, blk.s, blk.n); + expect(got.e).toBeCloseTo(want.x, 6); + expect(got.n).toBeCloseTo(want.y, 6); + } + }); + + it('the column sits on the centre line in stretch 0 and keeps clear of the kink', () => { + const c = m.columns[0]; + expect(c.k).toBe(0); + const world = modelWorld(m, c.k, c.s, 0); + const arc = copingSpan(axis, L).sStart + L / 2; + const expected = fromAxis(axis, { k: 0, s: arc, n: 0 }); + expect(world.e).toBeCloseTo(expected.e, 6); + expect(world.n).toBeCloseTo(expected.n, 6); + }); + + it('mitre plane trims stretch 0 before the kink and keeps stretch 1 after it', () => { + const a = kinkArc(axis)!; + const plane0 = mitrePlane(m, 0)!; + const plane1 = mitrePlane(m, 1)!; + const before = toScene(m, fromAxis(axis, { k: 0, s: a - 0.5, n: 0 })); + const after = toScene(m, fromAxis(axis, { k: 0, s: a + 0.5, n: 0 })); + expect(plane0.distanceToPoint(before)).toBeGreaterThan(0); + expect(plane0.distanceToPoint(after)).toBeLessThan(0); + expect(plane1.distanceToPoint(after)).toBeGreaterThan(0); + expect(plane1.distanceToPoint(before)).toBeLessThan(0); + }); + + it('scene builds one coping mesh per part, with mitre clipping on each', () => { + const scene = buildPierScene(m); + const coping = scene.parts.coping.children as THREE.Mesh[]; + expect(coping).toHaveLength(2); + for (const mesh of coping) { + const mat = mesh.material as THREE.MeshStandardMaterial; + expect(mat.clippingPlanes?.length ?? 0).toBe(1); + } + }); + + it('bent coping with footings or piles on the axis is refused until stage 3', () => { + expect(() => buildPier(bentInput('말뚝기초'))).toThrow('미실시'); + expect(() => buildPier(bentInput('직접기초'))).toThrow('미실시'); + }); + + it('a kink outside the coping is refused', () => { + const bad = { ...input, copingLengthMm: 2000 }; + expect(() => buildPier(bad)).toThrow('꺾임점이 코핑 범위 밖'); + }); +}); diff --git a/tests/validate-and-foundation.test.ts b/tests/validate-and-foundation.test.ts index 4ea788a..b2320d6 100644 --- a/tests/validate-and-foundation.test.ts +++ b/tests/validate-and-foundation.test.ts @@ -92,7 +92,7 @@ describe('bearing and block checks (spec §10 W03, W04)', () => { it('W03 when a bearing centre is closer than half a block width to a step boundary', () => { const m = buildPier(loadP1()); const boundary = m.segments[1].s0; - const bearings = m.bearings.map((b, k) => (k === 0 ? { ...b, s: boundary + 0.3 } : b)); + const bearings = m.bearings.map((b, k) => (k === 0 ? { ...b, g: boundary + 0.3 } : b)); const issues: Issue[] = []; validatePier(loadP1(), { ...m, bearings }, issues); expect(issues.map((i) => i.code)).toContain('W03'); @@ -100,7 +100,7 @@ describe('bearing and block checks (spec §10 W03, W04)', () => { it('W04 when a block footprint extends past the coping end', () => { const m = buildPier(loadP1()); - const blocks = m.blocks.map((b, k) => (k === 0 ? { ...b, s: 0.1 } : b)); + const blocks = m.blocks.map((b, k) => (k === 0 ? { ...b, g: 0.1 } : b)); const issues: Issue[] = []; validatePier(loadP1(), { ...m, blocks }, issues); expect(issues.map((i) => i.code)).toContain('W04');