feat(core,view): bent coping stage 2 — mitred coping, stretch-local bearings, blocks and columns
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
46709be532
commit
5371932cca
6 files changed
+295
-78
No files matched your search
+120
-59
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ const EXPECTED_COLUMNS: Record<PierType, number> = { '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,
|
||||
);
|
||||
|
||||
@@ -71,6 +71,7 @@ function setStatus(msg: string): void {
|
||||
// --- 3D renderer -------------------------------------------------------------
|
||||
const container = $<HTMLDivElement>('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();
|
||||
|
||||
+33
-13
@@ -30,14 +30,32 @@ const COLORS: Record<PartName, number> = {
|
||||
* 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<PartName, THREE.Group>;
|
||||
@@ -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<PartName, THREE.Group>;
|
||||
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).
|
||||
|
||||
@@ -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<typeof buildAxis>, 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('꺾임점이 코핑 범위 밖');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user