feat(abut): 교대 형상·입력 패널과 교각 받침·솔플레이트 개편
- 교대 형상(src/core/abut, 3D 장면, 목록·카메라·맞춤)과 교대 입력 패널 추가 - 교각 받침 기준 카드 제거, 솔플레이트 규칙을 교각별 입력으로 변경 - 받침 ELEVATION·솔플레이트 TABLE을 모델값으로 연결 - 교대 입력값 카드: 형식 행에 시점·종점 표시, 제목을 "A1 입력값 [입력]"으로 변경 - 테스트 614건 통과, 타입 검사 통과 Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b1b2ad1ddb
commit
1d31d2f3a9
44 files changed
+2571
-120
No files matched your search
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* 교대 3D 장면 (2026-11-03): 벽체, 흉벽, 저판, 말뚝, 날개벽, 헌치를 Three.js 그룹으로 만든다. 순수 함수 (렌더러 없음).
|
||||
*
|
||||
* 좌표 (장면 기준): x = 교대축 방향 s (IP1 → IP3), y = EL (위 +), z = 교대축 직각 t (전면 +).
|
||||
* 날개벽 평면(E, N)은 축 기준 (s, t)로 바꿔 쓴다: s = (P − IP1)·u, t = (P − IP1)·(전면 법선).
|
||||
* 흉벽·벽체 단면은 모식도다. 흉벽은 저판 상면에서 상단까지 그린다(교좌면 아래 세부는 배근 단계에서).
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type { AbutModel, AbutPoint } from '../core/abut/geometry';
|
||||
import { buildMembers, type AbutMembers } from '../core/abut/members';
|
||||
import { buildWings, type WingSolid } from '../core/abut/wings';
|
||||
import type { TerrainGrid } from '../core/ground-model';
|
||||
import { terrainGeometry } from './terrain';
|
||||
|
||||
export const ABUT_GROUP_NAMES = ['wall', 'parapet', 'footing', 'piles', 'wings', 'haunches', 'ground'] as const;
|
||||
/** 지반 pad margin (m) around the abutment plan, like the pier's GROUND_PAD. */
|
||||
export const ABUT_GROUND_MARGIN_M = 2;
|
||||
|
||||
/** The third axis point is IP3; the model keeps IP1 only, so it is taken from the axis length and direction. */
|
||||
function input3(model: AbutModel): AbutPoint {
|
||||
return { e: model.ip1.e + model.u.e * model.length, n: model.ip1.n + model.u.n * model.length };
|
||||
}
|
||||
|
||||
/** True when every point (with margin m) lies inside the terrain grid's E–N extent. */
|
||||
export function covers(grid: TerrainGrid, points: AbutPoint[], margin: number): boolean {
|
||||
const eMin = grid.origin[1];
|
||||
const eMax = grid.origin[1] + (grid.ny - 1) * grid.step;
|
||||
const nMin = grid.origin[0];
|
||||
const nMax = grid.origin[0] + (grid.nx - 1) * grid.step;
|
||||
return points.every((p) => p.e - margin >= eMin && p.e + margin <= eMax && p.n - margin >= nMin && p.n + margin <= nMax);
|
||||
}
|
||||
|
||||
/** Extrudes a closed profile given in its own (x, y) plane, placed by the basis vectors ax, ay and the extrusion az. */
|
||||
function prism(profile: [number, number][], depth: number, origin: THREE.Vector3, ax: THREE.Vector3, ay: THREE.Vector3, az: THREE.Vector3): THREE.BufferGeometry {
|
||||
const shape = new THREE.Shape(profile.map(([x, y]) => new THREE.Vector2(x, y)));
|
||||
const geom = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false, steps: 1 });
|
||||
geom.applyMatrix4(new THREE.Matrix4().makeBasis(ax, ay, az).setPosition(origin));
|
||||
return geom;
|
||||
}
|
||||
|
||||
function material(color: number): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide, roughness: 0.85 });
|
||||
}
|
||||
|
||||
/** Local (s, t) of a plan point, relative to the IP1 end of the axis. */
|
||||
function toAxis(model: AbutModel, p: AbutPoint): { s: number; t: number } {
|
||||
const d = { e: p.e - model.ip1.e, n: p.n - model.ip1.n };
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { s: d.e * model.u.e + d.n * model.u.n, t: d.e * front.e + d.n * front.n };
|
||||
}
|
||||
|
||||
function wingDirectionAxis(model: AbutModel): { ws: number; wt: number } {
|
||||
const rad = ((model.wing?.lineDeg ?? 0) * Math.PI) / 180;
|
||||
const w = { e: Math.cos(rad), n: Math.sin(rad) };
|
||||
const front = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
return { ws: w.e * model.u.e + w.n * model.u.n, wt: w.e * front.e + w.n * front.n };
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement of the abutment in the pier scene (2026-11-03). The pier scene maps (E, N, EL) to
|
||||
* (E − pivot.E, EL, −(N − pivot.N)); the abutment's local (s, t, EL) → (E, N) with s along u and t along the front
|
||||
* normal, then the same mapping. So the abutment and any pier in the same workbook share one frame.
|
||||
*/
|
||||
export function abutPlacement(model: AbutModel, pivot: { e: number; n: number }): THREE.Matrix4 {
|
||||
const f = { e: model.nLeft.e * model.frontSide, n: model.nLeft.n * model.frontSide };
|
||||
const xs = new THREE.Vector3(model.u.e, 0, -model.u.n);
|
||||
const y = new THREE.Vector3(0, 1, 0);
|
||||
const zt = new THREE.Vector3(f.e, 0, -f.n);
|
||||
const origin = new THREE.Vector3(model.ip1.e - pivot.e, 0, -(model.ip1.n - pivot.n));
|
||||
return new THREE.Matrix4().makeBasis(xs, y, zt).setPosition(origin);
|
||||
}
|
||||
|
||||
export function buildAbutGroup(
|
||||
model: AbutModel,
|
||||
members: AbutMembers = buildMembers(model),
|
||||
wings: WingSolid[] = buildWings(model, members),
|
||||
pivot?: { e: number; n: number },
|
||||
groundEl?: number,
|
||||
terrain?: TerrainGrid,
|
||||
): THREE.Group {
|
||||
const root = new THREE.Group();
|
||||
root.name = model.name;
|
||||
if (pivot) {
|
||||
// applyMatrix4 keeps auto-update on, so the world matrix is refreshed by the scene and by bounding-box calls.
|
||||
root.applyMatrix4(abutPlacement(model, pivot));
|
||||
}
|
||||
const L = model.length;
|
||||
const ex = new THREE.Vector3(1, 0, 0);
|
||||
const ey = new THREE.Vector3(0, 1, 0);
|
||||
const ez = new THREE.Vector3(0, 0, 1);
|
||||
const add = (name: (typeof ABUT_GROUP_NAMES)[number], geom: THREE.BufferGeometry, color: number) => {
|
||||
const mesh = new THREE.Mesh(geom, material(color));
|
||||
mesh.name = name;
|
||||
let group = root.getObjectByName(name) as THREE.Group | undefined;
|
||||
if (!group) {
|
||||
group = new THREE.Group();
|
||||
group.name = name;
|
||||
root.add(group);
|
||||
}
|
||||
group.add(mesh);
|
||||
};
|
||||
|
||||
const footingTop = members.footingTopEl;
|
||||
const footingBottom = members.footingBottomEl;
|
||||
|
||||
// 벽체: one segment per 교좌면 step, from the footing top to the 교좌면, t −0.8 … +1.6.
|
||||
for (const seg of members.wallSegments) {
|
||||
const profile: [number, number][] = [
|
||||
[seg.sFrom, footingTop],
|
||||
[seg.sTo, footingTop],
|
||||
[seg.sTo, seg.seatEl],
|
||||
[seg.sFrom, seg.seatEl],
|
||||
];
|
||||
add('wall', prism(profile, members.shape.wallFrontM - members.shape.wallBackM, new THREE.Vector3(0, 0, members.shape.wallBackM), ex, ey, ez), 0xb7c7d3);
|
||||
}
|
||||
|
||||
// 흉벽: t −0.5 … 0, from the footing top up to the parapet top line.
|
||||
const parapetProfile: [number, number][] = [
|
||||
[0, footingTop],
|
||||
[L, footingTop],
|
||||
[L, members.parapetTopEndEl],
|
||||
[0, members.parapetTopStartEl],
|
||||
];
|
||||
add('parapet', prism(parapetProfile, -members.shape.parapetBackM, new THREE.Vector3(0, 0, members.shape.parapetBackM), ex, ey, ez), 0x8898aa);
|
||||
|
||||
if (footingBottom !== undefined) {
|
||||
// 저판: s 0 … L, t −1.8 … +2.8, from the 기초 하단 to the 저판 상면.
|
||||
const footProfile: [number, number][] = [
|
||||
[0, members.shape.footingBackM],
|
||||
[L, members.shape.footingBackM],
|
||||
[L, members.shape.footingFrontM],
|
||||
[0, members.shape.footingFrontM],
|
||||
];
|
||||
add('footing', prism(footProfile, members.shape.footingThicknessM, new THREE.Vector3(0, footingBottom, 0), ex, ez, ey), 0x5a6a78);
|
||||
}
|
||||
|
||||
// 말뚝: D 600 (r 0.3) at each row and bar, from the 기초 하단 down to the 선단.
|
||||
if (members.pileBottomEl !== undefined && footingBottom !== undefined) {
|
||||
const height = footingBottom - members.pileBottomEl;
|
||||
for (const row of members.piles) {
|
||||
for (const s of row.s) {
|
||||
const geom = new THREE.CylinderGeometry(0.3, 0.3, height, 16);
|
||||
geom.translate(s, members.pileBottomEl + height / 2, row.t);
|
||||
add('piles', geom, 0x7d8b99);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 날개벽: the side profile (d along the wing, EL) extruded across the 0.45 m thickness toward the inside of the axis.
|
||||
const dir = wingDirectionAxis(model);
|
||||
for (const wing of wings) {
|
||||
const sEnd = wing.sEnd;
|
||||
const a = sEnd === 0 ? 1 : -1;
|
||||
const profile: [number, number][] = [
|
||||
...wing.topLine.map((p) => [p.dM, p.el] as [number, number]),
|
||||
...[...wing.bottomLine].reverse().map((p) => [p.dM, p.el] as [number, number]),
|
||||
];
|
||||
const ax = new THREE.Vector3(dir.ws, 0, dir.wt);
|
||||
const geom = prism(profile, 0.45, new THREE.Vector3(sEnd, 0, 0), ax, ey, new THREE.Vector3(a, 0, 0));
|
||||
add('wings', geom, 0xc9a77c);
|
||||
for (const h of wing.haunches) {
|
||||
const shape: [number, number][] = h.corners.map((c) => {
|
||||
const p = toAxis(model, c);
|
||||
return [p.s, p.t];
|
||||
});
|
||||
const zBottom = h.zBottomEl;
|
||||
const height = h.zTopEl - h.zBottomEl;
|
||||
if (height > 0) add('haunches', prism(shape, height, new THREE.Vector3(0, zBottom, 0), ex, ez, ey), 0xc9a77c);
|
||||
}
|
||||
}
|
||||
|
||||
// 지형: when the ground model's terrain grid covers the abutment footprint, the terrain is drawn (the same frame as the
|
||||
// piers); otherwise a flat pad at 지반고 covers the footing and the wings.
|
||||
const footprint = [model.ip1, input3(model), ...wings.flatMap((w) => w.corners)];
|
||||
const grid = terrain && pivot && covers(terrain, footprint, ABUT_GROUND_MARGIN_M) ? terrain : undefined;
|
||||
if (grid && pivot) {
|
||||
const mesh = new THREE.Mesh(terrainGeometry(grid, pivot), new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
group.add(mesh);
|
||||
root.add(group);
|
||||
} else if (groundEl !== undefined) {
|
||||
const ts = [members.shape.footingBackM, members.shape.footingFrontM, ...wings.flatMap((w) => w.corners.map((c) => toAxis(model, c).t))];
|
||||
const tMin = Math.min(...ts) - ABUT_GROUND_MARGIN_M;
|
||||
const tMax = Math.max(...ts) + ABUT_GROUND_MARGIN_M;
|
||||
const sMin = -ABUT_GROUND_MARGIN_M;
|
||||
const sMax = L + ABUT_GROUND_MARGIN_M;
|
||||
const pad = new THREE.BoxGeometry(sMax - sMin, 0.05, tMax - tMin);
|
||||
pad.translate((sMin + sMax) / 2, groundEl, (tMin + tMax) / 2);
|
||||
const mesh = new THREE.Mesh(pad, new THREE.MeshStandardMaterial({ color: 0x6b8f4e, roughness: 0.9, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide }));
|
||||
mesh.name = 'ground';
|
||||
const group = new THREE.Group();
|
||||
group.name = 'ground';
|
||||
group.add(mesh);
|
||||
root.add(group);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera for looking at one abutment (2026-11-03): the target is the centre of its bounding box, and the camera stands
|
||||
* back along a diagonal from the bridge side so both the wall and the piles are in view.
|
||||
*/
|
||||
export function cameraForBox(box: THREE.Box3): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||
const target = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const span = Math.max(size.x, size.y, size.z, 1);
|
||||
const dist = span * 1.6;
|
||||
const position = new THREE.Vector3(target.x + dist * 0.6, target.y + dist * 0.5, target.z + dist * 0.8);
|
||||
return { position, target };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { bearingEdgeDistances, pairEccentricities } from '../core/bearing-layout';
|
||||
import { BLOCK_DEPTH_MM } from '../core/bearing-table';
|
||||
|
||||
/** Number of pairs the 상세 보기 chips can show (①–⑩ in the mockup). */
|
||||
export const PAIR_CHIP_LABELS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
|
||||
@@ -88,6 +89,8 @@ export function bearingPlanSvg(m: PierModel, pairIndex: number): string {
|
||||
const highEdge = ib ? Y(ib.s) + 600 : Y(k.s) + 600;
|
||||
parts.push(vdim(lowEdge, Y(f.s), `C4 ${Math.round(d(f.no).leftMm)}`));
|
||||
parts.push(vdim(Y(k.s), highEdge, `C5 ${Math.round(d(k.no).rightMm)}`));
|
||||
// C6: block depth along the 교각축 (1200 for 분리 and 일체 alike).
|
||||
parts.push(`<text x="${xRight + 60}" y="${highEdge + 110}" font-size="90" fill="#18212B">C6 ${BLOCK_DEPTH_MM} (교각축 방향 블록 폭)</text>`);
|
||||
|
||||
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 블록 한 줄, 교축·교각축 방향, C1·C2·C3, C4·C5, 도면값 겹쳐 보기">${parts.join('')}</svg>`;
|
||||
return `<svg viewBox="-1900 -1300 3800 2600" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 평면: 쌍 ${pair.frontNo}·${pair.backNo}, 교축·교각축 방향, C1–C6">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PierModel } from '../core/geometry';
|
||||
import { pairEccentricities } from '../core/bearing-layout';
|
||||
import { pairCopingSpans } from '../core/bearing-table';
|
||||
|
||||
/**
|
||||
* 교량받침 상세 · 단면 (elevation) for one pair (승인 2026-10-26): the model's blocks, mortar, bearing body,
|
||||
* and solplate per bearing. x is the 교축 (n), y the EL (up). Labels: EL.X (mortar top), h1 (block), h3 (bearing body),
|
||||
* T (solplate, taper T1 at the (−) side, T2 at the (+) side).
|
||||
* 교량받침 삽도 · 단면 (elevation) for one pair (승인 2026-10-26, 모델값 2026-11-03): the model's blocks, mortar, bearing
|
||||
* body and solplate per bearing. x is the 교축 (n), y the EL (up). Labels per bearing: EL.X (mortar top), h2 · h3 · h4 · E,
|
||||
* h1 (block). Below: S1 (bearing centre to the coping edge), S2 (between the bearings), S1, and the coping width B.
|
||||
*/
|
||||
export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
|
||||
const pair = pairEccentricities(m.blocks)[pairIndex];
|
||||
@@ -34,16 +35,35 @@ export function bearingSectionSvg(m: PierModel, pairIndex: number): string {
|
||||
for (const b of [f, k]) {
|
||||
const pad = 1000;
|
||||
parts.push(`<rect x="${X(b.n) - pad / 2}" y="${Y(b.padTop)}" width="${pad}" height="${50}" fill="#B7C7D3" stroke="#6E8496" stroke-width="6"></rect>`);
|
||||
const { L, h3, T1, T2 } = b.plan;
|
||||
const { L, h3, T, T1, T2 } = b.plan;
|
||||
const bodyTop = b.padTop + h3 / 1000;
|
||||
parts.push(`<rect x="${X(b.n) - (L - 200) / 2}" y="${Y(bodyTop)}" width="${L - 200}" height="${h3}" fill="#8898AA" stroke="#5B6676" stroke-width="6"></rect>`);
|
||||
const x0 = X(b.n) - L / 2;
|
||||
const x1 = X(b.n) + L / 2;
|
||||
parts.push(`<polygon points="${x0},${Y(bodyTop)} ${x1},${Y(bodyTop)} ${x1},${Y(bodyTop + T2 / 1000)} ${x0},${Y(bodyTop + T1 / 1000)}" fill="#F3D9B8" stroke="#B4531F" stroke-width="6"></polygon>`);
|
||||
// EL.X at the mortar top, bearing number.
|
||||
// EL.X at the mortar top, bearing number, and the heights of this bearing.
|
||||
const E = Math.round((b.top - b.bottom) * 1000) + Math.round((b.padTop - b.top) * 1000) + h3 + T;
|
||||
parts.push(`<circle cx="${X(b.n)}" cy="${Y(b.padTop)}" r="30" fill="#18212B"></circle>`);
|
||||
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 60}" font-size="90" fill="#18212B">${b.no} · EL.X ${b.padTop.toFixed(3)}</text>`);
|
||||
parts.push(`<text x="${X(b.n) + 60}" y="${Y(b.padTop) - 260}" font-size="80" fill="#55606E">h2 ${Math.round((b.padTop - b.top) * 1000)} · h3 ${h3} · h4 ${T} · E ${E}</text>`);
|
||||
parts.push(`<text x="${X(b.n)}" y="${Y(b.bottom) + 160}" text-anchor="middle" font-size="90" fill="#55606E">h1 ${Math.round((b.top - b.bottom) * 1000)}</text>`);
|
||||
}
|
||||
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 상세 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X">${parts.join('')}</svg>`;
|
||||
|
||||
// S1 · S2 · S1 along 교축 below the blocks: bearing centre to the coping edge (S1), between the bearings (S2).
|
||||
const spans = pairCopingSpans(m, pairIndex);
|
||||
if (spans) {
|
||||
const lo = Math.min(f.n, k.n);
|
||||
const xLo = X(lo);
|
||||
const xHi = xLo + spans.S2;
|
||||
const xEdge0 = xLo - spans.S1Low;
|
||||
const xEdge1 = xHi + spans.S1High;
|
||||
const yDim = Math.max(Y(f.bottom), Y(k.bottom)) + 420;
|
||||
const dim = (x1: number, x2: number, label: string) =>
|
||||
`<line x1="${x1}" y1="${yDim}" x2="${x2}" y2="${yDim}" stroke="#55606E" stroke-width="6"></line><line x1="${x1}" y1="${yDim - 50}" x2="${x1}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><line x1="${x2}" y1="${yDim - 50}" x2="${x2}" y2="${yDim + 50}" stroke="#55606E" stroke-width="6"></line><text x="${(x1 + x2) / 2}" y="${yDim + 110}" text-anchor="middle" font-size="90" fill="#18212B">${label}</text>`;
|
||||
parts.push(dim(xEdge0, xLo, `S1 ${spans.S1Low}`));
|
||||
parts.push(dim(xLo, xHi, `S2 ${spans.S2}`));
|
||||
parts.push(dim(xHi, xEdge1, `S1 ${spans.S1High}`));
|
||||
parts.push(`<text x="${(xEdge0 + xEdge1) / 2}" y="${yDim + 280}" text-anchor="middle" font-size="90" fill="#55606E">코핑 폭 B ${spans.copingWidth} (받침중심 ↔ 코핑 연단: S1 · S2)</text>`);
|
||||
}
|
||||
return `<svg viewBox="-2000 -1200 4000 2000" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="교량받침 삽도 단면: 블록, 모르타르, 받침, 솔플레이트, EL.X, S1·S2 코핑 치수">${parts.join('')}</svg>`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Camera that contains a sphere (2026-11-03): keeps the view direction of `position → target`, moves the target to the
|
||||
* sphere centre, and backs off until the sphere fits both the vertical and the horizontal field of view.
|
||||
*/
|
||||
export function fitCamera(
|
||||
position: THREE.Vector3,
|
||||
target: THREE.Vector3,
|
||||
sphere: THREE.Sphere,
|
||||
fovDeg: number,
|
||||
aspect: number,
|
||||
margin = 1.1,
|
||||
): { position: THREE.Vector3; target: THREE.Vector3 } {
|
||||
const halfV = THREE.MathUtils.degToRad(fovDeg / 2);
|
||||
// The narrower of the two half-angles decides how far back the camera must stand.
|
||||
const halfH = Math.atan(Math.tan(halfV) * aspect);
|
||||
const halfFov = Math.min(halfV, halfH);
|
||||
const fit = (sphere.radius / Math.sin(halfFov)) * margin;
|
||||
const dir = position.clone().sub(target);
|
||||
const current = dir.length();
|
||||
const dist = Math.max(current, fit);
|
||||
dir.normalize();
|
||||
return {
|
||||
position: sphere.center.clone().add(dir.multiplyScalar(dist)),
|
||||
target: sphere.center.clone(),
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { BlockModel } from '../core/geometry';
|
||||
|
||||
/**
|
||||
* 솔플레이트 상세 (승인 2026-10-26, C안 2026-11-03) for one bearing: the plan (L across, W along the 교각축) and the
|
||||
* taper in side view (T1 at the (−) side, T2 at the (+) side, S % · T). Thickness is exaggerated for legibility.
|
||||
*/
|
||||
export function solplateDetailSvg(block: BlockModel): string {
|
||||
const plan = block.plan;
|
||||
const { L, W, T1: t1, T2: t2 } = plan;
|
||||
const parts: string[] = [];
|
||||
// Plan (left): L across, W along.
|
||||
parts.push(`<rect x="${-L / 2}" y="${-W / 2}" width="${L}" height="${W}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></rect>`);
|
||||
parts.push(`<text x="0" y="${-W / 2 - 40}" text-anchor="middle" font-size="60" fill="#18212B">L ${L.toFixed(0)}</text>`);
|
||||
parts.push(`<text x="${L / 2 + 40}" y="0" font-size="60" fill="#18212B">W ${W.toFixed(0)}</text>`);
|
||||
// Side view (right): thickness T1 at x = −L/2 and T2 at x = +L/2, shown with the taper.
|
||||
const ox = L / 2 + 500;
|
||||
const sc = 4; // exaggerated thickness for legibility, labelled
|
||||
parts.push(`<polygon points="${ox - L / 2},0 ${ox + L / 2},0 ${ox + L / 2},${-t2 * sc} ${ox - L / 2},${-t1 * sc}" fill="#F3D9B8" stroke="#B4531F" stroke-width="8"></polygon>`);
|
||||
parts.push(`<text x="${ox - L / 2}" y="${-t1 * sc - 40}" font-size="60" fill="#18212B">T1 ${t1.toFixed(1)}</text>`);
|
||||
parts.push(`<text x="${ox + L / 2}" y="${-t2 * sc - 40}" text-anchor="end" font-size="60" fill="#18212B">T2 ${t2.toFixed(1)}</text>`);
|
||||
parts.push(`<text x="${ox}" y="120" text-anchor="middle" font-size="60" fill="#55606E">S ${plan.slopePct} % · T ${plan.T} (두께 과장 ×${sc})</text>`);
|
||||
const minX = -L / 2 - 200;
|
||||
const maxX = ox + L / 2 + 200;
|
||||
return `<svg viewBox="${minX} -900 ${maxX - minX} 1300" width="100%" style="max-width: 560px; height: auto" role="img" aria-label="솔플레이트 상세: 평면 L × W, 두께 T1 · T2 (두께 과장)">${parts.join('')}</svg>`;
|
||||
}
|
||||
Reference in new issue
Block a user