diff --git a/src/composables/useShapeGenerator.ts b/src/composables/useShapeGenerator.ts index 77e2ea5..412abf5 100644 --- a/src/composables/useShapeGenerator.ts +++ b/src/composables/useShapeGenerator.ts @@ -1,12 +1,21 @@ // 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. +// panel with an arch- or oval-shaped opening cut out of it. +// +// Each block is sub-sampled at 4 quarter-points (splitting the block into a +// 2x2 grid) against the true ellipse boundary, so the resulting shape is +// derived directly from geometry rather than from an ad-hoc comparison +// between neighboring columns: +// 4/4 quarters solid -> full block +// 3/4 solid -> stair (the empty corner picks one of the 4 +// orientations - including upside-down ones) +// 2/4 solid, split top/bottom -> slab (top or bottom) +// 2/4 solid, split left/right or diagonally -> no clean single-block +// shape; defaults to a full block +// 0-1/4 solid -> air (too little material to represent) // // 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. +// for the opening. export type CellType = 'full' | 'slab' | 'stair' export type Half = 'top' | 'bottom' @@ -42,176 +51,76 @@ export interface GenerateOptions { 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 +/** True when (x, y) falls inside the elliptical opening. */ +function insideOpening(x: number, y: number, a: number, b: number): boolean { + return (x / a) ** 2 + (y / b) ** 2 <= 1 } -function countSolid(cells: Cell[]): number { - let n = 0 - for (const c of cells) if (c.type === 'full' || c.type === 'stair') n++ - return n +/** Continuous y-coordinate of a row's center: arch shapes sit on the ground (y=0 at the bottom edge), oval shapes are centered vertically. */ +function rowCenterY(r: number, mode: ShapeMode, height: number): number { + return mode === 'oval' ? r - (height - 1) / 2 : r + 0.5 } -/** - * 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 +function classifyCell(tl: boolean, tr: boolean, bl: boolean, br: boolean, useSlabs: boolean, useStairs: boolean): Cell | null { + const count = (tl ? 1 : 0) + (tr ? 1 : 0) + (bl ? 1 : 0) + (br ? 1 : 0) - const side = centerSide(i) - if (side === 0) continue + if (count === 4) return { type: 'full' } + if (count <= 1) return null // not enough material to represent - 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)) + if (count === 3) { + if (!useStairs) return { type: 'full' } + if (!tl) return { type: 'stair', half: 'bottom', openSide: 'left' } + if (!tr) return { type: 'stair', half: 'bottom', openSide: 'right' } + if (!bl) return { type: 'stair', half: 'top', openSide: 'left' } + return { type: 'stair', half: 'top', openSide: 'right' } // !br } - 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 } + // count === 2 + if (tl && tr) return useSlabs ? { type: 'slab', half: 'top' } : { type: 'full' } + if (bl && br) return useSlabs ? { type: 'slab', half: 'bottom' } : { type: 'full' } + // left/right split or diagonal split: no single-block shape represents this cleanly + return { type: 'full' } } 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') + const { mode, useSlabs, useStairs } = opts + + const a = width / 2 + const b = mode === 'oval' ? height / 2 : height + const centerOffsetX = (width - 1) / 2 + const useSubSampling = useSlabs || useStairs + + const columns: Column[] = [] + for (let i = 0; i < width; i++) { + const cx = i - centerOffsetX + const col: Column = [] + + for (let r = 0; r < height; r++) { + const cy = rowCenterY(r, mode, height) + + if (!useSubSampling) { + col.push(insideOpening(cx, cy, a, b) ? null : { type: 'full' }) + continue + } + + const leftX = cx - 0.25 + const rightX = cx + 0.25 + const bottomY = cy - 0.25 + const topY = cy + 0.25 + + const tl = !insideOpening(leftX, topY, a, b) + const tr = !insideOpening(rightX, topY, a, b) + const bl = !insideOpening(leftX, bottomY, a, b) + const br = !insideOpening(rightX, bottomY, a, b) + + col.push(classifyCell(tl, tr, bl, br, useSlabs, useStairs)) + } + columns.push(col) + } + + return { width, height, columns } } export interface Summary {