diff --git a/index.html b/index.html
new file mode 100644
index 0000000..5f52b57
--- /dev/null
+++ b/index.html
@@ -0,0 +1,56 @@
+
+
+
+
+
+ 교각 3D 형상 생성 (1단계)
+
+
+
+
+
+
+
+
+
+
+ 계산 결과
+
+ 알림
+
+ 기본값 적용 · 미구현
+
+
+
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..d343aa4
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,255 @@
+import './style.css';
+import * as THREE from 'three';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import * as XLSX from 'xlsx';
+import { parseWorkbook } from './core/parseWorkbook';
+import { buildPier, type PierModel } from './core/geometry';
+import { NOT_IMPLEMENTED_RULES, validateInput, validatePier } from './core/validate';
+import type { Issue, PierInput } from './core/types';
+import { buildPierScene, PART_LABELS, viewPosition, type PartName, type PierScene, type ViewKind } from './view/scene';
+
+interface Loaded {
+ input: PierInput;
+ model: PierModel | null;
+ issues: Issue[];
+}
+
+const $ = (id: string): T => document.getElementById(id) as T;
+
+const VIEW_LABELS: Record = { iso: '등각', front: '정면', side: '측면', top: '평면' };
+
+const state: {
+ piers: Loaded[];
+ parseIssues: Issue[];
+ current: { loaded: Loaded; scene: PierScene } | null;
+ view: ViewKind;
+ visible: Record;
+} = {
+ piers: [],
+ parseIssues: [],
+ current: null,
+ view: 'iso',
+ visible: Object.fromEntries(Object.keys(PART_LABELS).map((k) => [k, true])) as Record,
+};
+
+// --- 3D renderer -----------------------------------------------------------
+const container = $('canvas');
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+container.appendChild(renderer.domElement);
+const world = new THREE.Scene();
+world.background = new THREE.Color(0xe9eef4);
+const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 5000);
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+world.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 1.6));
+const sun = new THREE.DirectionalLight(0xffffff, 1.2);
+sun.position.set(30, 60, 40);
+world.add(sun);
+
+function resize(): void {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ renderer.setSize(w, h);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+}
+window.addEventListener('resize', resize);
+resize();
+
+function animate(): void {
+ controls.update();
+ renderer.render(world, camera);
+ requestAnimationFrame(animate);
+}
+animate();
+
+// --- UI builders (textContent only: workbook text is never parsed as HTML) -------
+function el(tag: K, text?: string, cls?: string): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (text !== undefined) node.textContent = text;
+ if (cls) node.className = cls;
+ return node;
+}
+
+function buildPartToggles(): void {
+ const box = $('parts');
+ for (const key of Object.keys(PART_LABELS) as PartName[]) {
+ const label = el('label');
+ const input = el('input');
+ input.type = 'checkbox';
+ input.checked = state.visible[key];
+ input.addEventListener('change', () => {
+ state.visible[key] = input.checked;
+ if (state.current) state.current.scene.parts[key].visible = input.checked;
+ });
+ label.append(input, document.createTextNode(PART_LABELS[key]));
+ box.appendChild(label);
+ }
+}
+
+function buildViewButtons(): void {
+ const box = $('views');
+ for (const kind of Object.keys(VIEW_LABELS) as ViewKind[]) {
+ const btn = el('button', VIEW_LABELS[kind]);
+ btn.type = 'button';
+ btn.setAttribute('aria-pressed', String(kind === state.view));
+ btn.addEventListener('click', () => setView(kind));
+ box.appendChild(btn);
+ }
+}
+
+function setView(kind: ViewKind): void {
+ state.view = kind;
+ document.querySelectorAll('#views button').forEach((b, i) => {
+ b.setAttribute('aria-pressed', String(Object.keys(VIEW_LABELS)[i] === kind));
+ });
+ if (!state.current) return;
+ const { position, target } = viewPosition(state.current.scene, kind, state.current.loaded.model!);
+ camera.position.copy(position);
+ controls.target.copy(target);
+ controls.update();
+}
+
+// --- Loading ----------------------------------------------------------------------
+function loadWorkbookBytes(bytes: Uint8Array): void {
+ const { piers, issues: parseIssues } = parseWorkbook(XLSX.read(bytes, { type: 'array' }));
+ const loaded: Loaded[] = piers.map((input) => {
+ const issues: Issue[] = [];
+ let model: PierModel | null = null;
+ if (validateInput(input, issues)) {
+ try {
+ model = buildPier(input);
+ validatePier(input, model, issues);
+ } catch (err) {
+ issues.push({ code: '미실시', level: 'E', pier: input.name, message: (err as Error).message });
+ }
+ }
+ return { input, model, issues };
+ });
+ state.piers = loaded;
+ state.parseIssues = parseIssues;
+
+ const select = $('pier');
+ select.replaceChildren();
+ for (const item of loaded) {
+ if (!item.model) continue;
+ select.appendChild(new Option(item.input.name, item.input.name));
+ }
+ select.disabled = select.options.length === 0;
+ const built = loaded.filter((l) => l.model).length;
+ $('status').textContent = `교각 ${piers.length}기 인식, 형상 생성 ${built}기`;
+ const first = loaded.find((l) => l.model);
+ if (first) showPier(first.input.name);
+ else showPier(null);
+}
+
+function showPier(name: string | null): void {
+ const select = $('pier');
+ if (state.current) world.remove(state.current.scene.root);
+ state.current = null;
+ if (!name) {
+ renderResults(null);
+ return;
+ }
+ const loaded = state.piers.find((l) => l.input.name === name && l.model);
+ if (!loaded || !loaded.model) return;
+ const scene = buildPierScene(loaded.model);
+ for (const key of Object.keys(PART_LABELS) as PartName[]) {
+ scene.parts[key].visible = state.visible[key];
+ }
+ world.add(scene.root);
+ state.current = { loaded, scene };
+ select.value = name;
+ setView(state.view);
+ renderResults(loaded);
+}
+
+// --- Results ------------------------------------------------------------------------
+function row(table: HTMLTableSectionElement, label: string, value: string): void {
+ const tr = el('tr');
+ tr.append(el('td', label), el('td', value));
+ table.appendChild(tr);
+}
+
+const fmt = (v: number, d = 3) => v.toFixed(d);
+const pt = (p: { e: number; n: number }) => `E ${p.e.toFixed(3)}, N ${p.n.toFixed(3)}`;
+
+function renderResults(loaded: Loaded | null): void {
+ const body = $('summary').tBodies[0] ?? $('summary').createTBody();
+ body.replaceChildren();
+ const issuesBox = $('issues');
+ issuesBox.replaceChildren();
+
+ const m = loaded?.model;
+ if (m && loaded) {
+ row(body, '교각', loaded.input.name);
+ row(body, '교각축 길이 L_IP (m)', fmt(m.lengthIp));
+ row(body, '교각축 방위각 (°)', fmt(m.azimuthDeg, 2));
+ row(body, 'BP1 (국토좌표 m)', pt(m.bp1));
+ row(body, 'BP2 (국토좌표 m)', pt(m.bp2));
+ row(body, 'd_IP2 (m)', fmt(m.dIp2));
+ row(body, '기둥 중심 (국토좌표 m)', pt(m.pivot));
+ row(body, '코핑 상면 EL 기준 (m)', fmt(loaded.input.copingTopEl));
+ row(body, '전열 구간 상면 EL (m)', m.segments.map((s) => fmt(s.top)).join(' / '));
+ row(body, '코핑 끝단 하면 EL (m)', fmt(m.endBottom));
+ row(body, '코핑 Level 하면 EL (m)', fmt(m.levelBottom));
+ row(body, '기둥 높이 (m)', fmt(m.derived.columnHeight));
+ row(body, '확대기초 상면 EL (m)', m.footings.length ? fmt(m.footings[0].top) : '없음');
+ row(body, '흙덮개 (m)', fmt(m.derived.soilCover));
+ row(body, '말뚝 수 (본)', String(m.derived.pileCount));
+ row(body, '말뚝 선단 EL (m)', m.derived.pileTipEl === null ? '없음' : fmt(m.derived.pileTipEl));
+ row(body, '받침블럭 콘크리트 전열 / 후열 (mm)', `${Math.round(m.derived.blockHeightFront * 1000)} / ${Math.round(m.derived.blockHeightBack * 1000)}`);
+ } else {
+ row(body, '상태', '교각을 불러오면 결과가 표시됩니다.');
+ }
+
+ const allIssues = [...state.parseIssues, ...state.piers.flatMap((l) => l.issues)];
+ if (allIssues.length === 0) {
+ issuesBox.appendChild(el('li', '알림 없음'));
+ }
+ for (const issue of allIssues) {
+ const li = el('li');
+ li.append(el('span', issue.code, 'code'), document.createTextNode(` ${issue.pier ? `[${issue.pier}] ` : ''}${issue.message}`));
+ issuesBox.appendChild(li);
+ }
+
+ const defaults = $('defaults');
+ defaults.replaceChildren();
+ const items = [
+ '[미확정] Q16 받침 EL·E 식: 현재 모델(블럭 높이 = EL 차) 적용',
+ '[미확정] Q5 말뚝 지름 600 mm (강관) 기본값',
+ '[미확정] Q4 직접기초 버림콘크리트 적용',
+ '횡방향 단차: 없을 때만 생성 (있으면 미실시 알림)',
+ ...NOT_IMPLEMENTED_RULES.map((r) => `미구현 규칙: ${r}`),
+ ];
+ for (const text of items) defaults.appendChild(el('li', text));
+}
+
+// --- Events -----------------------------------------------------------------------
+$('sample').addEventListener('click', async () => {
+ $('status').textContent = '샘플 불러오는 중…';
+ try {
+ const res = await fetch(new URL('samples/P1.xlsx', document.baseURI));
+ loadWorkbookBytes(new Uint8Array(await res.arrayBuffer()));
+ } catch (err) {
+ $('status').textContent = `샘플을 불러오지 못했습니다: ${(err as Error).message}`;
+ }
+});
+
+$('file').addEventListener('change', async (ev) => {
+ const file = (ev.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+ $('status').textContent = `${file.name} 읽는 중…`;
+ try {
+ loadWorkbookBytes(new Uint8Array(await file.arrayBuffer()));
+ } catch (err) {
+ $('status').textContent = `엑셀을 읽지 못했습니다: ${(err as Error).message}`;
+ }
+});
+
+$('pier').addEventListener('change', (ev) => showPier((ev.target as HTMLSelectElement).value));
+
+buildPartToggles();
+buildViewButtons();
+renderResults(null);
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..bb20c0f
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,227 @@
+:root {
+ --bg: #f5f7fa;
+ --panel: #ffffff;
+ --text: #1d2733;
+ --muted: #5b6878;
+ --line: #d5dce4;
+ --primary: #1f5fa8;
+ --warn: #9a5b00;
+ --focus: 2px solid #1f5fa8;
+ color-scheme: light dark;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme='light']) {
+ --bg: #111820;
+ --panel: #1a232d;
+ --text: #e6ebf0;
+ --muted: #9aa7b4;
+ --line: #2d3a47;
+ --primary: #5b93d6;
+ --warn: #e0a24a;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 14px/1.5 system-ui, 'Noto Sans KR', sans-serif;
+}
+
+button,
+select,
+input,
+label {
+ font: inherit;
+}
+
+button,
+select,
+.file {
+ min-height: 44px;
+}
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible {
+ outline: var(--focus);
+ outline-offset: 2px;
+}
+
+.top {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ border-bottom: 1px solid var(--line);
+ background: var(--panel);
+}
+
+.top h1 {
+ font-size: 18px;
+ margin: 0;
+}
+
+.stage {
+ font-size: 13px;
+ color: var(--muted);
+ font-weight: 400;
+ margin-left: 8px;
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.primary {
+ background: var(--primary);
+ color: #fff;
+ border: 0;
+ border-radius: 6px;
+ padding: 0 14px;
+ cursor: pointer;
+}
+
+.file {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.file input {
+ font-size: 12px;
+ max-width: 220px;
+}
+
+.layout {
+ display: grid;
+ grid-template-columns: 240px 1fr 320px;
+ gap: 12px;
+ padding: 12px 16px;
+ min-height: calc(100vh - 80px);
+}
+
+.side,
+.results,
+.viewer {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ padding: 12px;
+}
+
+.side section,
+.results section {
+ margin-bottom: 16px;
+}
+
+h2 {
+ font-size: 15px;
+ margin: 0 0 8px;
+}
+
+.muted {
+ color: var(--muted);
+}
+
+select {
+ width: 100%;
+ padding: 6px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--text);
+}
+
+.parts label,
+.views button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-height: 44px;
+}
+
+.views {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.views button {
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--panel);
+ color: var(--text);
+ justify-content: center;
+ cursor: pointer;
+}
+
+.views button[aria-pressed='true'] {
+ border-color: var(--primary);
+ color: var(--primary);
+ font-weight: 600;
+}
+
+.canvas {
+ width: 100%;
+ height: calc(100vh - 140px);
+ min-height: 420px;
+ border-radius: 6px;
+ overflow: hidden;
+ background: #e9eef4;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+td {
+ padding: 4px 6px;
+ border-bottom: 1px solid var(--line);
+ vertical-align: top;
+}
+
+td:first-child {
+ color: var(--muted);
+ width: 48%;
+}
+
+.issues {
+ padding-left: 18px;
+ margin: 0;
+}
+
+.issues li {
+ margin-bottom: 6px;
+ color: var(--text);
+}
+
+.issues .code {
+ font-weight: 600;
+ color: var(--warn);
+}
+
+@media (max-width: 900px) {
+ .layout {
+ grid-template-columns: 1fr;
+ }
+
+ .canvas {
+ height: 60vh;
+ }
+}
diff --git a/src/view/scene.ts b/src/view/scene.ts
new file mode 100644
index 0000000..9b041f9
--- /dev/null
+++ b/src/view/scene.ts
@@ -0,0 +1,137 @@
+import * as THREE from 'three';
+import type { PierModel } from '../core/geometry';
+
+export type PartName = 'coping' | 'columns' | 'footings' | 'piles' | 'blinding' | 'blocks' | 'pads' | 'ground';
+
+export const PART_LABELS: Record = {
+ coping: '코핑',
+ columns: '기둥',
+ footings: '확대기초',
+ piles: '말뚝',
+ blinding: '버림콘크리트',
+ blocks: '받침블럭',
+ pads: '모르타르 패드',
+ ground: '지반',
+};
+
+const COLORS: Record = {
+ coping: 0xb8c2cc,
+ columns: 0x9aa7b4,
+ footings: 0x7d8a99,
+ piles: 0x5b6878,
+ blinding: 0xe6e2d8,
+ blocks: 0xd6dde5,
+ pads: 0x8898aa,
+ ground: 0x6b8f4e,
+};
+
+/**
+ * Maps the model's local frame (x = along axis s, y = absolute EL, z = across axis n) to the scene.
+ * The scene uses y as up and places the first column centre at the origin (spec §1).
+ * Basis vectors: X = (u.e, 0, -u.n), Z = (nrm.e, 0, -nrm.n). The determinant is +1, so faces keep their winding.
+ */
+export function frameMatrix(m: PierModel): THREE.Matrix4 {
+ const X = new THREE.Vector3(m.u.e, 0, -m.u.n);
+ const Y = new THREE.Vector3(0, 1, 0);
+ const Z = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n);
+ const origin = new THREE.Vector3(m.bp1.e - m.pivot.e, 0, -(m.bp1.n - m.pivot.n));
+ return new THREE.Matrix4().makeBasis(X, Y, Z).setPosition(origin);
+}
+
+export interface PierScene {
+ root: THREE.Group;
+ parts: Record;
+ size: number;
+}
+
+export function buildPierScene(m: PierModel): PierScene {
+ const M = frameMatrix(m);
+ const parts = {} as Record;
+ for (const key of Object.keys(PART_LABELS) as PartName[]) {
+ parts[key] = new THREE.Group();
+ parts[key].name = key;
+ }
+
+ const add = (part: PartName, geometry: THREE.BufferGeometry, opacity = 1) => {
+ geometry.applyMatrix4(M);
+ const material = new THREE.MeshStandardMaterial({
+ color: COLORS[part],
+ roughness: 0.75,
+ metalness: 0.05,
+ transparent: opacity < 1,
+ opacity,
+ side: THREE.DoubleSide,
+ });
+ parts[part].add(new THREE.Mesh(geometry, material));
+ };
+
+ // Coping: the elevation outline extruded across the width (no boolean operations).
+ const shape = new THREE.Shape(m.profile.map(([s, z]) => new THREE.Vector2(s, z)));
+ const coping = new THREE.ExtrudeGeometry(shape, { depth: m.copingB, bevelEnabled: false });
+ coping.translate(0, 0, -m.copingB / 2);
+ add('coping', coping);
+
+ for (const c of m.columns) {
+ const g = new THREE.CylinderGeometry(c.diameter / 2, c.diameter / 2, c.top - c.bottom, 48);
+ g.translate(c.s, (c.top + c.bottom) / 2, 0);
+ add('columns', g);
+ }
+
+ for (const f of m.footings) {
+ const g = new THREE.BoxGeometry(f.length, f.top - f.bottom, f.width);
+ g.translate(f.s, (f.top + f.bottom) / 2, 0);
+ add('footings', g);
+ }
+
+ for (const b of m.blindings) {
+ const g = new THREE.BoxGeometry(b.length, b.thickness, b.width);
+ g.translate(b.s, b.top - b.thickness / 2, 0);
+ add('blinding', g);
+ }
+
+ for (const p of m.piles) {
+ const g = new THREE.CylinderGeometry(p.diameter / 2, p.diameter / 2, p.top - p.bottom, 20);
+ g.translate(p.s, (p.top + p.bottom) / 2, p.n);
+ add('piles', g);
+ }
+
+ for (const b of m.blocks) {
+ const h = b.top - b.bottom;
+ const g = new THREE.BoxGeometry(1.2, h, 1.2);
+ g.translate(b.s, b.bottom + h / 2, b.n);
+ add('blocks', g);
+ const pad = new THREE.BoxGeometry(1.0, 0.05, 1.0);
+ pad.translate(b.s, b.padTop - 0.025, b.n);
+ add('pads', pad);
+ }
+
+ // Ground: a translucent plane at the ground EL, used only to judge soil cover (not a computed input).
+ const ground = new THREE.PlaneGeometry(60, 60);
+ ground.rotateX(-Math.PI / 2);
+ ground.translate(m.copingL / 2, m.groundEl, 0);
+ add('ground', ground, 0.35);
+
+ const root = new THREE.Group();
+ for (const key of Object.keys(parts) as PartName[]) root.add(parts[key]);
+ const box = new THREE.Box3().setFromObject(root);
+ const size = Math.max(...box.getSize(new THREE.Vector3()).toArray());
+ return { root, parts, size };
+}
+
+export type ViewKind = 'iso' | 'front' | 'side' | 'top';
+
+/** Camera position for one of the four view presets, looking at the centre of the model. */
+export function viewPosition(scene: PierScene, kind: ViewKind, m: PierModel): { position: THREE.Vector3; target: THREE.Vector3 } {
+ const box = new THREE.Box3().setFromObject(scene.root);
+ const target = box.getCenter(new THREE.Vector3());
+ const dist = scene.size * 1.6;
+ const X = new THREE.Vector3(m.u.e, 0, -m.u.n);
+ const Z = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n);
+ const dirs: Record = {
+ iso: new THREE.Vector3(1, 0.7, 1).normalize(),
+ front: Z.clone().add(new THREE.Vector3(0, 0.15, 0)).normalize(),
+ side: X.clone().add(new THREE.Vector3(0, 0.15, 0)).normalize(),
+ top: new THREE.Vector3(0, 1, 0.001).normalize(),
+ };
+ return { position: target.clone().add(dirs[kind].multiplyScalar(dist)), target };
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/tests/scene.test.ts b/tests/scene.test.ts
new file mode 100644
index 0000000..b6821f0
--- /dev/null
+++ b/tests/scene.test.ts
@@ -0,0 +1,51 @@
+import * as THREE from 'three';
+import { describe, expect, it } from 'vitest';
+import { buildPier } from '../src/core/geometry';
+import { buildPierScene, frameMatrix } from '../src/view/scene';
+import { loadP1 } from './helpers';
+
+const model = buildPier(loadP1());
+const M = frameMatrix(model);
+
+/** Scene coordinates of a national-grid point at EL 0 (x = E − pivot E, z = −(N − pivot N)). */
+function sceneOf(e: number, n: number, el: number): THREE.Vector3 {
+ return new THREE.Vector3(e - model.pivot.e, el, -(n - model.pivot.n));
+}
+
+describe('3D frame (view/scene.ts)', () => {
+ it('is a proper rotation (determinant +1) so faces keep their winding', () => {
+ expect(M.determinant()).toBeCloseTo(1, 9);
+ });
+
+ it('maps local (along-axis s, across n, EL) to the national-grid position', () => {
+ // Block 1 on the front row: its centre in grid coordinates must match the local-to-grid formula.
+ const b = model.blocks[0];
+ const grid = {
+ e: model.bp1.e + model.u.e * b.s + model.nrm.e * b.n,
+ n: model.bp1.n + model.u.n * b.s + model.nrm.n * b.n,
+ };
+ const mapped = new THREE.Vector3(b.s, b.bottom, b.n).applyMatrix4(M);
+ const expected = sceneOf(grid.e, grid.n, b.bottom);
+ expect(mapped.distanceTo(expected)).toBeLessThan(1e-6);
+ });
+
+ it('bounding box spans pile tip (2.208 m) to the highest pad top (back bearing 5, 43.898 m = sheet E41)', () => {
+ const scene = buildPierScene(model);
+ const box = new THREE.Box3().setFromObject(scene.root);
+ expect(box.min.y).toBeCloseTo(2.208, 2);
+ expect(box.max.y).toBeCloseTo(43.898, 2);
+ });
+
+ it('builds the expected part counts for P1', () => {
+ const scene = buildPierScene(model);
+ const count = (key: keyof typeof scene.parts) => scene.parts[key].children.length;
+ expect(count('coping')).toBe(1);
+ expect(count('columns')).toBe(1);
+ expect(count('footings')).toBe(1);
+ expect(count('piles')).toBe(20);
+ expect(count('blocks')).toBe(10);
+ expect(count('pads')).toBe(10);
+ expect(count('blinding')).toBe(1);
+ expect(count('ground')).toBe(1);
+ });
+});