diff --git a/index.html b/index.html index a65997a..f798d65 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,6 @@
- + diff --git a/package.json b/package.json index 9bdcfe2..e58a90f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vue-tsc -b && vite build", + "type-check": "vue-tsc -b", "preview": "vite preview" }, "devEngines": { @@ -20,6 +21,8 @@ }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", - "vite": "^6.0.7" + "typescript": "^5.9.3", + "vite": "^6.0.7", + "vue-tsc": "^3.3.9" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e98c981..ed9f305 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -209,14 +209,20 @@ importers: dependencies: vue: specifier: ^3.5.13 - version: 3.5.40 + version: 3.5.40(typescript@5.9.3) devDependencies: '@vitejs/plugin-vue': specifier: ^5.2.1 - version: 5.2.4(vite@6.4.3)(vue@3.5.40) + version: 5.2.4(vite@6.4.3)(vue@3.5.40(typescript@5.9.3)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 vite: specifier: ^6.0.7 version: 6.4.3 + vue-tsc: + specifier: ^3.3.9 + version: 3.3.9(typescript@5.9.3) packages: @@ -551,6 +557,20 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + '@vue/compiler-core@3.5.40': resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} @@ -563,6 +583,9 @@ packages: '@vue/compiler-ssr@3.5.40': resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + '@vue/language-core@3.3.9': + resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==} + '@vue/reactivity@3.5.40': resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} @@ -578,6 +601,9 @@ packages: '@vue/shared@3.5.40': resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -610,11 +636,17 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + nanoid@3.3.16: resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -639,6 +671,11 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + vite@6.4.3: resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -679,6 +716,15 @@ packages: yaml: optional: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-tsc@3.3.9: + resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + vue@3.5.40: resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} peerDependencies: @@ -862,10 +908,24 @@ snapshots: '@types/estree@1.0.9': {} - '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.40)': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3)(vue@3.5.40(typescript@5.9.3))': dependencies: vite: 6.4.3 - vue: 3.5.40 + vue: 3.5.40(typescript@5.9.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + optionalDependencies: + typescript: 5.9.3 '@vue/compiler-core@3.5.40': dependencies: @@ -897,6 +957,16 @@ snapshots: '@vue/compiler-dom': 3.5.40 '@vue/shared': 3.5.40 + '@vue/language-core@3.3.9': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.5 + '@vue/reactivity@3.5.40': dependencies: '@vue/shared': 3.5.40 @@ -921,6 +991,8 @@ snapshots: '@vue/shared@3.5.40': {} + alien-signals@3.2.1: {} + csstype@3.2.3: {} entities@7.0.1: {} @@ -967,8 +1039,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + muggle-string@0.4.1: {} + nanoid@3.3.16: {} + path-browserify@1.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -1018,6 +1094,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + typescript@5.9.3: {} + vite@6.4.3: dependencies: esbuild: 0.25.12 @@ -1029,10 +1107,20 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - vue@3.5.40: + vscode-uri@3.1.0: {} + + vue-tsc@3.3.9(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28(typescript@5.9.3) + '@vue/language-core': 3.3.9 + typescript: 5.9.3 + + vue@3.5.40(typescript@5.9.3): dependencies: '@vue/compiler-dom': 3.5.40 '@vue/compiler-sfc': 3.5.40 '@vue/runtime-dom': 3.5.40 '@vue/server-renderer': 3.5.40 '@vue/shared': 3.5.40 + optionalDependencies: + typescript: 5.9.3 diff --git a/src/App.vue b/src/App.vue index 502d57c..c2c8d6a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,34 +1,43 @@ - @@ -136,9 +135,4 @@ const groundY = computed(() => props.shape.rowsAbove > 0 || props.shape.rowsBelo stroke: rgba(0, 0, 0, 0.35); stroke-width: 0.5; } -.ground-line { - stroke: #ef4444; - stroke-width: 1.5; - stroke-dasharray: 4 3; -} diff --git a/src/composables/useShapeGenerator.js b/src/composables/useShapeGenerator.js deleted file mode 100644 index 5c8bb0a..0000000 --- a/src/composables/useShapeGenerator.js +++ /dev/null @@ -1,198 +0,0 @@ -// Builds a Minecraft elevation-view (front-facing) ellipse/arch profile, -// smoothed with slabs (half-block resolution) and stairs (beveled corners), -// on top of a base grid of whole blocks. -// -// Coordinate system: columns run left->right (x), rows run bottom->up (y). -// Each column produces a stack of cells. Cell types: -// 'full' - a whole block -// 'slab' - a half-height slab, `half` tells which half is solid ('bottom' | 'top') -// 'stair' - a block whose solid area is a full bottom/top half plus one -// horizontal quadrant on the "inward" side; `half` is which half is -// fully solid, `openSide` is the horizontal side ('left' | 'right') -// of the *other* half that is left empty (the beveled corner). - -const EPS = 1e-9 - -/** - * Turn a continuous outward distance into a bottom-up (or top-down) list of - * cells, using half-block quantization for one extra level of smoothness. - * `dir` controls which half of the fractional cell is solid: - * 'up' -> solid half is the one closer to the origin (bottom of the cell) - * 'down' -> solid half is the one closer to the origin (top of the cell) - */ -function buildProfileCells(distance, dir) { - const halfSteps = Math.round(distance * 2) / 2 - const fullBlocks = Math.floor(halfSteps + EPS) - const remainder = halfSteps - fullBlocks - - const cells = [] - for (let k = 0; k < fullBlocks; k++) { - cells.push({ type: 'full' }) - } - if (remainder >= 0.5 - EPS) { - cells.push({ type: 'slab', half: dir === 'up' ? 'bottom' : 'top' }) - } - return cells -} - -/** - * Given the per-column array of "outward full-block counts" (ignoring slabs), - * convert the outermost full block of a column into a stair whenever the - * neighbor farther from the center is exactly one whole block shorter. - * Mutates `columns` in place (columns[i] is the cells array from buildProfileCells, - * ordered near-origin -> far-from-origin). - */ -function applyStairBevel(columns, centerIndexFn, dir) { - const n = columns.length - for (let i = 0; i < n; i++) { - const cells = columns[i] - if (cells.length === 0) continue - const top = cells[cells.length - 1] - if (top.type !== 'full') continue // slabs already smooth this transition - - const side = centerIndexFn(i) // <0 left of center, >0 right, 0 at center - if (side === 0) continue - - const outwardIdx = side < 0 ? i - 1 : i + 1 - const outwardFull = outwardIdx >= 0 && outwardIdx < n ? countFull(columns[outwardIdx]) : 0 - const ownFull = countFull(cells) - - if (ownFull - outwardFull === 1) { - top.type = 'stair' - top.half = dir === 'up' ? 'bottom' : 'top' - top.openSide = side < 0 ? 'left' : 'right' - } - } -} - -function countFull(cells) { - let n = 0 - for (const c of cells) if (c.type === 'full' || c.type === 'stair') n++ - return n -} - -/** - * Compute the full grid for one shape. - * - * @param {object} opts - * @param {number} opts.width - blocks across (span) - * @param {number} opts.height - blocks tall (rise). For 'oval' this is the full height. - * @param {'arch'|'oval'} opts.mode - * @param {boolean} opts.hollow - * @param {number} opts.thickness - wall thickness in blocks, only used when hollow - * @returns {{ width:number, rowsBelow:number, rowsAbove:number, columns: Array> }} - * columns[i] is a bottom-up array; index 0 = bottommost row of the grid. - */ -export function generateShape({ width, height, mode, hollow, thickness }) { - const w = Math.max(1, Math.round(width)) - const h = Math.max(1, Math.round(height)) - const a = w / 2 - const bTop = mode === 'oval' ? h / 2 : h - const bBottom = mode === 'oval' ? h / 2 : 0 - - const centerOffset = (w - 1) / 2 - - const upFull = [] - const downFull = [] - - for (let i = 0; i < w; i++) { - const dx = i - centerOffset - const t = Math.max(0, 1 - (dx / a) ** 2) - const yUp = bTop * Math.sqrt(t) - const yDown = bBottom * Math.sqrt(t) - upFull.push(buildProfileCells(yUp, 'up')) - downFull.push(buildProfileCells(yDown, 'down')) - } - - const centerIndexFn = (i) => { - const dx = i - centerOffset - if (Math.abs(dx) < EPS) return 0 - return dx < 0 ? -1 : 1 - } - - applyStairBevel(upFull, centerIndexFn, 'up') - if (mode === 'oval') applyStairBevel(downFull, centerIndexFn, 'down') - - let rowsAbove = 0 - let rowsBelow = 0 - for (let i = 0; i < w; i++) { - rowsAbove = Math.max(rowsAbove, upFull[i].length) - rowsBelow = Math.max(rowsBelow, downFull[i].length) - } - - // Assemble bottom-up columns: [reversed down-cells] + [up-cells] - const columns = [] - for (let i = 0; i < w; i++) { - const down = downFull[i].slice().reverse() // far-from-center -> near-center - const up = upFull[i] // near-center -> far-from-center - const col = new Array(rowsBelow + rowsAbove).fill(null) - - for (let k = 0; k < down.length; k++) { - col[rowsBelow - down.length + k] = down[k] - } - for (let k = 0; k < up.length; k++) { - col[rowsBelow + k] = up[k] - } - columns.push(col) - } - - let finalColumns = columns - if (hollow) { - const t = Math.max(1, Math.round(thickness)) - finalColumns = columns.map((col) => hollowColumn(col, t)) - } - - return { width: w, rowsBelow, rowsAbove, columns: finalColumns } -} - -// Keep only the outer `t` solid cells of a column (measured from each solid -// run's outer end inward), used for a hollow/outline shell of the shape. -function hollowColumn(col, t) { - const n = col.length - const result = new Array(n).fill(null) - - // Find contiguous solid run from the top (outward if arch) and from the bottom. - let topIdx = -1 - for (let r = n - 1; r >= 0; r--) { - if (col[r]) { - topIdx = r - break - } - } - let bottomIdx = -1 - for (let r = 0; r < n; r++) { - if (col[r]) { - bottomIdx = r - break - } - } - if (topIdx === -1) return result - - for (let r = 0; r < n; r++) { - if (!col[r]) continue - const distFromTop = topIdx - r - const distFromBottom = r - bottomIdx - if (distFromTop < t || distFromBottom < t) { - result[r] = col[r] - } - } - return result -} - -// Tally block counts for a shopping list. -export function summarize(shape) { - const counts = { full: 0, slab: 0, stairOpenLeft: 0, stairOpenRight: 0 } - for (const col of shape.columns) { - for (const cell of col) { - if (!cell) continue - if (cell.type === 'full') counts.full++ - else if (cell.type === 'slab') counts.slab++ - else if (cell.type === 'stair') { - if (cell.openSide === 'left') counts.stairOpenLeft++ - else counts.stairOpenRight++ - } - } - } - counts.total = counts.full + counts.slab + counts.stairOpenLeft + counts.stairOpenRight - return counts -} diff --git a/src/composables/useShapeGenerator.ts b/src/composables/useShapeGenerator.ts new file mode 100644 index 0000000..77e2ea5 --- /dev/null +++ b/src/composables/useShapeGenerator.ts @@ -0,0 +1,241 @@ +// Builds a Minecraft elevation-view (front-facing) block list for a wall +// panel with an arch- or oval-shaped opening cut out of it, smoothed with +// slabs (half-block resolution) and stairs (beveled corners) instead of a +// fully jagged hole outline. +// +// Coordinate system: columns run left->right (x), rows run bottom->up (y). +// The final shape always fills the whole `width x height` rectangle except +// for the opening; the opening's boundary cells are what carry the +// smoothing detail. + +export type CellType = 'full' | 'slab' | 'stair' +export type Half = 'top' | 'bottom' +export type Side = 'left' | 'right' + +export interface Cell { + type: CellType + /** Which half of the cell is solid. Only set for 'slab' and 'stair'. */ + half?: Half + /** + * For 'stair' cells only: the horizontal side whose far quadrant (on the + * non-solid half) is left empty - the beveled corner. + */ + openSide?: Side +} + +export type Column = (Cell | null)[] + +export interface Shape { + width: number + height: number + /** columns[i] is bottom-up, length === height */ + columns: Column[] +} + +export type ShapeMode = 'arch' | 'oval' + +export interface GenerateOptions { + width: number + height: number + mode: ShapeMode + useSlabs: boolean + useStairs: boolean +} + +const EPS = 1e-9 + +/** + * Turn a continuous outward distance into a near-origin -> far-from-origin + * list of solid cells, optionally using half-block quantization for one + * extra level of smoothness. `dir` controls which half of a fractional cell + * is solid: + * 'up' -> solid half is the one closer to the origin (bottom of the cell) + * 'down' -> solid half is the one closer to the origin (top of the cell) + */ +function buildProfileCells(distance: number, dir: 'up' | 'down', useSlabs: boolean): Cell[] { + const resolved = useSlabs ? Math.round(distance * 2) / 2 : Math.round(distance) + const fullBlocks = Math.floor(resolved + EPS) + const remainder = resolved - fullBlocks + + const cells: Cell[] = [] + for (let k = 0; k < fullBlocks; k++) { + cells.push({ type: 'full' }) + } + if (useSlabs && remainder >= 0.5 - EPS) { + cells.push({ type: 'slab', half: dir === 'up' ? 'bottom' : 'top' }) + } + return cells +} + +function countSolid(cells: Cell[]): number { + let n = 0 + for (const c of cells) if (c.type === 'full' || c.type === 'stair') n++ + return n +} + +/** + * Convert the outermost full block of a column into a stair whenever the + * neighbor farther from the center is exactly one whole block shorter. + * Mutates `columns` in place. + */ +function applyStairBevel(columns: Cell[][], centerSide: (i: number) => -1 | 0 | 1, dir: 'up' | 'down') { + const n = columns.length + for (let i = 0; i < n; i++) { + const cells = columns[i] + if (cells.length === 0) continue + const top = cells[cells.length - 1] + if (top.type !== 'full') continue // slabs already smooth this transition + + const side = centerSide(i) + if (side === 0) continue + + const outwardIdx = side < 0 ? i - 1 : i + 1 + const outwardFull = outwardIdx >= 0 && outwardIdx < n ? countSolid(columns[outwardIdx]) : 0 + const ownFull = countSolid(cells) + + if (ownFull - outwardFull === 1) { + top.type = 'stair' + top.half = dir === 'up' ? 'bottom' : 'top' + top.openSide = side < 0 ? 'left' : 'right' + } + } +} + +interface HoleShape { + totalRows: number + columns: Column[] +} + +/** Computes the solid "hole" shape (the arch mound / oval blob) that will later be subtracted from the wall. */ +function generateHoleShape(width: number, height: number, mode: ShapeMode, useSlabs: boolean, useStairs: boolean): HoleShape { + const a = width / 2 + const bTop = mode === 'oval' ? height / 2 : height + const bBottom = mode === 'oval' ? height / 2 : 0 + const centerOffset = (width - 1) / 2 + + const upCells: Cell[][] = [] + const downCells: Cell[][] = [] + + for (let i = 0; i < width; i++) { + const dx = i - centerOffset + const t = Math.max(0, 1 - (dx / a) ** 2) + const yUp = bTop * Math.sqrt(t) + const yDown = bBottom * Math.sqrt(t) + upCells.push(buildProfileCells(yUp, 'up', useSlabs)) + downCells.push(buildProfileCells(yDown, 'down', useSlabs)) + } + + const centerSide = (i: number): -1 | 0 | 1 => { + const dx = i - centerOffset + if (Math.abs(dx) < EPS) return 0 + return dx < 0 ? -1 : 1 + } + + if (useStairs) { + applyStairBevel(upCells, centerSide, 'up') + if (mode === 'oval') applyStairBevel(downCells, centerSide, 'down') + } + + let rowsAbove = 0 + let rowsBelow = 0 + for (let i = 0; i < width; i++) { + rowsAbove = Math.max(rowsAbove, upCells[i].length) + rowsBelow = Math.max(rowsBelow, downCells[i].length) + } + + const totalRows = rowsBelow + rowsAbove + const columns: Column[] = [] + for (let i = 0; i < width; i++) { + const down = downCells[i].slice().reverse() // far-from-center -> near-center + const up = upCells[i] // near-center -> far-from-center + const col: Column = new Array(totalRows).fill(null) + + for (let k = 0; k < down.length; k++) { + col[rowsBelow - down.length + k] = down[k] + } + for (let k = 0; k < up.length; k++) { + col[rowsBelow + k] = up[k] + } + columns.push(col) + } + + return { totalRows, columns } +} + +/** + * Subtract a hole shape from a solid `width x hole.totalRows` wall. + * + * The arch mound only has one real curved surface (its top - it sits flush + * on the ground, so its bottom is just the grid edge, not a smoothing + * boundary). The oval blob floats free and has two curved surfaces (top and + * bottom). `preserveBottomBoundary` selects between the two: when false, + * everything from the grid bottom up to (and excluding) the top boundary + * cell becomes air; when true, the bottom boundary cell is kept too and only + * the interior between the two boundaries becomes air. + */ +function invertToWall(hole: HoleShape, width: number, preserveBottomBoundary: boolean): Shape { + const rows = hole.totalRows + const columns: Column[] = hole.columns.map((col) => { + let lo = -1 + let hi = -1 + for (let r = 0; r < col.length; r++) { + if (col[r]) { + if (lo === -1) lo = r + hi = r + } + } + + const newCol: Column = new Array(rows) + if (lo === -1) { + for (let r = 0; r < rows; r++) newCol[r] = { type: 'full' } + return newCol + } + + const airFrom = preserveBottomBoundary ? lo + 1 : 0 + const keepLo = preserveBottomBoundary + + for (let r = 0; r < rows; r++) { + if (r > hi) newCol[r] = { type: 'full' } + else if (r === hi) newCol[r] = col[r] + else if (keepLo && r === lo) newCol[r] = col[r] + else if (r >= airFrom) newCol[r] = null + else newCol[r] = { type: 'full' } + } + return newCol + }) + + return { width, height: rows, columns } +} + +export function generateShape(opts: GenerateOptions): Shape { + const width = Math.max(1, Math.round(opts.width)) + const height = Math.max(1, Math.round(opts.height)) + const hole = generateHoleShape(width, height, opts.mode, opts.useSlabs, opts.useStairs) + return invertToWall(hole, width, opts.mode === 'oval') +} + +export interface Summary { + full: number + slab: number + stairOpenLeft: number + stairOpenRight: number + total: number +} + +// Tally block counts for a shopping list. +export function summarize(shape: Shape): Summary { + const counts: Summary = { full: 0, slab: 0, stairOpenLeft: 0, stairOpenRight: 0, total: 0 } + for (const col of shape.columns) { + for (const cell of col) { + if (!cell) continue + if (cell.type === 'full') counts.full++ + else if (cell.type === 'slab') counts.slab++ + else if (cell.type === 'stair') { + if (cell.openSide === 'left') counts.stairOpenLeft++ + else counts.stairOpenRight++ + } + } + } + counts.total = counts.full + counts.slab + counts.stairOpenLeft + counts.stairOpenRight + return counts +} diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..e0ea355 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, unknown> + export default component +} diff --git a/src/main.js b/src/main.ts similarity index 100% rename from src/main.js rename to src/main.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1bac202 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..dda5adc --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/env.d.ts","./src/main.ts","./src/composables/useShapeGenerator.ts","./src/App.vue","./src/components/BlockCanvas.vue"],"version":"5.9.3"} \ No newline at end of file