second version

This commit is contained in:
2026-08-04 01:32:33 +02:00
parent 94f72d351e
commit 081e08d1f5
11 changed files with 435 additions and 267 deletions
+1 -1
View File
@@ -7,6 +7,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+5 -2
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview" "preview": "vite preview"
}, },
"devEngines": { "devEngines": {
@@ -20,6 +21,8 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7" "typescript": "^5.9.3",
"vite": "^6.0.7",
"vue-tsc": "^3.3.9"
} }
} }
+93 -5
View File
@@ -209,14 +209,20 @@ importers:
dependencies: dependencies:
vue: vue:
specifier: ^3.5.13 specifier: ^3.5.13
version: 3.5.40 version: 3.5.40(typescript@5.9.3)
devDependencies: devDependencies:
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: ^5.2.1 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: vite:
specifier: ^6.0.7 specifier: ^6.0.7
version: 6.4.3 version: 6.4.3
vue-tsc:
specifier: ^3.3.9
version: 3.3.9(typescript@5.9.3)
packages: packages:
@@ -551,6 +557,20 @@ packages:
vite: ^5.0.0 || ^6.0.0 vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25 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': '@vue/compiler-core@3.5.40':
resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==}
@@ -563,6 +583,9 @@ packages:
'@vue/compiler-ssr@3.5.40': '@vue/compiler-ssr@3.5.40':
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
'@vue/language-core@3.3.9':
resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
'@vue/reactivity@3.5.40': '@vue/reactivity@3.5.40':
resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==}
@@ -578,6 +601,9 @@ packages:
'@vue/shared@3.5.40': '@vue/shared@3.5.40':
resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==}
alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -610,11 +636,17 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.16: nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -639,6 +671,11 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
vite@6.4.3: vite@6.4.3:
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -679,6 +716,15 @@ packages:
yaml: yaml:
optional: true 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: vue@3.5.40:
resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==}
peerDependencies: peerDependencies:
@@ -862,10 +908,24 @@ snapshots:
'@types/estree@1.0.9': {} '@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: dependencies:
vite: 6.4.3 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': '@vue/compiler-core@3.5.40':
dependencies: dependencies:
@@ -897,6 +957,16 @@ snapshots:
'@vue/compiler-dom': 3.5.40 '@vue/compiler-dom': 3.5.40
'@vue/shared': 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': '@vue/reactivity@3.5.40':
dependencies: dependencies:
'@vue/shared': 3.5.40 '@vue/shared': 3.5.40
@@ -921,6 +991,8 @@ snapshots:
'@vue/shared@3.5.40': {} '@vue/shared@3.5.40': {}
alien-signals@3.2.1: {}
csstype@3.2.3: {} csstype@3.2.3: {}
entities@7.0.1: {} entities@7.0.1: {}
@@ -967,8 +1039,12 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
muggle-string@0.4.1: {}
nanoid@3.3.16: {} nanoid@3.3.16: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@4.0.5: {} picomatch@4.0.5: {}
@@ -1018,6 +1094,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.5) fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5 picomatch: 4.0.5
typescript@5.9.3: {}
vite@6.4.3: vite@6.4.3:
dependencies: dependencies:
esbuild: 0.25.12 esbuild: 0.25.12
@@ -1029,10 +1107,20 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 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: dependencies:
'@vue/compiler-dom': 3.5.40 '@vue/compiler-dom': 3.5.40
'@vue/compiler-sfc': 3.5.40 '@vue/compiler-sfc': 3.5.40
'@vue/runtime-dom': 3.5.40 '@vue/runtime-dom': 3.5.40
'@vue/server-renderer': 3.5.40 '@vue/server-renderer': 3.5.40
'@vue/shared': 3.5.40 '@vue/shared': 3.5.40
optionalDependencies:
typescript: 5.9.3
+32 -22
View File
@@ -1,34 +1,43 @@
<script setup> <script setup lang="ts">
import { computed, reactive } from 'vue' import { computed, reactive } from 'vue'
import BlockCanvas from './components/BlockCanvas.vue' import BlockCanvas from './components/BlockCanvas.vue'
import { generateShape, summarize } from './composables/useShapeGenerator.js' import { generateShape, summarize, type ShapeMode } from './composables/useShapeGenerator'
const form = reactive({ interface FormState {
width: number
height: number
mode: ShapeMode
useSlabs: boolean
useStairs: boolean
cellSize: number
}
const form = reactive<FormState>({
width: 12, width: 12,
height: 6, height: 8,
mode: 'arch', mode: 'arch',
hollow: false, useSlabs: true,
thickness: 2, useStairs: true,
cellSize: 20, cellSize: 20,
}) })
const MIN_SIZE = 2 const MIN_SIZE = 2
const MAX_SIZE = 120 const MAX_SIZE = 120
function clamp(v, lo, hi) { function clamp(v: number, lo: number, hi: number): number {
if (Number.isNaN(v)) return lo if (Number.isNaN(v)) return lo
return Math.min(hi, Math.max(lo, v)) return Math.min(hi, Math.max(lo, v))
} }
const shape = computed(() => { const shape = computed(() => {
const width = clamp(Math.round(form.width), MIN_SIZE, MAX_SIZE) const width = clamp(Math.round(form.width), MIN_SIZE, MAX_SIZE)
const height = clamp(Math.round(form.height), 1, MAX_SIZE) const height = clamp(Math.round(form.height), MIN_SIZE, MAX_SIZE)
return generateShape({ return generateShape({
width, width,
height, height,
mode: form.mode, mode: form.mode,
hollow: form.hollow, useSlabs: form.useSlabs,
thickness: clamp(Math.round(form.thickness), 1, height), useStairs: form.useStairs,
}) })
}) })
@@ -40,9 +49,10 @@ const stats = computed(() => summarize(shape.value))
<header> <header>
<h1>Minecraft Arch / Ellipse Generator</h1> <h1>Minecraft Arch / Ellipse Generator</h1>
<p class="subtitle"> <p class="subtitle">
Enter a width and height (in blocks). The curve is smoothed with slabs Enter a width and height (in blocks) for the wall panel. An arch or
(half-block steps) and stairs (beveled corners) instead of a fully oval opening is cut out of it, smoothed with slabs (half-block steps)
jagged block outline. and stairs (beveled corners) instead of a fully jagged hole outline.
The result is the wall blocks you need to place, not the opening.
</p> </p>
</header> </header>
@@ -55,25 +65,25 @@ const stats = computed(() => summarize(shape.value))
<label> <label>
Height (blocks) Height (blocks)
<input type="number" v-model.number="form.height" :min="1" :max="MAX_SIZE" /> <input type="number" v-model.number="form.height" :min="MIN_SIZE" :max="MAX_SIZE" />
</label> </label>
<label> <label>
Shape Opening shape
<select v-model="form.mode"> <select v-model="form.mode">
<option value="arch">Arch (half ellipse, flat base)</option> <option value="arch">Arch doorway (cut from the ground up)</option>
<option value="oval">Full oval (window / portal)</option> <option value="oval">Oval window (cut from the middle)</option>
</select> </select>
</label> </label>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" v-model="form.hollow" /> <input type="checkbox" v-model="form.useSlabs" />
Hollow (outline shell) Use slabs (half-block smoothing)
</label> </label>
<label v-if="form.hollow"> <label class="checkbox">
Wall thickness (blocks) <input type="checkbox" v-model="form.useStairs" />
<input type="number" v-model.number="form.thickness" min="1" :max="form.height" /> Use stairs (corner bevel)
</label> </label>
<label> <label>
+33 -39
View File
@@ -1,27 +1,38 @@
<script setup> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { Cell, Shape } from '../composables/useShapeGenerator'
const props = defineProps({ const props = withDefaults(
shape: { type: Object, required: true }, defineProps<{
cellSize: { type: Number, default: 20 }, shape: Shape
}) cellSize?: number
}>(),
{ cellSize: 20 },
)
const totalRows = computed(() => props.shape.rowsBelow + props.shape.rowsAbove) const totalRows = computed(() => props.shape.height)
const totalCols = computed(() => props.shape.width) const totalCols = computed(() => props.shape.width)
const svgWidth = computed(() => totalCols.value * props.cellSize) const svgWidth = computed(() => totalCols.value * props.cellSize)
const svgHeight = computed(() => totalRows.value * props.cellSize) const svgHeight = computed(() => totalRows.value * props.cellSize)
const COLORS = { const COLORS: Record<Cell['type'], string> = {
full: '#8a8f98', full: '#8a8f98',
slab: '#e8a33d', slab: '#e8a33d',
stair: '#4fa3d1', stair: '#4fa3d1',
} }
interface LocalRect {
x: number
y: number
w: number
h: number
}
// Local (0..1) rectangles describing the solid area(s) of a cell, in // Local (0..1) rectangles describing the solid area(s) of a cell, in
// SVG-space where y grows downward. "top"/"bottom" refer to the visual // SVG-space where y grows downward. "top"/"bottom" refer to the visual
// top/bottom of the cell. // top/bottom of the cell.
function localRects(cell) { function localRects(cell: Cell): LocalRect[] {
if (cell.type === 'full') { if (cell.type === 'full') {
return [{ x: 0, y: 0, w: 1, h: 1 }] return [{ x: 0, y: 0, w: 1, h: 1 }]
} }
@@ -29,25 +40,28 @@ function localRects(cell) {
return [cell.half === 'bottom' ? { x: 0, y: 0.5, w: 1, h: 0.5 } : { x: 0, y: 0, w: 1, h: 0.5 }] return [cell.half === 'bottom' ? { x: 0, y: 0.5, w: 1, h: 0.5 } : { x: 0, y: 0, w: 1, h: 0.5 }]
} }
// stair: a full half (bottom or top) plus one quadrant of the other half // stair: a full half (bottom or top) plus one quadrant of the other half
const solidHalf = cell.half === 'bottom' ? { x: 0, y: 0.5, w: 1, h: 0.5 } : { x: 0, y: 0, w: 1, h: 0.5 } const solidHalf: LocalRect = cell.half === 'bottom' ? { x: 0, y: 0.5, w: 1, h: 0.5 } : { x: 0, y: 0, w: 1, h: 0.5 }
const otherHalfY = cell.half === 'bottom' ? 0 : 0.5 const otherHalfY = cell.half === 'bottom' ? 0 : 0.5
const solidQuadrantX = cell.openSide === 'left' ? 0.5 : 0 const solidQuadrantX = cell.openSide === 'left' ? 0.5 : 0
const quadrant = { x: solidQuadrantX, y: otherHalfY, w: 0.5, h: 0.5 } const quadrant: LocalRect = { x: solidQuadrantX, y: otherHalfY, w: 0.5, h: 0.5 }
return [solidHalf, quadrant] return [solidHalf, quadrant]
} }
const rects = computed(() => { interface PixelRect extends LocalRect {
const out = [] fill: string
const { columns, rowsBelow, rowsAbove } = props.shape }
const rows = rowsBelow + rowsAbove
const rects = computed<PixelRect[]>(() => {
const out: PixelRect[] = []
const { columns, height } = props.shape
const size = props.cellSize const size = props.cellSize
for (let i = 0; i < columns.length; i++) { for (let i = 0; i < columns.length; i++) {
const col = columns[i] const col = columns[i]
for (let r = 0; r < rows; r++) { for (let r = 0; r < height; r++) {
const cell = col[r] const cell = col[r]
if (!cell) continue if (!cell) continue
const cellX = i * size const cellX = i * size
const cellY = (rows - 1 - r) * size const cellY = (height - 1 - r) * size
for (const rect of localRects(cell)) { for (const rect of localRects(cell)) {
out.push({ out.push({
x: cellX + rect.x * size, x: cellX + rect.x * size,
@@ -62,10 +76,9 @@ const rects = computed(() => {
return out return out
}) })
// Faint grid outline for every cell (including empty ones) so the overall // Faint grid outline for every cell so the overall bounding box is visible.
// bounding box of the shape is visible. const gridCells = computed<LocalRect[]>(() => {
const gridCells = computed(() => { const out: LocalRect[] = []
const out = []
const rows = totalRows.value const rows = totalRows.value
const size = props.cellSize const size = props.cellSize
for (let i = 0; i < totalCols.value; i++) { for (let i = 0; i < totalCols.value; i++) {
@@ -75,12 +88,6 @@ const gridCells = computed(() => {
} }
return out return out
}) })
// Spring line (ground level) marker, only meaningful for the arch mode but
// harmless to show always at rowsBelow.
const groundY = computed(() => props.shape.rowsAbove > 0 || props.shape.rowsBelow > 0
? (totalRows.value - props.shape.rowsBelow) * props.cellSize
: 0)
</script> </script>
<template> <template>
@@ -109,14 +116,6 @@ const groundY = computed(() => props.shape.rowsAbove > 0 || props.shape.rowsBelo
:fill="r.fill" :fill="r.fill"
class="block-rect" class="block-rect"
/> />
<line
v-if="shape.rowsBelow > 0"
:x1="0"
:x2="svgWidth"
:y1="groundY"
:y2="groundY"
class="ground-line"
/>
</svg> </svg>
</template> </template>
@@ -136,9 +135,4 @@ const groundY = computed(() => props.shape.rowsAbove > 0 || props.shape.rowsBelo
stroke: rgba(0, 0, 0, 0.35); stroke: rgba(0, 0, 0, 0.35);
stroke-width: 0.5; stroke-width: 0.5;
} }
.ground-line {
stroke: #ef4444;
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
</style> </style>
-198
View File
@@ -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<Array<cell|null>> }}
* 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
}
+241
View File
@@ -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
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, unknown>
export default component
}
View File
+22
View File
@@ -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"]
}
+1
View File
@@ -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"}