second version
This commit is contained in:
+32
-22
@@ -1,34 +1,43 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from '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,
|
||||
height: 6,
|
||||
height: 8,
|
||||
mode: 'arch',
|
||||
hollow: false,
|
||||
thickness: 2,
|
||||
useSlabs: true,
|
||||
useStairs: true,
|
||||
cellSize: 20,
|
||||
})
|
||||
|
||||
const MIN_SIZE = 2
|
||||
const MAX_SIZE = 120
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
function clamp(v: number, lo: number, hi: number): number {
|
||||
if (Number.isNaN(v)) return lo
|
||||
return Math.min(hi, Math.max(lo, v))
|
||||
}
|
||||
|
||||
const shape = computed(() => {
|
||||
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({
|
||||
width,
|
||||
height,
|
||||
mode: form.mode,
|
||||
hollow: form.hollow,
|
||||
thickness: clamp(Math.round(form.thickness), 1, height),
|
||||
useSlabs: form.useSlabs,
|
||||
useStairs: form.useStairs,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,9 +49,10 @@ const stats = computed(() => summarize(shape.value))
|
||||
<header>
|
||||
<h1>Minecraft Arch / Ellipse Generator</h1>
|
||||
<p class="subtitle">
|
||||
Enter a width and height (in blocks). The curve is smoothed with slabs
|
||||
(half-block steps) and stairs (beveled corners) instead of a fully
|
||||
jagged block outline.
|
||||
Enter a width and height (in blocks) for the wall panel. An arch or
|
||||
oval opening is cut out of it, smoothed with slabs (half-block steps)
|
||||
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>
|
||||
</header>
|
||||
|
||||
@@ -55,25 +65,25 @@ const stats = computed(() => summarize(shape.value))
|
||||
|
||||
<label>
|
||||
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>
|
||||
Shape
|
||||
Opening shape
|
||||
<select v-model="form.mode">
|
||||
<option value="arch">Arch (half ellipse, flat base)</option>
|
||||
<option value="oval">Full oval (window / portal)</option>
|
||||
<option value="arch">Arch doorway (cut from the ground up)</option>
|
||||
<option value="oval">Oval window (cut from the middle)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="form.hollow" />
|
||||
Hollow (outline shell)
|
||||
<input type="checkbox" v-model="form.useSlabs" />
|
||||
Use slabs (half-block smoothing)
|
||||
</label>
|
||||
|
||||
<label v-if="form.hollow">
|
||||
Wall thickness (blocks)
|
||||
<input type="number" v-model.number="form.thickness" min="1" :max="form.height" />
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="form.useStairs" />
|
||||
Use stairs (corner bevel)
|
||||
</label>
|
||||
|
||||
<label>
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Cell, Shape } from '../composables/useShapeGenerator'
|
||||
|
||||
const props = defineProps({
|
||||
shape: { type: Object, required: true },
|
||||
cellSize: { type: Number, default: 20 },
|
||||
})
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
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 svgWidth = computed(() => totalCols.value * props.cellSize)
|
||||
const svgHeight = computed(() => totalRows.value * props.cellSize)
|
||||
|
||||
const COLORS = {
|
||||
const COLORS: Record<Cell['type'], string> = {
|
||||
full: '#8a8f98',
|
||||
slab: '#e8a33d',
|
||||
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
|
||||
// SVG-space where y grows downward. "top"/"bottom" refer to the visual
|
||||
// top/bottom of the cell.
|
||||
function localRects(cell) {
|
||||
function localRects(cell: Cell): LocalRect[] {
|
||||
if (cell.type === 'full') {
|
||||
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 }]
|
||||
}
|
||||
// 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 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]
|
||||
}
|
||||
|
||||
const rects = computed(() => {
|
||||
const out = []
|
||||
const { columns, rowsBelow, rowsAbove } = props.shape
|
||||
const rows = rowsBelow + rowsAbove
|
||||
interface PixelRect extends LocalRect {
|
||||
fill: string
|
||||
}
|
||||
|
||||
const rects = computed<PixelRect[]>(() => {
|
||||
const out: PixelRect[] = []
|
||||
const { columns, height } = props.shape
|
||||
const size = props.cellSize
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const col = columns[i]
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let r = 0; r < height; r++) {
|
||||
const cell = col[r]
|
||||
if (!cell) continue
|
||||
const cellX = i * size
|
||||
const cellY = (rows - 1 - r) * size
|
||||
const cellY = (height - 1 - r) * size
|
||||
for (const rect of localRects(cell)) {
|
||||
out.push({
|
||||
x: cellX + rect.x * size,
|
||||
@@ -62,10 +76,9 @@ const rects = computed(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
// Faint grid outline for every cell (including empty ones) so the overall
|
||||
// bounding box of the shape is visible.
|
||||
const gridCells = computed(() => {
|
||||
const out = []
|
||||
// Faint grid outline for every cell so the overall bounding box is visible.
|
||||
const gridCells = computed<LocalRect[]>(() => {
|
||||
const out: LocalRect[] = []
|
||||
const rows = totalRows.value
|
||||
const size = props.cellSize
|
||||
for (let i = 0; i < totalCols.value; i++) {
|
||||
@@ -75,12 +88,6 @@ const gridCells = computed(() => {
|
||||
}
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -109,14 +116,6 @@ const groundY = computed(() => props.shape.rowsAbove > 0 || props.shape.rowsBelo
|
||||
:fill="r.fill"
|
||||
class="block-rect"
|
||||
/>
|
||||
<line
|
||||
v-if="shape.rowsBelow > 0"
|
||||
:x1="0"
|
||||
:x2="svgWidth"
|
||||
:y1="groundY"
|
||||
:y2="groundY"
|
||||
class="ground-line"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, unknown>
|
||||
export default component
|
||||
}
|
||||
Reference in New Issue
Block a user