feat(view): solplate in the 3D model (2단계): wedges from the drawing's A, B, S; read the solplate table from DXF

Each bearing gets a solplate wedge: tapered along the 교축 with T1 on the 시점 side, underside at the bearing top (block
top + h3). A, B come from the drawing's ELEVATION table, S and T1/T2 from the SOLPLATE table (NO, S(%), L, W, T, T1, T2),
read by position. The 공통 규칙 (T 30, margin 100, h3 181) feed the plan; changing them regenerates the piers. A new
"솔플레이트" part can be shown or hidden. A solplate table is read only when its S values are found; a missing S is reported.
Also commits the option-1 test change (drawing EL.X minus Excel z).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-23 14:46:53 +09:00
1 parent 1d8746d533
commit a50f4f5ee5
10 files changed
+579 -13

No files matched your search

+4
View File
@@ -114,6 +114,10 @@ export interface DrawingBearing {
elx: number;
h1: number;
h2: number;
/** 받침 평면 A, B (mm) and solplate slope S (%), when read from the drawing (승인 2026-10-23). */
A?: number;
B?: number;
S?: number;
}
/** Tolerance for the coping top check against the drawing (승인 2026-10-23: 5 mm). */
+43 -1
View File
@@ -96,7 +96,49 @@ export function parseBearingElevationTable(texts: DxfText[]): BearingTableResult
errors.push(`No ${r.no}: EL.X·h1·h2 중 값이 없습니다.`);
continue;
}
drawing.push({ no: r.no, elx, h1, h2 });
drawing.push({ no: r.no, elx, h1, h2, A: r.values.a, B: r.values.b });
}
return { rows, drawing: drawing.sort((a, b) => a.no - b.no), errors };
}
export interface SolplateTableRow {
no: number;
/** 기울기 S (%), negative as drawn. */
S: number;
L: number;
W: number;
/** 중심 두께 T, and the 교각축 직각 (−) and (+) side thicknesses T1, T2 (mm). */
T: number;
T1: number;
T2: number;
}
/**
* 솔플레이트 TABLE (승인 2026-10-23): the header row has NO, S(%), L, W, T, T1, T2; each bearing row gives the values under
* their headers, as in the bearing ELEVATION table.
*/
export function parseSolplateTable(texts: DxfText[]): { rows: SolplateTableRow[]; errors: string[] } {
const sHeader = texts.find((t) => norm(t.text) === 's(%)' || norm(t.text) === 's%');
if (!sHeader) return { rows: [], errors: ['솔플레이트 TABLE의 S(%) 머리글을 찾지 못했습니다.'] };
const headerY = sHeader.y;
const headerRow = texts.filter((t) => Math.abs(t.y - headerY) <= 60);
const key = (name: string) => headerRow.find((t) => norm(t.text) === name);
const cols = { S: key('s(%)') ?? key('s%'), L: key('l'), W: key('w'), T: key('t'), T1: key('t1'), T2: key('t2') };
const noHeader = headerRow.find((t) => norm(t.text) === 'no');
if (!cols.L || !cols.W || !cols.T || !cols.T1 || !cols.T2 || !noHeader) {
return { rows: [], errors: ['솔플레이트 TABLE의 머리글(NO, L, W, T, T1, T2)이 모두 있어야 합니다.'] };
}
const noCells = texts.filter((t) => NUMBER.test(t.text) && Number.isInteger(Number(t.text)) && Math.abs(t.x - noHeader.x) <= 250 && t.y < headerY - 60);
const at = (col: DxfText, y: number) => {
const hit = texts.find((t) => Math.abs(t.x - col.x) <= 150 && Math.abs(t.y - y) <= 120 && NUMBER.test(t.text));
return hit ? Number(hit.text) : NaN;
};
const rows: SolplateTableRow[] = [];
const errors: string[] = [];
for (const cell of noCells.sort((a, b) => b.y - a.y)) {
const r = { no: Number(cell.text), S: cols.S ? at(cols.S, cell.y) : NaN, L: at(cols.L, cell.y), W: at(cols.W, cell.y), T: at(cols.T, cell.y), T1: at(cols.T1, cell.y), T2: at(cols.T2, cell.y) };
if (Object.values(r).some((v) => Number.isNaN(v))) errors.push(`No ${r.no}: 솔플레이트 값이 빠졌습니다.`);
else rows.push(r);
}
return { rows: rows.sort((a, b) => a.no - b.no), errors };
}
+10 -2
View File
@@ -69,6 +69,8 @@ export interface BlockModel {
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;
/** Solplate plan of this bearing (A, B, S, T, margin, h3), when the input gives it. */
plan?: { A: number; B: number; S: number; T: number; margin: number; h3: number };
}
/** Skew of a bearing row (deg) from the bearing azimuth and the stretch axis direction u (see {@link BlockModel.rowSkewDeg}). */
@@ -400,13 +402,19 @@ 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);
// Solplate plans from the input (승인 2026-10-23); the 공통 규칙 default is T 30, margin 100, h3 181.
const rules = input.solplate ?? { T: 30, margin: 100, h3: 181 };
const planOf = (no: number) => {
const p = input.bearingPlans?.find((q) => q.no === no);
return p ? { A: p.A, B: p.B, S: p.S, T: rules.T, margin: rules.margin, h3: rules.h3 } : undefined;
};
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, rowSkewDeg: skewOf(b.no, b.k) });
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), plan: planOf(b.no) });
});
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, rowSkewDeg: skewOf(b.no, b.k) });
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), plan: planOf(b.no) });
});
const blockHeightFront = blocks.find((b) => b.row === '전열')!.top - blocks.find((b) => b.row === '전열')!.bottom;
+4
View File
@@ -40,6 +40,10 @@ export interface PierInput {
pileRows?: number[];
ips: IpPoint[];
bearings: Bearing[];
/** 솔플레이트 plan per bearing (승인 2026-10-23): A, B (mm) and the slope S (%). Absent = no solplate. */
bearingPlans?: { no: number; A: number; B: number; S: number }[];
/** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */
solplate?: { T: number; margin: number; h3: number };
}
export type IssueLevel = 'E' | 'W' | 'I';
+16 -4
View File
@@ -16,7 +16,7 @@ import { buildRebar3D, type RebarColorMode, type RebarScene } from './view/rebar
import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, materialFrom, type BendFactors, type FckByMember, type FckMember, type GradeKey, type Material } from './core/rebar/common';
import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity';
import { parseDrawingRows } from './core/drawing-import';
import { parseBearingElevationTable, readDxfTexts } from './core/dxf-table';
import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table';
import { P1_COPING_GEOM } from './core/coping-geom';
import { applyBearingRules, BEARING_ELEVATION_REF, BEARING_RULES_DEFAULT, type BearingBasis, type BearingRules, type DrawingBearing, DRAWING_TOL_MM, drawingCopingCheck, SOLPLATE_REF, SOLPLATE_T_RULE, solplateTs, withDrawingBasis } from './core/bearing-ref';
@@ -46,6 +46,7 @@ type Stage = 'shape' | 'plan' | 'rebar' | 'quantity';
const PART_GROUPS: { label: string; parts: PartName[] }[] = [
{ label: '받침·받침블럭', parts: ['blocks', 'pads'] },
{ label: '솔플레이트', parts: ['solplates'] },
{ label: '코핑', parts: ['coping'] },
{ label: '기둥', parts: ['columns'] },
{ label: '확대기초', parts: ['footings'] },
@@ -207,7 +208,12 @@ function buildEntry(rawInput: PierInput, checked: boolean): Pier {
let model: PierModel | null = null;
// The model uses the drawing EL.X when the project basis is 'drawing' and this pier has drawing values (Q16).
const drawing = bearingBasis === 'drawing' ? bearingDrawing[input.name] : undefined;
const modelInput = drawing?.length ? withDrawingBasis(input, drawing) : input;
const baseInput = drawing?.length ? withDrawingBasis(input, drawing) : input;
// Solplate plans come from the drawing rows that have A, B and S, whatever the 받침 기준 (승인 2026-10-23).
const plans = (bearingDrawing[input.name] ?? [])
.filter((d) => d.A !== undefined && d.B !== undefined && d.S !== undefined)
.map((d) => ({ no: d.no, A: d.A as number, B: d.B as number, S: d.S as number }));
const modelInput: PierInput = { ...baseInput, bearingPlans: plans, solplate: { T: bearingRules.T, margin: bearingRules.margin, h3: bearingRules.h3 } };
if (validateInput(input, issues)) {
try {
model = buildPier(modelInput);
@@ -1351,12 +1357,17 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void {
// A DXF drawing: the 교량받침 ELEVATION TABLE is read by position. Otherwise a sheet with No, EL.X, h1, h2.
let drawingRows: DrawingBearing[];
if (file.name.toLowerCase().endsWith('.dxf')) {
const r = parseBearingElevationTable(readDxfTexts(await file.text()));
const texts = readDxfTexts(await file.text());
const r = parseBearingElevationTable(texts);
if (r.errors.length) {
setError(`도면 불러오기 실패: ${r.errors.slice(0, 3).join(' ')}`);
return;
}
drawingRows = r.drawing;
// The solplate table gives S per bearing (승인 2026-10-23); a missing S is reported, not guessed.
const sp = parseSolplateTable(texts);
const sByNo = new Map(sp.rows.map((x) => [x.no, x.S]));
drawingRows = r.drawing.map((d) => ({ ...d, S: sByNo.get(d.no) }));
if (sp.errors.length) setStatus(`솔플레이트 값 확인: ${sp.errors.slice(0, 2).join(' ')}`);
} else {
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = wb.Sheets[wb.SheetNames[0]];
@@ -1421,6 +1432,7 @@ function renderBearingRef(box: HTMLDivElement, pier: Pier): void {
return;
}
bearingRules = { ...bearingRules, [key]: v };
regenerateAllPiers();
renderAll();
});
grid.append(lab, input, el('span', `기본 ${BEARING_RULES_DEFAULT[key]}`, 'muted small'));
+32 -1
View File
@@ -1,7 +1,8 @@
import * as THREE from 'three';
import type { PierModel } from '../core/geometry';
import { solplateGeometry } from '../core/solplate';
export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'ground';
export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'solplates' | 'ground';
export const PART_LABELS: Record<PartName, string> = {
coping: '코핑',
@@ -11,6 +12,7 @@ export const PART_LABELS: Record<PartName, string> = {
blinding: '버림콘크리트',
blocks: '받침블럭',
pads: '모르타르 패드',
solplates: '솔플레이트',
ground: '지반',
};
@@ -22,6 +24,7 @@ const COLORS: Record<PartName, number> = {
blinding: 0xe6e2d8,
blocks: 0xd6dde5,
pads: 0x8898aa,
solplates: 0x6f7c8a,
ground: 0x6b8f4e,
};
@@ -136,6 +139,11 @@ export function buildPierScene(m: PierModel): PierScene {
pad.rotateY(turn);
pad.translate(b.s, b.padTop - 0.025, b.n);
add('pads', pad, 1, b.k);
if (b.plan) {
// Solplate: a wedge that tapers along the 교축 (T1 on the 시점 side), on the bearing (승인 2026-10-23).
const sp = solplateGeometry(b, b.plan.h3, { A: b.plan.A, B: b.plan.B, margin: b.plan.margin, T: b.plan.T, slopePct: b.plan.S });
add('solplates', wedgeGeometry(sp.corners), 1, b.k);
}
}
// Ground: a translucent plane at the ground EL, used only to judge soil cover (not a computed input).
@@ -186,3 +194,26 @@ export function viewPosition(scene: PierScene, kind: ViewKind, m: PierModel): {
};
return { position: target.clone().add(dirs[kind].multiplyScalar(dist)), target };
}
/**
* A wedge from the eight corners of solplateGeometry: corner index (ai, ci, t) = (ai·2 + ci)·2 + t, with ai the 교축 side
* (0 = 시점), ci the 교각축 직각 side and t 0 = underside, 1 = top. Each face is a quad split into two triangles.
*/
function wedgeGeometry(corners: [number, number, number][]): THREE.BufferGeometry {
const idx = (ai: number, ci: number, t: number) => (ai * 2 + ci) * 2 + t;
const quads = [
[idx(0, 0, 0), idx(0, 1, 0), idx(1, 1, 0), idx(1, 0, 0)],
[idx(0, 0, 1), idx(1, 0, 1), idx(1, 1, 1), idx(0, 1, 1)],
[idx(0, 0, 0), idx(0, 1, 0), idx(0, 1, 1), idx(0, 0, 1)],
[idx(1, 0, 0), idx(1, 0, 1), idx(1, 1, 1), idx(1, 1, 0)],
[idx(0, 0, 0), idx(0, 0, 1), idx(1, 0, 1), idx(1, 0, 0)],
[idx(0, 1, 0), idx(1, 1, 0), idx(1, 1, 1), idx(0, 1, 1)],
];
const index: number[] = [];
for (const [q0, q1, q2, q3] of quads) index.push(q0, q1, q2, q0, q2, q3);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(corners.flat(), 3));
geo.setIndex(index);
geo.computeVertexNormals();
return geo;
}