first version
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Minecraft Arch / Ellipse Generator</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "arch-generator",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Minecraft ellipse/arch block generator - smoothed with slabs and stairs",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devEngines": {
|
||||||
|
"packageManager": {
|
||||||
|
"name": "pnpm",
|
||||||
|
"version": "^11.3.0",
|
||||||
|
"onFail": "download"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1038
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
+248
@@ -0,0 +1,248 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
import BlockCanvas from './components/BlockCanvas.vue'
|
||||||
|
import { generateShape, summarize } from './composables/useShapeGenerator.js'
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
width: 12,
|
||||||
|
height: 6,
|
||||||
|
mode: 'arch',
|
||||||
|
hollow: false,
|
||||||
|
thickness: 2,
|
||||||
|
cellSize: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MIN_SIZE = 2
|
||||||
|
const MAX_SIZE = 120
|
||||||
|
|
||||||
|
function clamp(v, lo, hi) {
|
||||||
|
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)
|
||||||
|
return generateShape({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
mode: form.mode,
|
||||||
|
hollow: form.hollow,
|
||||||
|
thickness: clamp(Math.round(form.thickness), 1, height),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stats = computed(() => summarize(shape.value))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="controls">
|
||||||
|
<label>
|
||||||
|
Width (blocks)
|
||||||
|
<input type="number" v-model.number="form.width" :min="MIN_SIZE" :max="MAX_SIZE" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Height (blocks)
|
||||||
|
<input type="number" v-model.number="form.height" :min="1" :max="MAX_SIZE" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Shape
|
||||||
|
<select v-model="form.mode">
|
||||||
|
<option value="arch">Arch (half ellipse, flat base)</option>
|
||||||
|
<option value="oval">Full oval (window / portal)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" v-model="form.hollow" />
|
||||||
|
Hollow (outline shell)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label v-if="form.hollow">
|
||||||
|
Wall thickness (blocks)
|
||||||
|
<input type="number" v-model.number="form.thickness" min="1" :max="form.height" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Zoom
|
||||||
|
<input type="range" v-model.number="form.cellSize" min="6" max="40" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-row"><span class="swatch full" /> Full block</div>
|
||||||
|
<div class="legend-row"><span class="swatch slab" /> Slab</div>
|
||||||
|
<div class="legend-row"><span class="swatch stair" /> Stair</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<h2>Shopping list</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Full blocks: <strong>{{ stats.full }}</strong></li>
|
||||||
|
<li>Slabs: <strong>{{ stats.slab }}</strong></li>
|
||||||
|
<li>
|
||||||
|
Stairs (facing left): <strong>{{ stats.stairOpenRight }}</strong>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Stairs (facing right): <strong>{{ stats.stairOpenLeft }}</strong>
|
||||||
|
</li>
|
||||||
|
<li class="total">Total blocks: <strong>{{ stats.total }}</strong></li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">
|
||||||
|
"Facing left/right" tells which side of the stair's step is open
|
||||||
|
(the beveled, outward corner) — mirror the two on opposite halves
|
||||||
|
of the curve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="canvas-wrap">
|
||||||
|
<BlockCanvas :shape="shape" :cell-size="form.cellSize" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: #1b1e24;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label.checkbox {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
background: #0f1115;
|
||||||
|
border: 1px solid #333844;
|
||||||
|
color: #e5e7eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
border-top: 1px solid #333844;
|
||||||
|
padding-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch.full {
|
||||||
|
background: #8a8f98;
|
||||||
|
}
|
||||||
|
.swatch.slab {
|
||||||
|
background: #e8a33d;
|
||||||
|
}
|
||||||
|
.swatch.stair {
|
||||||
|
background: #4fa3d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
border-top: 1px solid #333844;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats .total {
|
||||||
|
border-top: 1px solid #333844;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #8a8f98;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
background: #14161a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
shape: { type: Object, required: true },
|
||||||
|
cellSize: { type: Number, default: 20 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalRows = computed(() => props.shape.rowsBelow + props.shape.rowsAbove)
|
||||||
|
const totalCols = computed(() => props.shape.width)
|
||||||
|
|
||||||
|
const svgWidth = computed(() => totalCols.value * props.cellSize)
|
||||||
|
const svgHeight = computed(() => totalRows.value * props.cellSize)
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
full: '#8a8f98',
|
||||||
|
slab: '#e8a33d',
|
||||||
|
stair: '#4fa3d1',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (cell.type === 'full') {
|
||||||
|
return [{ x: 0, y: 0, w: 1, h: 1 }]
|
||||||
|
}
|
||||||
|
if (cell.type === 'slab') {
|
||||||
|
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 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 }
|
||||||
|
return [solidHalf, quadrant]
|
||||||
|
}
|
||||||
|
|
||||||
|
const rects = computed(() => {
|
||||||
|
const out = []
|
||||||
|
const { columns, rowsBelow, rowsAbove } = props.shape
|
||||||
|
const rows = rowsBelow + rowsAbove
|
||||||
|
const size = props.cellSize
|
||||||
|
for (let i = 0; i < columns.length; i++) {
|
||||||
|
const col = columns[i]
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const cell = col[r]
|
||||||
|
if (!cell) continue
|
||||||
|
const cellX = i * size
|
||||||
|
const cellY = (rows - 1 - r) * size
|
||||||
|
for (const rect of localRects(cell)) {
|
||||||
|
out.push({
|
||||||
|
x: cellX + rect.x * size,
|
||||||
|
y: cellY + rect.y * size,
|
||||||
|
w: rect.w * size,
|
||||||
|
h: rect.h * size,
|
||||||
|
fill: COLORS[cell.type],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 = []
|
||||||
|
const rows = totalRows.value
|
||||||
|
const size = props.cellSize
|
||||||
|
for (let i = 0; i < totalCols.value; i++) {
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
out.push({ x: i * size, y: (rows - 1 - r) * size, w: size, h: size })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<svg
|
||||||
|
:width="svgWidth"
|
||||||
|
:height="svgHeight"
|
||||||
|
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
|
||||||
|
class="block-canvas"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
v-for="(c, idx) in gridCells"
|
||||||
|
:key="'g' + idx"
|
||||||
|
:x="c.x"
|
||||||
|
:y="c.y"
|
||||||
|
:width="c.w"
|
||||||
|
:height="c.h"
|
||||||
|
class="grid-cell"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
v-for="(r, idx) in rects"
|
||||||
|
:key="'r' + idx"
|
||||||
|
:x="r.x"
|
||||||
|
:y="r.y"
|
||||||
|
:width="r.w"
|
||||||
|
:height="r.h"
|
||||||
|
: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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.block-canvas {
|
||||||
|
background: #1b1e24;
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.grid-cell {
|
||||||
|
fill: none;
|
||||||
|
stroke: #2f333c;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.block-rect {
|
||||||
|
stroke: rgba(0, 0, 0, 0.35);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
}
|
||||||
|
.ground-line {
|
||||||
|
stroke: #ef4444;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// 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,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #14161a;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user