feat: a working thingy
This commit is contained in:
@@ -1,12 +1,21 @@
|
|||||||
// Builds a Minecraft elevation-view (front-facing) block list for a wall
|
// 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
|
// panel with an arch- or oval-shaped opening cut out of it.
|
||||||
// slabs (half-block resolution) and stairs (beveled corners) instead of a
|
//
|
||||||
// fully jagged hole outline.
|
// 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).
|
// Coordinate system: columns run left->right (x), rows run bottom->up (y).
|
||||||
// The final shape always fills the whole `width x height` rectangle except
|
// The final shape always fills the whole `width x height` rectangle except
|
||||||
// for the opening; the opening's boundary cells are what carry the
|
// for the opening.
|
||||||
// smoothing detail.
|
|
||||||
|
|
||||||
export type CellType = 'full' | 'slab' | 'stair'
|
export type CellType = 'full' | 'slab' | 'stair'
|
||||||
export type Half = 'top' | 'bottom'
|
export type Half = 'top' | 'bottom'
|
||||||
@@ -42,176 +51,76 @@ export interface GenerateOptions {
|
|||||||
useStairs: boolean
|
useStairs: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const EPS = 1e-9
|
/** 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
|
||||||
* 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 {
|
/** 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. */
|
||||||
let n = 0
|
function rowCenterY(r: number, mode: ShapeMode, height: number): number {
|
||||||
for (const c of cells) if (c.type === 'full' || c.type === 'stair') n++
|
return mode === 'oval' ? r - (height - 1) / 2 : r + 0.5
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function classifyCell(tl: boolean, tr: boolean, bl: boolean, br: boolean, useSlabs: boolean, useStairs: boolean): Cell | null {
|
||||||
* Convert the outermost full block of a column into a stair whenever the
|
const count = (tl ? 1 : 0) + (tr ? 1 : 0) + (bl ? 1 : 0) + (br ? 1 : 0)
|
||||||
* 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 (count === 4) return { type: 'full' }
|
||||||
if (side === 0) continue
|
if (count <= 1) return null // not enough material to represent
|
||||||
|
|
||||||
const outwardIdx = side < 0 ? i - 1 : i + 1
|
if (count === 3) {
|
||||||
const outwardFull = outwardIdx >= 0 && outwardIdx < n ? countSolid(columns[outwardIdx]) : 0
|
if (!useStairs) return { type: 'full' }
|
||||||
const ownFull = countSolid(cells)
|
if (!tl) return { type: 'stair', half: 'bottom', openSide: 'left' }
|
||||||
|
if (!tr) return { type: 'stair', half: 'bottom', openSide: 'right' }
|
||||||
if (ownFull - outwardFull === 1) {
|
if (!bl) return { type: 'stair', half: 'top', openSide: 'left' }
|
||||||
top.type = 'stair'
|
return { type: 'stair', half: 'top', openSide: 'right' } // !br
|
||||||
top.half = dir === 'up' ? 'bottom' : 'top'
|
|
||||||
top.openSide = side < 0 ? 'left' : 'right'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HoleShape {
|
// count === 2
|
||||||
totalRows: number
|
if (tl && tr) return useSlabs ? { type: 'slab', half: 'top' } : { type: 'full' }
|
||||||
columns: Column[]
|
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' }
|
||||||
/** 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 {
|
export function generateShape(opts: GenerateOptions): Shape {
|
||||||
const width = Math.max(1, Math.round(opts.width))
|
const width = Math.max(1, Math.round(opts.width))
|
||||||
const height = Math.max(1, Math.round(opts.height))
|
const height = Math.max(1, Math.round(opts.height))
|
||||||
const hole = generateHoleShape(width, height, opts.mode, opts.useSlabs, opts.useStairs)
|
const { mode, useSlabs, useStairs } = opts
|
||||||
return invertToWall(hole, width, opts.mode === 'oval')
|
|
||||||
|
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 {
|
export interface Summary {
|
||||||
|
|||||||
Reference in New Issue
Block a user