The demo below runs a discrete grassfire on a pixel grid. Click {{btn_burn_label}} to ignite the boundary and watch wave-fronts spread inward. Pixels where two or more fronts collide at the same time are painted as the medial axis — the skeleton.
<!-- Medial axis demo: grassfire burn on a pixel grid -->
<div class="toolbar">
<span class="label">Shape:</span>
<button id="btn-h" type="button">H</button>
<button id="btn-ring" type="button">Ring</button>
<button id="btn-star" type="button">Star</button>
<button id="btn-rect" type="button">Rectangle</button>
</div>
<canvas id="cv" width="300" height="300" title="Grassfire simulation canvas"></canvas>
<div class="toolbar">
<button id="btn-burn" type="button">Burn inward</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div id="status" class="status">Choose a shape and press Burn inward.</div>
/* Styles for the medial axis grassfire demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.toolbar { display: flex; gap: .4rem; align-items: center; margin: .4rem 0; flex-wrap: wrap; }
.label { font-size: .85rem; color: #555; margin-right: .2rem; }
button { font: 600 13px system-ui; padding: .38rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #e63946; border-color: #c92f3c; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px;
image-rendering: pixelated; max-width: 300px; }
.status { font-size: .9rem; font-weight: 600; margin: .4rem 0; min-height: 1.3em; color: #1d3557; }
.status.done { color: #0a7d33; }
// Medial axis via discrete grassfire (BFS from boundary pixels)
// Cell state constants
const C_EMPTY = 0; // outside the shape
const C_FILLED = 1; // interior, not yet burned
const C_BOUNDARY = 2; // boundary pixels (fire starts here)
const C_AXIS = 3; // medial axis pixel (two wavefronts met)
const C_WAVE = 4; // burned interior, not on axis
const PALETTE = ['#f0f4f8','#1d3557','#e63946','#f4a261','#a8dadc'];
// empty, filled, boundary, axis (orange), wave (teal)
const W = 60, H = 60; // grid size in pixels
let grid = new Uint8Array(W * H);
let animId = null;
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
// Shape drawing functions — fill interior pixels with C_FILLED
function drawH() {
grid.fill(C_EMPTY);
// left vertical stroke
for (let r = 10; r < 50; r++) for (let c = 8; c < 22; c++) grid[r*W+c] = C_FILLED;
// right vertical stroke
for (let r = 10; r < 50; r++) for (let c = 38; c < 52; c++) grid[r*W+c] = C_FILLED;
// horizontal crossbar
for (let r = 27; r < 33; r++) for (let c = 8; c < 52; c++) grid[r*W+c] = C_FILLED;
}
function drawRing() {
grid.fill(C_EMPTY);
const cx = 30, cy = 30, ro = 22, ri = 14;
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
const d2 = (r-cy)*(r-cy)+(c-cx)*(c-cx);
if (d2 <= ro*ro && d2 >= ri*ri) grid[r*W+c] = C_FILLED;
}
}
function drawStar() {
// 5-pointed star using polar interpolation
grid.fill(C_EMPTY);
const cx = 30, cy = 30, spokes = 5, R = 24, r = 10;
for (let row = 0; row < H; row++) for (let col = 0; col < W; col++) {
const dx = col - cx, dy = row - cy;
const ang = Math.atan2(dy, dx);
const dist = Math.sqrt(dx*dx + dy*dy);
// interpolate radius between inner and outer for each spoke sector
let inStar = false;
for (let i = 0; i < spokes; i++) {
const a0 = (2*Math.PI*i)/spokes - Math.PI/2;
const a1 = (2*Math.PI*(i+0.5))/spokes - Math.PI/2;
const a2 = (2*Math.PI*(i+1))/spokes - Math.PI/2;
// fraction along this sector: 0=spoke tip, 1=next tip
let t = (ang - a0) / (a2 - a0);
if (t < 0) t += 1; if (t > 1) t -= 1;
const rEdge = t <= 0.5 ? r + (R-r)*(t/0.5) : R - (R-r)*((t-0.5)/0.5);
if (dist <= rEdge + 1) inStar = true;
}
if (inStar) grid[row*W+col] = C_FILLED;
}
}
function drawRect() {
grid.fill(C_EMPTY);
for (let r = 10; r < 50; r++) for (let c = 8; c < 52; c++) grid[r*W+c] = C_FILLED;
}
// Mark boundary: filled cells adjacent to empty are C_BOUNDARY
function markBoundary() {
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
if (grid[r*W+c] !== C_FILLED) continue;
const nb = [[r-1,c],[r+1,c],[r,c-1],[r,c+1]];
if (nb.some(([nr,nc]) => nr<0||nr>=H||nc<0||nc>=W||grid[nr*W+nc]===C_EMPTY))
grid[r*W+c] = C_BOUNDARY;
}
}
// Render grid to canvas pixel-by-pixel
function render() {
const img = ctx.createImageData(W, H);
const hex = (h) => [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
const colors = PALETTE.map(hex);
for (let i = 0; i < W*H; i++) {
const [rr,gg,bb] = colors[grid[i]];
img.data[i*4]=rr; img.data[i*4+1]=gg; img.data[i*4+2]=bb; img.data[i*4+3]=255;
}
ctx.putImageData(img, 0, 0);
}
// Grassfire / BFS wavefront propagation
// Pixels are colored by the time their wavefront arrives; collisions mark the axis.
function startBurn() {
if (animId) cancelAnimationFrame(animId);
// distance (in BFS steps) from the boundary for each pixel
const dist = new Int16Array(W * H).fill(-1);
const queue = [];
// seed the queue with all boundary pixels at distance 0
for (let i = 0; i < W*H; i++) {
if (grid[i] === C_BOUNDARY) { dist[i] = 0; queue.push(i); }
}
// BFS through interior pixels; record traversal order for animation
const order = [];
let head = 0;
while (head < queue.length) {
const i = queue[head++];
const r = Math.floor(i / W), c = i % W;
const nb = [];
if (r>0) nb.push(i-W);
if (r<H-1) nb.push(i+W);
if (c>0) nb.push(i-1);
if (c<W-1) nb.push(i+1);
for (const j of nb) {
if (grid[j] === C_FILLED && dist[j] === -1) {
dist[j] = dist[i] + 1;
queue.push(j);
order.push(j);
}
}
}
// Detect axis pixels: a pixel is on the axis if a neighbor has the same distance
// i.e. two wavefronts arrived at the same time from different directions
const isAxis = new Uint8Array(W * H);
for (const i of order) {
const d = dist[i];
// A pixel is on the medial axis if no cardinal neighbor has a strictly greater distance (local max)
const cardinal = [i-W,i+W,i-1,i+1];
let isLocalMax = true;
for (const j of cardinal) {
if (j>=0 && j<W*H && dist[j] > d) { isLocalMax = false; break; }
}
isAxis[i] = isLocalMax ? 1 : 0;
}
// Animate the burn frame by frame
let step = 0;
const CHUNK = 6; // pixels to color per frame
const status = document.getElementById('status');
status.textContent = 'Burning…';
status.className = 'status';
function frame() {
for (let k = 0; k < CHUNK && step < order.length; k++, step++) {
const i = order[step];
grid[i] = isAxis[i] ? C_AXIS : C_WAVE;
}
render();
if (step < order.length) {
animId = requestAnimationFrame(frame);
} else {
status.textContent = 'Done — orange pixels are the medial axis skeleton.';
status.className = 'status done';
}
}
animId = requestAnimationFrame(frame);
}
// Preset button wiring and initial draw
let currentShape = 'h';
function setShape(id, fn) {
if (animId) { cancelAnimationFrame(animId); animId = null; }
currentShape = id;
fn();
markBoundary();
render();
document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
document.getElementById('status').className = 'status';
document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
document.getElementById('btn-'+id).classList.add('active');
}
document.getElementById('btn-h').addEventListener('click', () => setShape('h', drawH));
document.getElementById('btn-ring').addEventListener('click', () => setShape('ring', drawRing));
document.getElementById('btn-star').addEventListener('click', () => setShape('star', drawStar));
document.getElementById('btn-rect').addEventListener('click', () => setShape('rect', drawRect));
document.getElementById('btn-burn').addEventListener('click', startBurn);
document.getElementById('btn-reset').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
drawH(); markBoundary(); render();
currentShape = 'h';
document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
document.getElementById('status').className = 'status';
document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
document.getElementById('btn-h').classList.add('active');
});
// Initial render
drawH();
markBoundary();
render();
document.getElementById('btn-h').classList.add('active');
// Medial axis via discrete grassfire (BFS from boundary pixels)
// Cell state constants
const C_EMPTY = 0; // outside the shape
const C_FILLED = 1; // interior, not yet burned
const C_BOUNDARY = 2; // boundary pixels (fire starts here)
const C_AXIS = 3; // medial axis pixel (two wavefronts met)
const C_WAVE = 4; // burned interior, not on axis
const PALETTE = ['#f0f4f8','#1d3557','#e63946','#f4a261','#a8dadc'];
// empty, filled, boundary, axis (orange), wave (teal)
const W = 60, H = 60; // grid size in pixels
let grid = new Uint8Array(W * H);
let animId = null;
const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');
// Shape drawing functions — fill interior pixels with C_FILLED
function drawH() {
grid.fill(C_EMPTY);
// left vertical stroke
for (let r = 10; r < 50; r++) for (let c = 8; c < 22; c++) grid[r*W+c] = C_FILLED;
// right vertical stroke
for (let r = 10; r < 50; r++) for (let c = 38; c < 52; c++) grid[r*W+c] = C_FILLED;
// horizontal crossbar
for (let r = 27; r < 33; r++) for (let c = 8; c < 52; c++) grid[r*W+c] = C_FILLED;
}
function drawRing() {
grid.fill(C_EMPTY);
const cx = 30, cy = 30, ro = 22, ri = 14;
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
const d2 = (r-cy)*(r-cy)+(c-cx)*(c-cx);
if (d2 <= ro*ro && d2 >= ri*ri) grid[r*W+c] = C_FILLED;
}
}
function drawStar() {
// 5-pointed star using polar interpolation
grid.fill(C_EMPTY);
const cx = 30, cy = 30, spokes = 5, R = 24, r = 10;
for (let row = 0; row < H; row++) for (let col = 0; col < W; col++) {
const dx = col - cx, dy = row - cy;
const ang = Math.atan2(dy, dx);
const dist = Math.sqrt(dx*dx + dy*dy);
// interpolate radius between inner and outer for each spoke sector
let inStar = false;
for (let i = 0; i < spokes; i++) {
const a0 = (2*Math.PI*i)/spokes - Math.PI/2;
const a1 = (2*Math.PI*(i+0.5))/spokes - Math.PI/2;
const a2 = (2*Math.PI*(i+1))/spokes - Math.PI/2;
// fraction along this sector: 0=spoke tip, 1=next tip
let t = (ang - a0) / (a2 - a0);
if (t < 0) t += 1; if (t > 1) t -= 1;
const rEdge = t <= 0.5 ? r + (R-r)*(t/0.5) : R - (R-r)*((t-0.5)/0.5);
if (dist <= rEdge + 1) inStar = true;
}
if (inStar) grid[row*W+col] = C_FILLED;
}
}
function drawRect() {
grid.fill(C_EMPTY);
for (let r = 10; r < 50; r++) for (let c = 8; c < 52; c++) grid[r*W+c] = C_FILLED;
}
// Mark boundary: filled cells adjacent to empty are C_BOUNDARY
function markBoundary() {
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
if (grid[r*W+c] !== C_FILLED) continue;
const nb = [[r-1,c],[r+1,c],[r,c-1],[r,c+1]];
if (nb.some(([nr,nc]) => nr<0||nr>=H||nc<0||nc>=W||grid[nr*W+nc]===C_EMPTY))
grid[r*W+c] = C_BOUNDARY;
}
}
// Render grid to canvas pixel-by-pixel
function render() {
const img = ctx.createImageData(W, H);
const hex = (h) => [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
const colors = PALETTE.map(hex);
for (let i = 0; i < W*H; i++) {
const [rr,gg,bb] = colors[grid[i]];
img.data[i*4]=rr; img.data[i*4+1]=gg; img.data[i*4+2]=bb; img.data[i*4+3]=255;
}
ctx.putImageData(img, 0, 0);
}
// Grassfire / BFS wavefront propagation
// Pixels are colored by the time their wavefront arrives; collisions mark the axis.
function startBurn() {
if (animId) cancelAnimationFrame(animId);
// distance (in BFS steps) from the boundary for each pixel
const dist = new Int16Array(W * H).fill(-1);
const queue = [];
// seed the queue with all boundary pixels at distance 0
for (let i = 0; i < W*H; i++) {
if (grid[i] === C_BOUNDARY) { dist[i] = 0; queue.push(i); }
}
// BFS through interior pixels; record traversal order for animation
const order = [];
let head = 0;
while (head < queue.length) {
const i = queue[head++];
const r = Math.floor(i / W), c = i % W;
const nb = [];
if (r>0) nb.push(i-W);
if (r<H-1) nb.push(i+W);
if (c>0) nb.push(i-1);
if (c<W-1) nb.push(i+1);
for (const j of nb) {
if (grid[j] === C_FILLED && dist[j] === -1) {
dist[j] = dist[i] + 1;
queue.push(j);
order.push(j);
}
}
}
// Detect axis pixels: a pixel is on the axis if a neighbor has the same distance
// i.e. two wavefronts arrived at the same time from different directions
const isAxis = new Uint8Array(W * H);
for (const i of order) {
const d = dist[i];
// A pixel is on the medial axis if no cardinal neighbor has a strictly greater distance (local max)
const cardinal = [i-W,i+W,i-1,i+1];
let isLocalMax = true;
for (const j of cardinal) {
if (j>=0 && j<W*H && dist[j] > d) { isLocalMax = false; break; }
}
isAxis[i] = isLocalMax ? 1 : 0;
}
// Animate the burn frame by frame
let step = 0;
const CHUNK = 6; // pixels to color per frame
const status = document.getElementById('status');
status.textContent = 'Burning…';
status.className = 'status';
function frame() {
for (let k = 0; k < CHUNK && step < order.length; k++, step++) {
const i = order[step];
grid[i] = isAxis[i] ? C_AXIS : C_WAVE;
}
render();
if (step < order.length) {
animId = requestAnimationFrame(frame);
} else {
status.textContent = 'Done — orange pixels are the medial axis skeleton.';
status.className = 'status done';
}
}
animId = requestAnimationFrame(frame);
}
// Preset button wiring and initial draw
let currentShape = 'h';
function setShape(id, fn) {
if (animId) { cancelAnimationFrame(animId); animId = null; }
currentShape = id;
fn();
markBoundary();
render();
document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
document.getElementById('status').className = 'status';
document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
document.getElementById('btn-'+id).classList.add('active');
}
document.getElementById('btn-h').addEventListener('click', () => setShape('h', drawH));
document.getElementById('btn-ring').addEventListener('click', () => setShape('ring', drawRing));
document.getElementById('btn-star').addEventListener('click', () => setShape('star', drawStar));
document.getElementById('btn-rect').addEventListener('click', () => setShape('rect', drawRect));
document.getElementById('btn-burn').addEventListener('click', startBurn);
document.getElementById('btn-reset').addEventListener('click', () => {
if (animId) { cancelAnimationFrame(animId); animId = null; }
drawH(); markBoundary(); render();
currentShape = 'h';
document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
document.getElementById('status').className = 'status';
document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
document.getElementById('btn-h').classList.add('active');
});
// Initial render
drawH();
markBoundary();
render();
document.getElementById('btn-h').classList.add('active');
Comments
Loading comments...