diff --git a/src/core/changes.ts b/src/core/changes.ts index 8f9cc34..711c2d7 100644 --- a/src/core/changes.ts +++ b/src/core/changes.ts @@ -23,7 +23,7 @@ export const EDITABLE_FIELDS = [ ] as const; /** Inputs tracked for overrides and pending changes: the numeric cards plus 열별 본수 (an array, compared by value). */ -export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows'] as const; +export const TRACKED_KEYS = [...EDITABLE_FIELDS.map((f) => f.key), 'pileRows', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'] as const; export type EditableKey = (typeof TRACKED_KEYS)[number]; @@ -44,6 +44,10 @@ export const FIELD_LABELS: Record = { pileLengthMm: '말뚝 길이', groundEl: '지반고 EL', pileRows: '열별 본수', + groundPosition: '지반 위치', + groundWaterOn: '지하수위 고려', + groundWaterEl: '지하수위 EL', + strataRows: '지층 행', }; /** Keys whose values differ between two inputs. Arrays are compared by value. */ diff --git a/src/core/copy.ts b/src/core/copy.ts index 5eff6bf..1b1161e 100644 --- a/src/core/copy.ts +++ b/src/core/copy.ts @@ -5,7 +5,7 @@ import type { PierInput } from './types'; * 속성 복사 (spec §9 · plan C7). Copies chosen attributes from one pier to others. * Values the spec never copies (coordinates, IP, elevations, names) are not in any item, so they cannot be copied. */ -export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows'; +export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows' | 'ground-position'; interface ItemRule { label: string; @@ -21,6 +21,7 @@ export const COPY_ITEMS: Record = { 'coping-length': { label: '코핑 길이 L', keys: ['copingLengthMm'], defaultOn: false, pileItem: false }, 'pile-length': { label: '말뚝 길이', keys: ['pileLengthMm'], defaultOn: false, pileItem: true }, 'pile-rows': { label: '열별 본수', keys: ['pileRows'], defaultOn: false, pileItem: true }, + 'ground-position': { label: '지반 위치·지하수위', keys: ['groundPosition', 'groundWaterOn', 'groundWaterEl'], defaultOn: false, pileItem: false }, }; export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn); diff --git a/src/core/ground.ts b/src/core/ground.ts new file mode 100644 index 0000000..c7aac17 --- /dev/null +++ b/src/core/ground.ts @@ -0,0 +1,39 @@ +import type { Issue, PierInput } from './types'; + +/** 위치 choices of the 지반·지층 card (시안 2026-10-26). */ +export const GROUND_POSITIONS = ['육상', '하상', '해상'] as const; + +/** 지층 names, in the order of the RAG strata list (지반모델링계획). */ +export const STRATA_NAMES = ['매립토', '퇴적토', '풍화토', '풍화암', '연암', '경암'] as const; + +/** The 지지층 for the pile tip check (지반 모델 G-7). */ +export const SUPPORT_STRATUM = '연암'; + +/** + * 지반·지층 입력 검사 (승인 2026-10-26). + * G01: 층 하단 EL은 위 경계보다 낮아야 함. G02: 지하수위는 지반고와 가장 아래 층 사이에 있어야 함. + * G03: 층 이름은 지층 목록에 있어야 함. Ground values are inputs only; they do not change the 3D model. + */ +export function validateGround(input: PierInput, issues: Issue[]): void { + const rows = input.strataRows ?? []; + let upper = input.groundEl; + rows.forEach((row, i) => { + const label = `지층 ${i + 1}행 (${row.name})`; + if (!(STRATA_NAMES as readonly string[]).includes(row.name)) { + issues.push({ code: 'G03', level: 'W', pier: input.name, message: `${label}: 층 이름이 지층 목록에 없습니다.` }); + } + if (!(row.bottomEl < upper)) { + issues.push({ code: 'G01', level: 'W', pier: input.name, message: `${label}: 하단 EL ${row.bottomEl.toFixed(3)}이 위 경계 ${upper.toFixed(3)}보다 낮아야 합니다.` }); + } + upper = row.bottomEl; + }); + if (input.groundWaterOn) { + const w = input.groundWaterEl; + const lowest = rows.length ? rows[rows.length - 1].bottomEl : undefined; + if (w === undefined || !Number.isFinite(w)) { + issues.push({ code: 'G02', level: 'W', pier: input.name, message: '지하수위를 고려하지만 지하수위 EL이 없습니다.' }); + } else if (w > input.groundEl || (lowest !== undefined && w < lowest)) { + issues.push({ code: 'G02', level: 'W', pier: input.name, message: `지하수위 EL ${w.toFixed(3)}이 지반고와 가장 아래 층 사이에 있지 않습니다.` }); + } + } +} diff --git a/src/core/types.ts b/src/core/types.ts index 7e110cb..7ad0bc0 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -56,6 +56,20 @@ export interface PierInput { pileKind?: '강관' | 'PHC'; /** 공통 규칙 for the solplate: centre thickness T, 솔플 여유 margin (mm), bearing height h3 (mm). */ solplate?: { T: number; margin: number; h3: number }; + /** 지반·지층 (승인 2026-10-26): 위치, 지하수위 고려와 EL, 지층별 행 (위에서 아래, 행의 EL = 그 층의 하단 EL). + * Absent = 육상, 지하수위 없음, 지층 행 없음. */ + groundPosition?: GroundPosition; + groundWaterOn?: boolean; + groundWaterEl?: number; + strataRows?: StrataRow[]; +} + +export type GroundPosition = '육상' | '하상' | '해상'; + +export interface StrataRow { + name: string; + /** 이 층의 하단 EL (m). */ + bottomEl: number; } export type IssueLevel = 'E' | 'W' | 'I'; diff --git a/src/core/validate.ts b/src/core/validate.ts index ff13647..3fdd7c5 100644 --- a/src/core/validate.ts +++ b/src/core/validate.ts @@ -1,5 +1,6 @@ import { DIM, type PierModel } from './geometry'; import type { Issue, PierInput, PierType } from './types'; +import { validateGround } from './ground'; const EXPECTED_COLUMNS: Record = { 'T형': 1, '2주형': 2, '3주형': 3 }; @@ -89,4 +90,5 @@ export function validatePier(input: PierInput, model: PierModel, issues: Issue[] message: `받침블럭 높이 ${(minBlock * 1000).toFixed(0)} mm가 피복 + 철근 지름(${((cover + barDia) * 1000).toFixed(0)} mm)보다 작습니다. 블럭 철근은 코핑 안에 묻힙니다.`, }); } + validateGround(input, issues); } diff --git a/src/main.ts b/src/main.ts index 90d8678..9c8830c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,8 @@ import { DEFAULT_MATERIAL, FCK_MEMBERS, fyOf, GRADE_CHOICES, isAssumedMaterial, import { buildQuantity, quantityRows, type Quantity, type QuantityLine } from './core/rebar/quantity'; import { parseDrawingRows } from './core/drawing-import'; import { groundElFor, parseGroundModel, type GroundModelFile } from './core/ground-model'; +import { GROUND_POSITIONS, STRATA_NAMES } from './core/ground'; +import { groundSectionSvg } from './view/ground-svg'; import { parseBearingElevationTable, parseSolplateTable, readDxfTexts } from './core/dxf-table'; import { columnDiameterIssues } from './core/column-check'; import { findPileSpec, PHC_PILES, STEEL_PILES } from './core/pile-spec'; @@ -37,7 +39,7 @@ import { allVisibleChecked, setVisibleChecked, type TypeFilter } from './core/se import { choose, defaultSummary, isSelectable, SHAPE_GROUPS, UNDEFINED_REASON, type MemberId } from './core/shape-options'; import { parseProject, SCHEMA_VERSION, serializeProject, type ProjectFile, type ProjectInfo } from './core/project'; import { clearBackup, readBackup, writeBackup, type Backup } from './view/backup'; -import type { Issue, PierInput, PierType } from './core/types'; +import type { Issue, PierInput, PierType, StrataRow } from './core/types'; import { buildPierScene, setRebarView, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene'; /** One pier as shown in the list. `checked` is the multi-select state (시안 ①). */ @@ -78,7 +80,7 @@ const MEMBERS: { id: MemberId; label: string; keys: EditableKey[]; note: string { id: 'coping', label: '코핑', keys: ['copingWidthMm', 'copingLengthMm', 'copingH1Mm', 'copingH2Mm', 'levelWidthMm'], note: '폭, 길이, 끝단 높이 h1, 테이퍼 높이 h2, Level 폭을 입력합니다. 계단 위치·단차선 입력은 다음 단계에서 추가합니다.' }, { id: 'column', label: '기둥', keys: ['columnDiameterMm'], note: '기둥 직경 D를 입력합니다 (1200–3000 mm, 50 mm 단위). 높이와 위치는 계산값입니다.' }, { id: 'footing', label: '확대기초 · 말뚝', keys: ['footingLengthMm', 'footingWidthMm', 'footingHeightMm', 'blindingMarginMm', 'blindingThicknessMm', 'pileLengthMm'], note: '확대기초 치수, 비림, 말뚝 길이와 규격을 입력합니다. 규칙 미정 항목(모따기, 경사, 복합 말뚝)은 다음 단계에서 추가합니다.' }, - { id: 'ground', label: '지반·지층', keys: ['groundEl'], note: '지반고는 지반 모델 값을 적용하거나 직접 입력합니다. 지층 입력은 다음 단계에서 추가합니다.' }, + { id: 'ground', label: '지반·지층', keys: ['groundEl', 'groundPosition', 'groundWaterOn', 'groundWaterEl', 'strataRows'], note: '지반고는 계산(흙덮개)에 쓰고, 지층은 행으로, 지하수위는 직접 입력합니다. 위치는 육상·하상·해상 중 고릅니다.' }, ]; interface Pending { @@ -1498,9 +1500,157 @@ function renderBearingBasis(wrap: HTMLDivElement, pier: Pier): void { } } -/** ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. */ +/** Text of one tracked value in the 변경 list. */ +function valueText(key: EditableKey, v: unknown): string { + if (v === undefined || v === null) return '없음'; + if (key === 'strataRows') { + const rows = v as StrataRow[]; + return rows.length ? rows.map((r) => `${r.name} ${r.bottomEl}`).join(' / ') : '없음'; + } + if (typeof v === 'boolean') return v ? '예' : '아니오'; + return String(v); +} + +/** Sets one 지반·지층 input in the draft (applied to the model by 다시 생성). */ +function editGround(name: string, key: 'groundPosition' | 'groundWaterOn' | 'groundWaterEl', value: unknown): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + if (key === 'groundWaterEl' && value !== undefined && !Number.isFinite(value)) { + setError('지하수위 EL은 숫자로 입력하세요. 값을 되돌렸습니다.'); + renderAll(); + return; + } + p.draft = { ...p.draft, [key]: value }; + renderAll(); +} + +function setStrataRows(name: string, rows: StrataRow[]): void { + const p = state.piers.find((q) => q.input.name === name); + if (!p) return; + p.draft = { ...p.draft, strataRows: rows }; + renderAll(); +} + +/** 일괄 적용: the source's 위치 and 지하수위 go to the checked piers, which are then regenerated. */ +function applyGroundPositionTo(source: Pier, targets: Pier[]): void { + for (const target of targets) { + target.draft = applyCopy(target.draft, planCopy(source.draft, target.draft, ['ground-position'])); + } + regenerateAllPiers(); + renderAll(); + setStatus(`${targets.length}기에 위치와 지하수위를 적용했습니다.`); +} + +/** + * ② 지반·지층 card (시안 2026-10-26; 승인 2026-10-26: 지반고는 계산에 쓰고, 지층은 행으로 입력하며, 지하수위는 직접 입력). + * The ground model import and the 지반고 apply stay at the bottom, as before. + */ function renderGroundModel(box: HTMLDivElement, pier: Pier): void { + const name = pier.input.name; + const d = pier.draft; const wrap = el('div', undefined, 'ground-model'); + + wrap.appendChild(el('p', '위치', 'lbl')); + const positions = el('div', undefined, 'opts'); + for (const v of GROUND_POSITIONS) { + const b = el('button', v, 'btn small'); + b.type = 'button'; + b.setAttribute('aria-pressed', String((d.groundPosition ?? '육상') === v)); + b.addEventListener('click', () => editGround(name, 'groundPosition', v)); + positions.appendChild(b); + } + wrap.appendChild(positions); + + wrap.appendChild(el('p', '지하수위', 'lbl')); + const water = el('div', undefined, 'ground-water'); + const check = el('input'); + check.type = 'checkbox'; + check.checked = d.groundWaterOn === true; + check.id = 'ground-water-on'; + check.addEventListener('change', () => editGround(name, 'groundWaterOn', check.checked)); + const checkLabel = el('label', ' 지하수위 고려'); + checkLabel.prepend(check); + const level = el('input'); + level.type = 'number'; + level.step = 'any'; + level.id = 'ground-water-el'; + level.placeholder = '지하수위 EL (m)'; + level.value = d.groundWaterEl === undefined ? '' : String(d.groundWaterEl); + level.disabled = !check.checked; + level.addEventListener('change', () => editGround(name, 'groundWaterEl', level.value === '' ? undefined : Number(level.value))); + water.append(checkLabel, level, el('span', 'm', 'muted small')); + wrap.appendChild(water); + + // 지층 행 (승인 2026-10-26): one row per layer, from the top down; the EL is the layer's bottom EL. + const rows = d.strataRows ?? []; + wrap.appendChild(el('p', `지층 행 (위에서 아래 · 하단 EL m · 지반고 ${d.groundEl.toFixed(3)} 아래)`, 'lbl')); + const table = el('table', undefined, 'grid-table rebar-table'); + const head = el('thead'); + const hr = el('tr'); + for (const h of ['지층', '하단 EL (m)', '']) hr.appendChild(el('th', h)); + head.appendChild(hr); + table.appendChild(head); + const body = el('tbody'); + rows.forEach((row, i) => { + const tr = el('tr'); + const select = el('select'); + for (const s of STRATA_NAMES) { + const o = el('option', s); + o.value = s; + select.appendChild(o); + } + if (!(STRATA_NAMES as readonly string[]).includes(row.name)) { + const o = el('option', row.name); + o.value = row.name; + select.appendChild(o); + } + select.value = row.name; + select.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, name: select.value } : r)))); + const bottomInput = el('input'); + bottomInput.type = 'number'; + bottomInput.step = '0.001'; + bottomInput.value = String(row.bottomEl); + bottomInput.addEventListener('change', () => setStrataRows(name, rows.map((r, j) => (j === i ? { ...r, bottomEl: Number(bottomInput.value) } : r)))); + const del = el('button', '삭제', 'btn small'); + del.type = 'button'; + del.addEventListener('click', () => setStrataRows(name, rows.filter((_, j) => j !== i))); + tr.append(...[select, bottomInput, del].map((x) => { + const td = el('td'); + td.appendChild(x); + return td; + })); + body.appendChild(tr); + }); + table.appendChild(body); + wrap.appendChild(el('div', undefined, 'table-scroll')).appendChild(table); + const add = el('button', '지층 행 추가', 'btn small'); + add.type = 'button'; + add.addEventListener('click', () => { + const last = rows[rows.length - 1]; + const next = last ? (STRATA_NAMES as readonly string[])[Math.min((STRATA_NAMES as readonly string[]).indexOf(last.name) + 1, STRATA_NAMES.length - 1)] : STRATA_NAMES[0]; + setStrataRows(name, [...rows, { name: next, bottomEl: Number(((last?.bottomEl ?? d.groundEl) - 2).toFixed(3)) }]); + }); + wrap.appendChild(add); + + // 단면 (삽도): the footings and piles are the generated model; the layers and water level are the inputs. + if (pier.model) { + const fig = el('figure', undefined, 'ground-fig'); + fig.innerHTML = groundSectionSvg(d, pier.model); + fig.appendChild(el('figcaption', '단면 · 기초와 말뚝은 생성본, 지층과 지하수위는 입력값', 'muted small')); + wrap.appendChild(fig); + } + + // 일괄 적용: copies the 위치 and 지하수위 to the other checked piers (속성 복사 item 'ground-position'). + const others = state.piers.filter((q) => q.checked && q.input.name !== name); + const batch = el('button', `${name} 외 일괄 대상 ${others.length}기에 적용`, 'btn small'); + batch.type = 'button'; + batch.title = '위치와 지하수위 설정을 체크한 다른 교각에 적용합니다.'; + batch.disabled = others.length === 0; + batch.addEventListener('click', () => applyGroundPositionTo(pier, others)); + wrap.appendChild(batch); + + // ② 지반 모델 (승인 2026-10-23): the pier-centre ground EL from ground_model.json. Applied only when the user presses the button. + wrap.appendChild(el('p', '지반 모델 (지반 모델 결과 · ground_model.json)', 'lbl')); const importInput = el('input'); importInput.type = 'file'; @@ -1734,7 +1884,7 @@ function renderPlan(): void { changes.replaceChildren(); const pending = pier ? pendingKeys(pier) : []; for (const key of pier ? overrideKeys(pier) : []) { - const li = el('li', `${FIELD_LABELS[key]}: 기본 ${pier!.defaults[key] ?? '없음'} → 현재 ${pier!.draft[key] ?? '없음'}${pending.includes(key) ? ' (미반영)' : ''}`); + const li = el('li', `${FIELD_LABELS[key]}: 기본 ${valueText(key, pier!.defaults[key])} → 현재 ${valueText(key, pier!.draft[key])}${pending.includes(key) ? ' (미반영)' : ''}`); const revert = el('button', '되돌리기', 'btn small'); revert.type = 'button'; revert.addEventListener('click', () => revertField(pier!.input.name, key)); diff --git a/src/view/ground-svg.ts b/src/view/ground-svg.ts new file mode 100644 index 0000000..b1e756f --- /dev/null +++ b/src/view/ground-svg.ts @@ -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(``); + parts.push(`${escapeText(row.name)}`); + if (row.name === SUPPORT_STRATUM) { + parts.push(``); + parts.push(`연암층 상면 EL ${upper.toFixed(3)}`); + } + upper = row.bottomEl; + }); + if (rows.length === 0 || upper > yMin) { + parts.push(``); + } + + // Ground line and label. + parts.push(``); + parts.push(`지반고 ${ground.toFixed(3)}`); + + // Groundwater (only when the input is on and has a value). + if (water !== undefined) { + parts.push(``); + parts.push(`지하수위 ${water.toFixed(3)}`); + } + + // Footing, column stub, and piles. + const cx = WIDTH / 2; + const colW = 30; + parts.push(``); + const footW = 140; + parts.push(``); + 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(``); + }); + + return `${parts.join('')}`; +} + +function escapeText(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} diff --git a/tests/ground-inputs.test.ts b/tests/ground-inputs.test.ts new file mode 100644 index 0000000..6508d6b --- /dev/null +++ b/tests/ground-inputs.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { buildPier } from '../src/core/geometry'; +import { validateGround } from '../src/core/ground'; +import { changedKeys } from '../src/core/changes'; +import { COPY_ITEMS, applyCopy, planCopy } from '../src/core/copy'; +import type { Issue, PierInput } from '../src/core/types'; +import { groundSectionSvg } from '../src/view/ground-svg'; +import { loadP1 } from './helpers'; + +const base = loadP1(); + +function withGround(extra: Partial): PierInput { + return { ...base, groundEl: 28.931, ...extra }; +} + +function ground(input: PierInput): string[] { + const issues: Issue[] = []; + validateGround(input, issues); + return issues.map((i) => i.code); +} + +const GOOD_ROWS = [ + { name: '매립토', bottomEl: 27.5 }, + { name: '풍화토', bottomEl: 22 }, + { name: '연암', bottomEl: 10 }, +]; + +describe('지반·지층 입력 검사 (G01–G03)', () => { + it('no issues for a well-ordered set of rows and a water level inside the range', () => { + expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 25 }))).toEqual([]); + }); + + it('G01: a layer whose bottom EL is not below the boundary above it', () => { + const rows = [{ name: '매립토', bottomEl: 27.5 }, { name: '풍화토', bottomEl: 28 }]; + expect(ground(withGround({ strataRows: rows }))).toEqual(['G01']); + }); + + it('G01: the first layer must be below the ground EL', () => { + expect(ground(withGround({ strataRows: [{ name: '매립토', bottomEl: 29 }] }))).toEqual(['G01']); + }); + + it('G03: a layer name outside the strata list', () => { + expect(ground(withGround({ strataRows: [{ name: '점토', bottomEl: 27 }] }))).toEqual(['G03']); + }); + + it('G02: the water level above the ground, below the lowest layer, or missing while on', () => { + expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 29 }))).toEqual(['G02']); + expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 5 }))).toEqual(['G02']); + expect(ground(withGround({ groundWaterOn: true }))).toEqual(['G02']); + }); + + it('the water level is not checked when it is off', () => { + expect(ground(withGround({ strataRows: GOOD_ROWS, groundWaterOn: false, groundWaterEl: 99 }))).toEqual([]); + }); +}); + +describe('일괄 적용 (속성 복사 · 위치·지하수위)', () => { + it('copies only the position and the water level, not the layer rows', () => { + const source = withGround({ groundPosition: '하상', groundWaterOn: true, groundWaterEl: 25, strataRows: GOOD_ROWS }); + const target = withGround({ groundPosition: '육상', strataRows: [] }); + const plan = planCopy(source, target, ['ground-position']); + expect(plan.changes.map((c) => c.key).sort()).toEqual(['groundPosition', 'groundWaterEl', 'groundWaterOn']); + const next = applyCopy(target, plan); + expect(next.groundPosition).toBe('하상'); + expect(next.strataRows).toEqual([]); + }); + + it('the item is off by default, so the existing 속성 복사 defaults do not change', () => { + expect(COPY_ITEMS['ground-position'].defaultOn).toBe(false); + }); +}); + +describe('변경 추적 (지반·지층 키)', () => { + it('a changed layer row or water level is reported as a change', () => { + const a = withGround({ strataRows: GOOD_ROWS }); + const b = withGround({ strataRows: [...GOOD_ROWS, { name: '경암', bottomEl: 5 }] }); + expect(changedKeys(a, b)).toEqual(['strataRows']); + expect(changedKeys(a, { ...a, groundWaterEl: 20 })).toEqual(['groundWaterEl']); + }); +}); + +describe('단면 그림 (삽도)', () => { + const model = buildPier(base); + + it('shows the ground EL and one band per layer row, with the 연암 top line', () => { + const svg = groundSectionSvg(withGround({ strataRows: GOOD_ROWS }), model); + expect(svg).toContain('지반고 28.931'); + expect(svg).toContain('연암층 상면 EL 22.000'); + expect((svg.match(/fill="#(EADFC8|DCCBA8|C9B38D)"/g) ?? []).length).toBeGreaterThanOrEqual(3); + }); + + it('draws the water level only when it is on and has a value', () => { + expect(groundSectionSvg(withGround({ strataRows: GOOD_ROWS, groundWaterOn: true, groundWaterEl: 25 }), model)).toContain('지하수위 25.000'); + // The dashed water line and its label are absent (the aria-label always names 지하수위). + const off = groundSectionSvg(withGround({ strataRows: GOOD_ROWS, groundWaterOn: false, groundWaterEl: 25 }), model); + expect(off).not.toContain('stroke-dasharray="6 4"'); + expect(off).not.toContain('지하수위 25'); + }); + + it('with no layer rows, the ground band still fills the drawing down to its bottom', () => { + const svg = groundSectionSvg(withGround({}), model); + expect(svg).toContain('