<!-- Straight skeleton demo — canvas animation of inward-offset wavefront -->
<p class="hint">Each edge of the building footprint moves inward at constant speed. The paths traced by the corners are the <b>straight skeleton</b> — the ridge and valley lines of the hip roof above.</p>
<div class="controls">
<label for="preset-select">Footprint:</label>
<select id="preset-select">
<option value="rectangle">Rectangle</option>
<option value="lshape">L-shape</option>
<option value="pentagon">Pentagon</option>
<option value="tshape">T-shape</option>
</select>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="440" height="310"></canvas>
<div id="status" class="status"></div>
/* Layout: hint text, preset selector, canvas, status line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .85rem; color: #555; }
select { font: 14px system-ui, sans-serif; padding: .3rem .5rem; border: 1px solid #b0b8c4; border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; color: #1d3557; }
// Straight skeleton via iterated inward polygon offset (simplified, visual)
// Each vertex moves along its angle bisector direction — the key insight
// Preset footprint polygons (pixel coords, CCW winding)
const PRESETS = {
rectangle: [[60,240],[380,240],[380,60],[60,60]],
lshape: [[60,240],[240,240],[240,160],[380,160],[380,60],[60,60]],
pentagon: [[220,50],[380,160],[320,260],[120,260],[60,160]],
tshape: [[60,100],[380,100],[380,170],[260,170],[260,270],[180,270],[180,170],[60,170]]
};
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
let animId = null;
let skeletonEdges = [];
let footprintPoly = [];
// 2D vector helpers
function sub(a,b){ return {x:a.x-b.x, y:a.y-b.y}; }
function add(a,b){ return {x:a.x+b.x, y:a.y+b.y}; }
function scale(v,t){ return {x:v.x*t, y:v.y*t}; }
function len(v){ return Math.sqrt(v.x*v.x+v.y*v.y); }
function norm(v){ const l=len(v); return l<1e-9?{x:0,y:0}:{x:v.x/l,y:v.y/l}; }
function perp(v){ return {x:v.y,y:-v.x}; } // right-hand perpendicular (inward normal for CW polygon in canvas y-down coords)
// Compute the inward bisector direction at vertex curr
function bisectorDir(prev, curr, next){
const d1 = norm(sub(curr, prev));
const d2 = norm(sub(next, curr));
const n1 = perp(d1); // right-hand perpendicular = inward normal for CW winding in canvas coords
const n2 = perp(d2);
const bis = norm(add(n1, n2));
return bis;
}
// Offset every vertex of poly inward by distance t
function offsetPolygon(poly, t){
const n = poly.length;
const out = [];
for(let i=0;i<n;i++){
const prev = poly[(i-1+n)%n];
const curr = poly[i];
const next = poly[(i+1)%n];
const dir = bisectorDir(prev, curr, next);
out.push(add(curr, scale(dir, t)));
}
return out;
}
// Canvas drawing helpers
function drawPoly(poly, color, lineWidth){
if(poly.length < 2) return;
ctx.beginPath();
ctx.moveTo(poly[0].x, poly[0].y);
for(let i=1;i<poly.length;i++) ctx.lineTo(poly[i].x, poly[i].y);
ctx.closePath();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
function drawSegment(a, b, color, lineWidth){
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// Stop animation when polygon has collapsed (near-zero area)
function polyArea(poly){
let a=0;
const n=poly.length;
for(let i=0;i<n;i++){
const j=(i+1)%n;
a += poly[i].x*poly[j].y - poly[j].x*poly[i].y;
}
return Math.abs(a)/2;
}
function resetAnim(){
if(animId){ cancelAnimationFrame(animId); animId=null; }
}
function getSelectedPoly(){
const key = document.getElementById('preset-select').value;
return PRESETS[key].map(([x,y])=>({x,y}));
}
function drawScene(poly, t, edges){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw accumulated skeleton edges
for(const e of edges){
const hue = (e.idx * 47) % 360;
drawSegment(e.a, e.b, `hsl(${hue},70%,45%)`, 1.8);
}
// draw current wavefront polygon
if(poly.length >= 3) drawPoly(poly, '#1d3557', 2);
// draw original footprint (faint)
drawPoly(footprintPoly, '#9fb2c8', 1);
}
function runAnimation(){
resetAnim();
footprintPoly = getSelectedPoly();
skeletonEdges = [];
statusEl.textContent = '';
let poly = footprintPoly.map(p=>({...p}));
let t = 0;
const STEP = 1.5;
const MAX_T = 280;
let idx = 0;
function step(){
if(t > MAX_T || polyArea(poly) < 10){
drawScene([], t, skeletonEdges);
statusEl.textContent = 'Skeleton complete — these are the roof ridge lines.';
return;
}
const prevPoly = poly.map(p=>({...p}));
const nextPoly = offsetPolygon(poly, STEP);
// record skeleton edge for each vertex path segment
for(let i=0;i<prevPoly.length;i++){
skeletonEdges.push({a: prevPoly[i], b: nextPoly[i], idx: idx++});
}
poly = nextPoly;
t += STEP;
drawScene(poly, t, skeletonEdges);
animId = requestAnimationFrame(step);
}
step();
}
document.getElementById('btn-run').addEventListener('click', runAnimation);
document.getElementById('btn-reset').addEventListener('click', ()=>{
resetAnim();
skeletonEdges = [];
footprintPoly = getSelectedPoly();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPoly(footprintPoly, '#1d3557', 2);
statusEl.textContent = 'Press Run to animate the wavefront.';
});
// Draw initial footprint on load
footprintPoly = getSelectedPoly();
drawPoly(footprintPoly, '#1d3557', 2);
<!-- Straight skeleton demo — canvas animation of inward-offset wavefront -->
<p class="hint">Each edge of the building footprint moves inward at constant speed. The paths traced by the corners are the <b>straight skeleton</b> — the ridge and valley lines of the hip roof above.</p>
<div class="controls">
<label for="preset-select">Footprint:</label>
<select id="preset-select">
<option value="rectangle">Rectangle</option>
<option value="lshape">L-shape</option>
<option value="pentagon">Pentagon</option>
<option value="tshape">T-shape</option>
</select>
<button id="btn-run" type="button">Run</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<canvas id="canvas" width="440" height="310"></canvas>
<div id="status" class="status"></div>
/* Layout: hint text, preset selector, canvas, status line */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; margin-bottom: .6rem; }
label { font-size: .85rem; color: #555; }
select { font: 14px system-ui, sans-serif; padding: .3rem .5rem; border: 1px solid #b0b8c4; border-radius: 6px; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px; background: #f5f8fb; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.4em; color: #1d3557; }
// Straight skeleton via iterated inward polygon offset (simplified, visual)
// Each vertex moves along its angle bisector direction — the key insight
// Preset footprint polygons (pixel coords, CCW winding)
const PRESETS = {
rectangle: [[60,240],[380,240],[380,60],[60,60]],
lshape: [[60,240],[240,240],[240,160],[380,160],[380,60],[60,60]],
pentagon: [[220,50],[380,160],[320,260],[120,260],[60,160]],
tshape: [[60,100],[380,100],[380,170],[260,170],[260,270],[180,270],[180,170],[60,170]]
};
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const statusEl = document.getElementById('status');
let animId = null;
let skeletonEdges = [];
let footprintPoly = [];
// 2D vector helpers
function sub(a,b){ return {x:a.x-b.x, y:a.y-b.y}; }
function add(a,b){ return {x:a.x+b.x, y:a.y+b.y}; }
function scale(v,t){ return {x:v.x*t, y:v.y*t}; }
function len(v){ return Math.sqrt(v.x*v.x+v.y*v.y); }
function norm(v){ const l=len(v); return l<1e-9?{x:0,y:0}:{x:v.x/l,y:v.y/l}; }
function perp(v){ return {x:v.y,y:-v.x}; } // right-hand perpendicular (inward normal for CW polygon in canvas y-down coords)
// Compute the inward bisector direction at vertex curr
function bisectorDir(prev, curr, next){
const d1 = norm(sub(curr, prev));
const d2 = norm(sub(next, curr));
const n1 = perp(d1); // right-hand perpendicular = inward normal for CW winding in canvas coords
const n2 = perp(d2);
const bis = norm(add(n1, n2));
return bis;
}
// Offset every vertex of poly inward by distance t
function offsetPolygon(poly, t){
const n = poly.length;
const out = [];
for(let i=0;i<n;i++){
const prev = poly[(i-1+n)%n];
const curr = poly[i];
const next = poly[(i+1)%n];
const dir = bisectorDir(prev, curr, next);
out.push(add(curr, scale(dir, t)));
}
return out;
}
// Canvas drawing helpers
function drawPoly(poly, color, lineWidth){
if(poly.length < 2) return;
ctx.beginPath();
ctx.moveTo(poly[0].x, poly[0].y);
for(let i=1;i<poly.length;i++) ctx.lineTo(poly[i].x, poly[i].y);
ctx.closePath();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
function drawSegment(a, b, color, lineWidth){
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
// Stop animation when polygon has collapsed (near-zero area)
function polyArea(poly){
let a=0;
const n=poly.length;
for(let i=0;i<n;i++){
const j=(i+1)%n;
a += poly[i].x*poly[j].y - poly[j].x*poly[i].y;
}
return Math.abs(a)/2;
}
function resetAnim(){
if(animId){ cancelAnimationFrame(animId); animId=null; }
}
function getSelectedPoly(){
const key = document.getElementById('preset-select').value;
return PRESETS[key].map(([x,y])=>({x,y}));
}
function drawScene(poly, t, edges){
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw accumulated skeleton edges
for(const e of edges){
const hue = (e.idx * 47) % 360;
drawSegment(e.a, e.b, `hsl(${hue},70%,45%)`, 1.8);
}
// draw current wavefront polygon
if(poly.length >= 3) drawPoly(poly, '#1d3557', 2);
// draw original footprint (faint)
drawPoly(footprintPoly, '#9fb2c8', 1);
}
function runAnimation(){
resetAnim();
footprintPoly = getSelectedPoly();
skeletonEdges = [];
statusEl.textContent = '';
let poly = footprintPoly.map(p=>({...p}));
let t = 0;
const STEP = 1.5;
const MAX_T = 280;
let idx = 0;
function step(){
if(t > MAX_T || polyArea(poly) < 10){
drawScene([], t, skeletonEdges);
statusEl.textContent = 'Skeleton complete — these are the roof ridge lines.';
return;
}
const prevPoly = poly.map(p=>({...p}));
const nextPoly = offsetPolygon(poly, STEP);
// record skeleton edge for each vertex path segment
for(let i=0;i<prevPoly.length;i++){
skeletonEdges.push({a: prevPoly[i], b: nextPoly[i], idx: idx++});
}
poly = nextPoly;
t += STEP;
drawScene(poly, t, skeletonEdges);
animId = requestAnimationFrame(step);
}
step();
}
document.getElementById('btn-run').addEventListener('click', runAnimation);
document.getElementById('btn-reset').addEventListener('click', ()=>{
resetAnim();
skeletonEdges = [];
footprintPoly = getSelectedPoly();
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawPoly(footprintPoly, '#1d3557', 2);
statusEl.textContent = 'Press Run to animate the wavefront.';
});
// Draw initial footprint on load
footprintPoly = getSelectedPoly();
drawPoly(footprintPoly, '#1d3557', 2);
Comments
Loading comments...