feat(core,view): per-row pile counts 열별 본수 (plan C4)
- A 열 is one row across the footing width; each has its own count along the length. Default floor((L − 1400) / 2250) + 1 per 열, as in spec §4.6. - Counts are validated: one per 열, whole numbers 2–30. Spacing below the 2250 mm minimum is shown as a warning, not refused (the plan's 6본 → 5@1800 example). - PierInput gains an optional pileRows; absent means the defaults, so existing files and the P1 results are unchanged. - Change tracking compares arrays by value; 열별 본수 can be reverted and copied (copy item off by default). - The footing card has −/+ per 열 with spacing, total, and the display string. - Tests: defaults 4열 × 5본 = 20 and the 1단계 text; 6본 → 5@1800; limits; model-level 21 piles. - Not verified in a browser. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b162c2441
commit
17b5b24682
9 files changed
+256
-39
No files matched your search
+13
-3
@@ -10,11 +10,21 @@ export const EDITABLE_FIELDS = [
|
||||
{ key: 'pileLengthMm', label: '말뚝 길이', unit: 'mm' },
|
||||
] as const;
|
||||
|
||||
export type EditableKey = (typeof EDITABLE_FIELDS)[number]['key'];
|
||||
/** 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;
|
||||
|
||||
/** Keys whose values differ between two inputs. */
|
||||
export type EditableKey = (typeof TRACKED_KEYS)[number];
|
||||
|
||||
export const FIELD_LABELS: Record<EditableKey, string> = {
|
||||
copingWidthMm: '코핑 폭 B',
|
||||
copingLengthMm: '코핑 길이 L',
|
||||
pileLengthMm: '말뚝 길이',
|
||||
pileRows: '열별 본수',
|
||||
};
|
||||
|
||||
/** Keys whose values differ between two inputs. Arrays are compared by value. */
|
||||
export function changedKeys(a: PierInput, b: PierInput): EditableKey[] {
|
||||
return EDITABLE_FIELDS.map((f) => f.key).filter((key) => a[key] !== b[key]);
|
||||
return TRACKED_KEYS.filter((key) => JSON.stringify(a[key]) !== JSON.stringify(b[key]));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+7
-6
@@ -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';
|
||||
export type CopyItem = 'coping-width' | 'coping-length' | 'pile-length' | 'pile-rows';
|
||||
|
||||
interface ItemRule {
|
||||
label: string;
|
||||
@@ -20,14 +20,15 @@ export const COPY_ITEMS: Record<CopyItem, ItemRule> = {
|
||||
'coping-width': { label: '코핑 폭', keys: ['copingWidthMm'], defaultOn: true, pileItem: false },
|
||||
'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 },
|
||||
};
|
||||
|
||||
export const DEFAULT_COPY_ITEMS: CopyItem[] = (Object.keys(COPY_ITEMS) as CopyItem[]).filter((k) => COPY_ITEMS[k].defaultOn);
|
||||
|
||||
export interface CopyChange {
|
||||
key: EditableKey;
|
||||
from: number;
|
||||
to: number;
|
||||
from: unknown;
|
||||
to: unknown;
|
||||
}
|
||||
|
||||
export interface CopyPlan {
|
||||
@@ -47,7 +48,7 @@ export function planCopy(source: PierInput, target: PierInput, items: CopyItem[]
|
||||
continue;
|
||||
}
|
||||
for (const key of rule.keys) {
|
||||
if (source[key] === target[key]) continue;
|
||||
if (JSON.stringify(source[key]) === JSON.stringify(target[key])) continue;
|
||||
changes.push({ key, from: target[key], to: source[key] });
|
||||
}
|
||||
}
|
||||
@@ -56,7 +57,7 @@ export function planCopy(source: PierInput, target: PierInput, items: CopyItem[]
|
||||
|
||||
/** Applies a plan to a target input. Only the planned keys change. */
|
||||
export function applyCopy(target: PierInput, plan: CopyPlan): PierInput {
|
||||
const next = { ...target };
|
||||
const next: Record<string, unknown> = { ...target };
|
||||
for (const c of plan.changes) next[c.key] = c.to;
|
||||
return next;
|
||||
return next as unknown as PierInput;
|
||||
}
|
||||
+20
-25
@@ -1,4 +1,5 @@
|
||||
import { buildAxis, copingSpan, fromAxis, kinkArc, nearKink, toAxis } from './axis';
|
||||
import { layoutText, resolveRowCounts, rowLayout, type PileGeometry } from './piles';
|
||||
import type { PierInput, Row } from './types';
|
||||
|
||||
/** Dimensions in m. Sources: intent.PR sheet (P1 rules) and spec.md §3 defaults. */
|
||||
@@ -363,7 +364,9 @@ export function buildPier(input: PierInput): PierModel {
|
||||
thickness: DIM.blindingT,
|
||||
});
|
||||
if (input.foundationType === '말뚝기초') {
|
||||
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, f.length).map((p) => ({ ...p, k: f.k })));
|
||||
const g = pileGeometry(f.length);
|
||||
const counts = resolveRowCounts(input.pileRows, g);
|
||||
piles.push(...pilesFor(f.s, input.footingBottomEl, pileLength, g, counts).map((p) => ({ ...p, k: f.k })));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,7 +422,7 @@ export function buildPier(input: PierInput): PierModel {
|
||||
columnHeight: levelBottom - columns[0].bottom,
|
||||
soilCover: input.groundEl - (input.foundationType === '현장타설말뚝' ? input.footingBottomEl : footingTop),
|
||||
pileCount: piles.length,
|
||||
pileLayout: input.foundationType === '말뚝기초' ? pileLayoutText(footings[0].length) : null,
|
||||
pileLayout: input.foundationType === '말뚝기초' ? layoutText(resolveRowCounts(input.pileRows, pileGeometry(footings[0].length)), pileGeometry(footings[0].length)) : null,
|
||||
pileTipEl: piles.length ? pileTip : null,
|
||||
blockHeightFront,
|
||||
blockHeightBack,
|
||||
@@ -460,38 +463,30 @@ export function footingGroups(colS: number[]): { s: number; length: number }[] {
|
||||
* Piles under one footing (spec §4.6): rows across the footing width, piles along the length,
|
||||
* both with an end distance of 0.7 m and an even spacing that never exceeds 2.25 m.
|
||||
*/
|
||||
export function pileGrid(footingLength: number = DIM.footingL): { countS: number; countN: number; spacingS: number; spacingN: number } {
|
||||
const span = (size: number) => Math.floor((size - 2 * DIM.pileEnd) / DIM.pileSpacing) + 1;
|
||||
const countS = span(footingLength);
|
||||
const countN = span(DIM.footingW);
|
||||
return {
|
||||
countS,
|
||||
countN,
|
||||
spacingS: (footingLength - 2 * DIM.pileEnd) / (countS - 1),
|
||||
spacingN: (DIM.footingW - 2 * DIM.pileEnd) / (countN - 1),
|
||||
};
|
||||
/** Pile geometry of one footing, from the 1단계 constants (spec §4.6). */
|
||||
export function pileGeometry(footingLength: number): PileGeometry {
|
||||
return { footingLength, footingWidth: DIM.footingW, end: DIM.pileEnd, minSpacing: DIM.pileSpacing };
|
||||
}
|
||||
|
||||
/** Display string such as "4@2250 × 3@2250 = 20본" (gaps along the length × gaps across the width). */
|
||||
export function pileLayoutText(footingLength: number = DIM.footingL): string {
|
||||
const g = pileGrid(footingLength);
|
||||
return `${g.countS - 1}@${Math.round(g.spacingS * 1000)} × ${g.countN - 1}@${Math.round(g.spacingN * 1000)} = ${g.countS * g.countN}본`;
|
||||
}
|
||||
|
||||
export function pilesFor(s: number, topEl: number, length: number, footingLength: number = DIM.footingL): PileModel[] {
|
||||
const { countS, countN, spacingS, spacingN } = pileGrid(footingLength);
|
||||
/**
|
||||
* Piles under one footing (spec §4.6): one 열 per row across the width, each with its own count along the length.
|
||||
* counts holds the 본수 of each 열 (see core/piles.ts).
|
||||
*/
|
||||
export function pilesFor(s: number, topEl: number, length: number, g: PileGeometry, counts: number[]): PileModel[] {
|
||||
const rows = rowLayout(counts, g);
|
||||
const spacingN = (g.footingWidth - 2 * g.end) / (counts.length - 1);
|
||||
const out: PileModel[] = [];
|
||||
for (let i = 0; i < countS; i++) {
|
||||
for (let j = 0; j < countN; j++) {
|
||||
rows.forEach((row, j) => {
|
||||
for (let i = 0; i < row.count; i++) {
|
||||
out.push({
|
||||
s: s + (i - (countS - 1) / 2) * spacingS,
|
||||
s: s + (i - (row.count - 1) / 2) * row.spacing,
|
||||
k: 0,
|
||||
n: (j - (countN - 1) / 2) * spacingN,
|
||||
n: (j - (counts.length - 1) / 2) * spacingN,
|
||||
top: topEl,
|
||||
bottom: topEl - length,
|
||||
diameter: DIM.pileDiameter,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 말뚝 열별 본수 (spec §4.6 · §5.4 · plan C4). A 열 is one row across the footing width; 본 are along the length.
|
||||
* Default: each 열 gets floor((길이 − 2·단부)/간격) + 1 본, so the centre spacing is at least the minimum.
|
||||
* The user may change any 열's count. Counts below the minimum spacing are allowed and reported as a warning.
|
||||
*/
|
||||
export interface PileGeometry {
|
||||
footingLength: number;
|
||||
footingWidth: number;
|
||||
/** 단부 거리 (700 mm). */
|
||||
end: number;
|
||||
/** 최소 중심간격 (2250 mm). */
|
||||
minSpacing: number;
|
||||
}
|
||||
|
||||
/** A count outside this range is refused: fewer than two piles has no spacing, and the cap guards against typing errors. */
|
||||
export const PILE_COUNT_LIMITS = { min: 2, max: 30 } as const;
|
||||
|
||||
/** Largest count along one length with centres at or above the minimum spacing. */
|
||||
export function maxCount(length: number, g: PileGeometry): number {
|
||||
return Math.floor((length - 2 * g.end) / g.minSpacing) + 1;
|
||||
}
|
||||
|
||||
/** Number of 열 across the width. */
|
||||
export function rowsAcross(g: PileGeometry): number {
|
||||
return maxCount(g.footingWidth, { ...g, minSpacing: g.minSpacing });
|
||||
}
|
||||
|
||||
export function defaultRowCounts(g: PileGeometry): number[] {
|
||||
return Array.from({ length: rowsAcross(g) }, () => maxCount(g.footingLength, g));
|
||||
}
|
||||
|
||||
/** Counts to use for a footing: the requested 열별 본수 if any, otherwise the defaults. Refuses bad input. */
|
||||
export function resolveRowCounts(requested: number[] | undefined, g: PileGeometry): number[] {
|
||||
if (requested === undefined) return defaultRowCounts(g);
|
||||
const rows = rowsAcross(g);
|
||||
if (requested.length !== rows) {
|
||||
throw new Error(`열별 본수는 ${rows}개여야 합니다 (입력 ${requested.length}개).`);
|
||||
}
|
||||
for (const c of requested) {
|
||||
if (!Number.isInteger(c) || c < PILE_COUNT_LIMITS.min || c > PILE_COUNT_LIMITS.max) {
|
||||
throw new Error(`본수는 ${PILE_COUNT_LIMITS.min}–${PILE_COUNT_LIMITS.max} 사이의 정수여야 합니다 (입력 ${c}).`);
|
||||
}
|
||||
}
|
||||
return [...requested];
|
||||
}
|
||||
|
||||
export interface RowLayout {
|
||||
count: number;
|
||||
/** Centre spacing along the length, in m. */
|
||||
spacing: number;
|
||||
/** True when the spacing is below the minimum (warning, not refused). */
|
||||
belowMinimum: boolean;
|
||||
}
|
||||
|
||||
export function rowLayout(counts: number[], g: PileGeometry): RowLayout[] {
|
||||
return counts.map((count) => {
|
||||
const spacing = (g.footingLength - 2 * g.end) / (count - 1);
|
||||
return { count, spacing, belowMinimum: spacing < g.minSpacing - 1e-9 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Total piles (sum of the counts along each 열). */
|
||||
export function totalPiles(counts: number[]): number {
|
||||
return counts.reduce((sum, c) => sum + c, 0);
|
||||
}
|
||||
|
||||
/** Changes one 열's count by delta, staying inside the limits. Returns a new array. */
|
||||
export function adjustRow(counts: number[], row: number, delta: number): number[] {
|
||||
const next = [...counts];
|
||||
next[row] = Math.min(PILE_COUNT_LIMITS.max, Math.max(PILE_COUNT_LIMITS.min, next[row] + delta));
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display string. Uniform counts keep the 1단계 form, e.g. "4@2250 × 3@2250 = 20본"
|
||||
* (gaps along the length × gaps across the width). Non-uniform counts list each 열, e.g. "열1 5@2250 · 열2 6@1800 = 23본".
|
||||
*/
|
||||
export function layoutText(counts: number[], g: PileGeometry): string {
|
||||
const layout = rowLayout(counts, g);
|
||||
const total = totalPiles(counts);
|
||||
const mm = (v: number) => Math.round(v * 1000);
|
||||
const across = (g.footingWidth - 2 * g.end) / (counts.length - 1);
|
||||
const uniform = counts.every((c) => c === counts[0]);
|
||||
if (uniform) {
|
||||
return `${layout[0].count - 1}@${mm(layout[0].spacing)} × ${counts.length - 1}@${mm(across)} = ${total}본`;
|
||||
}
|
||||
const parts = layout.map((r, j) => `열${j + 1} ${r.count - 1}@${mm(r.spacing)}`);
|
||||
return `${parts.join(' · ')} = ${total}본`;
|
||||
}
|
||||
@@ -32,6 +32,8 @@ export interface PierInput {
|
||||
copingWidthMm: number;
|
||||
copingLengthMm: number;
|
||||
columnCount: number;
|
||||
/** 열별 본수 (spec §4.6): one count per 열 across the footing width. Absent = the defaults. */
|
||||
pileRows?: number[];
|
||||
ips: IpPoint[];
|
||||
bearings: Bearing[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user