Fillet concave V-notches in busbar preview

At every vertex (cell or waypoint) where two capsule segments meet, the union of the
rectangles and pad discs left a sharp inward V on the concave side. Canvas lineJoin
only smooths the convex side, so added an explicit tangent-arc region per concave
corner: intersect the inner offset lines to find the notch tip, place the arc center
on the angle bisector at radius padRadius, and fill the curved-triangle bounded by
the two tangent points and the arc. The added subpath unions into the existing fill.
This commit is contained in:
Maxim
2026-04-22 18:38:56 +02:00
parent 15388f681a
commit 5e010bd2b6

View File

@@ -19,6 +19,42 @@ function addCapsuleSubpath(ctx, x1, y1, x2, y2, r) {
ctx.restore(); ctx.restore();
} }
// Tangent-arc fillet on the concave side of the CCW sector between u1 and u2.
function addConcaveFillet(ctx, vx, vy, u1, u2, padR) {
const p1x = -u1[1], p1y = u1[0];
const p2x = u2[1], p2y = -u2[0];
const A1x = vx + padR * p1x, A1y = vy + padR * p1y;
const A2x = vx + padR * p2x, A2y = vy + padR * p2y;
const det = u1[0] * (-u2[1]) - (-u2[0]) * u1[1];
if (Math.abs(det) < 1e-9) return;
const dx = A2x - A1x, dy = A2y - A1y;
const t = ((-u2[1]) * dx - (-u2[0]) * dy) / det;
const Vnx = A1x + t * u1[0];
const Vny = A1y + t * u1[1];
const bsx = p1x + p2x, bsy = p1y + p2y;
const blen = Math.hypot(bsx, bsy);
if (blen < 1e-6) return;
const bx = bsx / blen, by = bsy / blen;
const sinHalf = blen / 2;
const r = padR;
const dist = r / sinHalf;
const cx = Vnx + dist * bx;
const cy = Vny + dist * by;
const T1x = cx - r * p1x, T1y = cy - r * p1y;
const T2x = cx - r * p2x, T2y = cy - r * p2y;
const angleT1 = Math.atan2(T1y - cy, T1x - cx);
const angleT2 = Math.atan2(T2y - cy, T2x - cx);
ctx.moveTo(Vnx, Vny);
ctx.lineTo(T1x, T1y);
ctx.arc(cx, cy, r, angleT1, angleT2, true);
ctx.lineTo(Vnx, Vny);
}
export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, padRadius, spacing, activeId) { export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, padRadius, spacing, activeId) {
const canvas = document.getElementById('preview'); const canvas = document.getElementById('preview');
if (!canvas) return; if (!canvas) return;
@@ -36,6 +72,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
ctx.scale(canvasState.zoom, canvasState.zoom); ctx.scale(canvasState.zoom, canvasState.zoom);
const zoom = canvasState.zoom; const zoom = canvasState.zoom;
const TWO_PI = 2 * Math.PI;
busbars.forEach((busbar, idx) => { busbars.forEach((busbar, idx) => {
const geom = geometries[idx]; const geom = geometries[idx];
@@ -45,20 +82,22 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
ctx.fillStyle = hexToRgba(busbar.color, fillAlpha); ctx.fillStyle = hexToRgba(busbar.color, fillAlpha);
ctx.beginPath(); ctx.beginPath();
for (const i of busbar.cellIndices) { for (const i of busbar.cellIndices) {
if (!positions[i]) continue; if (!positions[i]) continue;
const [x, y] = positions[i]; const [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y); const sx = toScreenX(x), sy = toScreenY(y);
ctx.moveTo(sx + padRadiusScreen, sy); ctx.moveTo(sx + padRadiusScreen, sy);
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2); ctx.arc(sx, sy, padRadiusScreen, 0, TWO_PI);
} }
for (const edge of geom.edges) { for (const edge of geom.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]]; const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
for (let k = 1; k < pts.length - 1; k++) { for (let k = 1; k < pts.length - 1; k++) {
const [wx, wy] = pts[k]; const [wx, wy] = pts[k];
const sx = toScreenX(wx), sy = toScreenY(wy); const sx = toScreenX(wx), sy = toScreenY(wy);
ctx.moveTo(sx + padRadiusScreen, sy); ctx.moveTo(sx + padRadiusScreen, sy);
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2); ctx.arc(sx, sy, padRadiusScreen, 0, TWO_PI);
} }
for (let k = 0; k < pts.length - 1; k++) { for (let k = 0; k < pts.length - 1; k++) {
addCapsuleSubpath( addCapsuleSubpath(
@@ -69,6 +108,43 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
); );
} }
} }
const vertMap = new Map();
const vkey = (sx, sy) => `${Math.round(sx * 100)},${Math.round(sy * 100)}`;
const addDir = (sx, sy, dx, dy) => {
const len = Math.hypot(dx, dy);
if (len < 1e-6) return;
const k = vkey(sx, sy);
if (!vertMap.has(k)) vertMap.set(k, { x: sx, y: sy, dirs: [] });
vertMap.get(k).dirs.push([dx / len, dy / len]);
};
for (const edge of geom.edges) {
const pts = [positions[edge.from], ...edge.waypoints, positions[edge.to]];
for (let k = 0; k < pts.length - 1; k++) {
const ax = toScreenX(pts[k][0]), ay = toScreenY(pts[k][1]);
const bx = toScreenX(pts[k + 1][0]), by = toScreenY(pts[k + 1][1]);
addDir(ax, ay, bx - ax, by - ay);
addDir(bx, by, ax - bx, ay - by);
}
}
for (const v of vertMap.values()) {
if (v.dirs.length < 2) continue;
const sorted = v.dirs.slice().sort((a, b) => Math.atan2(a[1], a[0]) - Math.atan2(b[1], b[0]));
for (let i = 0; i < sorted.length; i++) {
const u1 = sorted[i];
const u2 = sorted[(i + 1) % sorted.length];
const a1 = Math.atan2(u1[1], u1[0]);
const a2 = Math.atan2(u2[1], u2[0]);
let gap = a2 - a1;
if (gap <= 0) gap += TWO_PI;
if (gap > 0 && gap < Math.PI - 1e-3) {
addConcaveFillet(ctx, v.x, v.y, u1, u2, padRadiusScreen);
}
}
}
ctx.fill(); ctx.fill();
ctx.strokeStyle = hexToRgba(busbar.color, isActive ? 1.0 : 0.85); ctx.strokeStyle = hexToRgba(busbar.color, isActive ? 1.0 : 0.85);
@@ -78,7 +154,7 @@ export function drawBusbarsOverlay(busbars, geometries, positions, cellSize, pad
const [x, y] = positions[i]; const [x, y] = positions[i];
const sx = toScreenX(x), sy = toScreenY(y); const sx = toScreenX(x), sy = toScreenY(y);
ctx.beginPath(); ctx.beginPath();
ctx.arc(sx, sy, padRadiusScreen, 0, Math.PI * 2); ctx.arc(sx, sy, padRadiusScreen, 0, TWO_PI);
ctx.stroke(); ctx.stroke();
} }