feat(ground): 지반·지층 card inputs, layer rows, water level, section drawing

- types/ground.ts: 위치 (육상·하상·해상), 지하수위 고려 and EL, 지층 행
  (name and 하단 EL, from the top down). Checks G01 (layer order), G02 (water
  level range), G03 (layer name).
- The 지반고 still feeds the soil cover check; the layer rows and water level
  are inputs and do not change the 3D model.
- view/ground-svg.ts: the 삽도 drawn from the inputs and the generated
  footings and piles on one EL scale.
- 일괄 적용 copies 위치 and 지하수위 to the checked piers (속성 복사 item,
  off by default). Changes list shows arrays and booleans as text.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
ypshimandClaude Haiku 5.5 committed 2026-10-26 10:11:42 +09:00
1 parent 0e70cfb969
commit 16ab317094
8 files changed
+400 -6

No files matched your search

+79
View File
@@ -0,0 +1,79 @@
import type { PierModel } from '../core/geometry';
import type { PierInput } from '../core/types';
import { SUPPORT_STRATUM } from '../core/ground';
/** Layer fills from the top down (시안 2026-10-26: 지층 1 light, deeper layers darker). */
const LAYER_FILLS = ['#EADFC8', '#DCCBA8', '#C9B38D', '#B9A37E', '#A8926F', '#97815F'];
const WIDTH = 400;
const PLOT_H = 300;
const TOP_PAD = 24;
/**
* 지반·지층 단면 (삽도). Drawn from the inputs and the generated footings and piles, on one vertical EL scale.
* Horizontal positions are schematic (no dimensions): piles are spread across the footing.
*/
export function groundSectionSvg(input: PierInput, model: PierModel): string {
const rows = input.strataRows ?? [];
const ground = input.groundEl;
const footTop = Math.max(...model.footings.map((f) => f.top));
const footBottom = Math.min(...model.footings.map((f) => f.bottom));
const pileTips = model.piles.map((p) => p.bottom);
const lowestRow = rows.length ? rows[rows.length - 1].bottomEl : ground - 1;
const water = input.groundWaterOn && input.groundWaterEl !== undefined && Number.isFinite(input.groundWaterEl) ? input.groundWaterEl : undefined;
const yMin = Math.min(footBottom, lowestRow, ...pileTips, water ?? ground) - 1;
const yMax = Math.max(ground, footTop, input.copingTopEl) + 0.5;
const scale = PLOT_H / (yMax - yMin);
const y = (el: number) => TOP_PAD + (yMax - el) * scale;
const H = TOP_PAD + PLOT_H + 16;
const parts: string[] = [];
// Layers: each band runs from the upper boundary (ground for the first) down to its 하단 EL.
let upper = ground;
rows.forEach((row, i) => {
const top = y(upper);
const bottom = y(row.bottomEl);
parts.push(`<rect x="0" y="${top}" width="${WIDTH}" height="${bottom - top}" fill="${LAYER_FILLS[i % LAYER_FILLS.length]}"></rect>`);
parts.push(`<text x="8" y="${(top + bottom) / 2 + 4}" font-size="12" fill="#18212B">${escapeText(row.name)}</text>`);
if (row.name === SUPPORT_STRATUM) {
parts.push(`<line x1="0" y1="${top}" x2="${WIDTH}" y2="${top}" stroke="#5B4A2E" stroke-width="2"></line>`);
parts.push(`<text x="8" y="${top - 4}" font-size="12" fill="#18212B">연암층 상면 EL ${upper.toFixed(3)}</text>`);
}
upper = row.bottomEl;
});
if (rows.length === 0 || upper > yMin) {
parts.push(`<rect x="0" y="${y(upper)}" width="${WIDTH}" height="${y(yMin) - y(upper)}" fill="${LAYER_FILLS[rows.length % LAYER_FILLS.length]}"></rect>`);
}
// Ground line and label.
parts.push(`<line x1="0" y1="${y(ground)}" x2="${WIDTH}" y2="${y(ground)}" stroke="#8A6D3B" stroke-width="1.5"></line>`);
parts.push(`<text x="8" y="${y(ground) - 6}" font-size="12" fill="#18212B">지반고 ${ground.toFixed(3)}</text>`);
// Groundwater (only when the input is on and has a value).
if (water !== undefined) {
parts.push(`<line x1="0" y1="${y(water)}" x2="${WIDTH}" y2="${y(water)}" stroke="#2F6DB5" stroke-dasharray="6 4"></line>`);
parts.push(`<text x="8" y="${y(water) - 4}" font-size="12" fill="#123E70">지하수위 ${water.toFixed(3)}</text>`);
}
// Footing, column stub, and piles.
const cx = WIDTH / 2;
const colW = 30;
parts.push(`<rect x="${cx - colW / 2}" y="${y(yMax)}" width="${colW}" height="${y(footTop) - y(yMax)}" fill="#D8C7AA" stroke="#9C8763"></rect>`);
const footW = 140;
parts.push(`<rect x="${cx - footW / 2}" y="${y(footTop)}" width="${footW}" height="${y(footBottom) - y(footTop)}" fill="#B7C7D3" stroke="#6E8496"></rect>`);
const sValues = [...new Set(model.piles.map((p) => p.s))].sort((a, b) => a - b);
const spread = sValues.length > 1 ? (footW - 20) / (sValues.length - 1) : 0;
sValues.forEach((s) => {
const tips = model.piles.filter((p) => p.s === s);
const x = sValues.length > 1 ? cx - footW / 2 + 10 + sValues.indexOf(s) * spread : cx;
const tip = Math.min(...tips.map((p) => p.bottom));
parts.push(`<rect x="${x - 3}" y="${y(footBottom)}" width="6" height="${y(tip) - y(footBottom)}" fill="#7D8A97"></rect>`);
});
return `<svg viewBox="0 0 ${WIDTH} ${H}" width="100%" style="max-width: ${WIDTH}px; height: auto" role="img" aria-label="지반·지층 단면: 지반면, 지층, 지하수위, 기초와 말뚝">${parts.join('')}</svg>`;
}
function escapeText(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}