diff --git a/src/index.html b/src/index.html index e905f37..37e7434 100644 --- a/src/index.html +++ b/src/index.html @@ -7606,8 +7606,13 @@ // Pure-string SVG inner content. Shape coordinates live in a 0–100 // viewBox so the same string scales to whatever bounds the surface uses. - function buildShapeSvgInner(shape, fill, stroke) { + // vbH = the viewBox height. The width is always 100; the height is scaled + // to the layer's aspect ratio so the 0–100×vbH viewBox maps to the layer + // box with a *uniform* scale — strokes stay even, corners stay circular. + function buildShapeSvgInner(shape, fill, stroke, vbH = 100) { if (!shape) return ''; + const H = Number(vbH) > 0 ? Number(vbH) : 100; + const cy = H / 2; const isGradient = fill?.type === 'linear-gradient' || fill?.type === 'radial-gradient'; const gradId = isGradient ? 'g' + Math.random().toString(36).slice(2, 9) : ''; const defs = isGradient ? `${buildGradientDef(gradId, fill)}` : ''; @@ -7616,49 +7621,48 @@ const kind = shape.kind; let body = ''; if (kind === 'rect') { - body = ``; + body = ``; } else if (kind === 'rounded-rect') { const r = Math.max(0, Math.min(50, Number(shape.cornerRadius) || 0)); - body = ``; + body = ``; } else if (kind === 'circle' || kind === 'ellipse') { - body = ``; + body = ``; } else if (kind === 'triangle') { - body = ``; + body = ``; } else if (kind === 'polygon') { const sides = Math.max(3, Math.min(20, Number(shape.sides) || 6)); const pts = []; for (let i = 0; i < sides; i++) { const angle = (i / sides) * Math.PI * 2 - Math.PI / 2; - pts.push(`${(50 + 50 * Math.cos(angle)).toFixed(2)},${(50 + 50 * Math.sin(angle)).toFixed(2)}`); + pts.push(`${(50 + 50 * Math.cos(angle)).toFixed(2)},${(cy + cy * Math.sin(angle)).toFixed(2)}`); } body = ``; } else if (kind === 'star') { const points = Math.max(3, Math.min(20, Number(shape.points) || 5)); - const inner = Math.max(0.05, Math.min(0.95, Number(shape.innerRadiusRatio) || 0.45)) * 50; - const outer = 50; + const ratio = Math.max(0.05, Math.min(0.95, Number(shape.innerRadiusRatio) || 0.45)); const pts = []; for (let i = 0; i < points * 2; i++) { const angle = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2; - const r = i % 2 === 0 ? outer : inner; - pts.push(`${(50 + r * Math.cos(angle)).toFixed(2)},${(50 + r * Math.sin(angle)).toFixed(2)}`); + const f = i % 2 === 0 ? 1 : ratio; + pts.push(`${(50 + f * 50 * Math.cos(angle)).toFixed(2)},${(cy + f * cy * Math.sin(angle)).toFixed(2)}`); } body = ``; } else if (kind === 'line') { const x1 = clamp01(Number(shape.x1) ?? 0, 0, 100); - const y1 = clamp01(Number(shape.y1) ?? 50, 0, 100); + const y1 = clamp01(Number(shape.y1) ?? 50, 0, 100) * H / 100; const x2 = clamp01(Number(shape.x2) ?? 100, 0, 100); - const y2 = clamp01(Number(shape.y2) ?? 50, 0, 100); + const y2 = clamp01(Number(shape.y2) ?? 50, 0, 100) * H / 100; const sw = Math.max(0.5, Math.min(50, Number(shape.strokeWidth) || 6)); // Line strokes use the fill paint (solid color or gradient). - body = ``; + body = ``; } else if (kind === 'path') { const d = String(shape.d || ''); if (d) { const vb = shape.viewBox || { x: 0, y: 0, w: 100, h: 100 }; const sx = vb.w > 0 ? 100 / vb.w : 1; - const sy = vb.h > 0 ? 100 / vb.h : 1; + const sy = vb.h > 0 ? H / vb.h : 1; // SVG transform applies right-to-left: translate the path's bbox to - // the origin, then scale so it fills the 0–100 shape space. + // the origin, then scale so it fills the 100×vbH shape space. body = ``; } } else if (kind === 'svg') { @@ -7670,7 +7674,7 @@ if (inner) { const vb = shape.viewBox || { x: 0, y: 0, w: 100, h: 100 }; const sx = vb.w > 0 ? 100 / vb.w : 1; - const sy = vb.h > 0 ? 100 / vb.h : 1; + const sy = vb.h > 0 ? H / vb.h : 1; body = `${inner}`; } } @@ -7689,11 +7693,17 @@ const masking = isClipSourceLayer(layer); const fill = masking ? { type: 'none' } : layer?.fill; const stroke = masking ? { type: 'none' } : layer?.stroke; - const inner = buildShapeSvgInner(layer?.shape, fill, stroke); + // viewBox height tracks the layer's aspect ratio so the viewBox→layer + // mapping is a uniform scale — strokes/corners don't squish on a + // non-square layer. + const lw = Number(layer?.w) > 0 ? Number(layer.w) : 1; + const lh = Number(layer?.h) > 0 ? Number(layer.h) : 1; + const vbH = 100 * lh / lw; + const inner = buildShapeSvgInner(layer?.shape, fill, stroke, vbH); // overflow:visible — a stroke is centered on the path edge, so half its - // width sits outside the 0–100 geometry. Without this the SVG viewport - // clips that outer half when the stroke is wide. - el.innerHTML = `${inner}`; + // width sits outside the geometry. Without this the SVG viewport clips + // that outer half when the stroke is wide. + el.innerHTML = `${inner}`; } // ---- Clip / mask plumbing (Phase E) ---- @@ -7714,7 +7724,10 @@ // Build an SVG path 'd' string for a clip shape. `scale` is the size of // the output coordinate range (1 for objectBoundingBox 0–1 fractional // coords used by CSS clip-path; 100 for the canvas 0–100 viewBox space). - function clipShapeToPathD(shape, scale = 100) { + // clipW/clipH: the aspect of the layer being clipped. The clip path is + // applied via objectBoundingBox so it stretches to that layer's box — + // rounded-rect corner radii are pre-distorted here to stay circular. + function clipShapeToPathD(shape, scale = 100, clipW, clipH) { if (!shape) return ''; const s = scale; const half = s / 2; @@ -7724,7 +7737,12 @@ const r0 = Math.max(0, Math.min(50, Number(shape.cornerRadius) || 0)); const r = (r0 / 100) * s; if (r <= 0) return `M 0 0 L ${s} 0 L ${s} ${s} L 0 ${s} Z`; - return `M ${r} 0 L ${s - r} 0 A ${r} ${r} 0 0 1 ${s} ${r} L ${s} ${s - r} A ${r} ${r} 0 0 1 ${s - r} ${s} L ${r} ${s} A ${r} ${r} 0 0 1 0 ${s - r} L 0 ${r} A ${r} ${r} 0 0 1 ${r} 0 Z`; + const lw = Number(clipW) > 0 ? Number(clipW) : 1; + const lh = Number(clipH) > 0 ? Number(clipH) : 1; + const m = Math.min(lw, lh); + const rx = Math.min(half, r * m / lw); + const ry = Math.min(half, r * m / lh); + return `M ${rx} 0 L ${s - rx} 0 A ${rx} ${ry} 0 0 1 ${s} ${ry} L ${s} ${s - ry} A ${rx} ${ry} 0 0 1 ${s - rx} ${s} L ${rx} ${s} A ${rx} ${ry} 0 0 1 0 ${s - ry} L 0 ${ry} A ${rx} ${ry} 0 0 1 ${rx} 0 Z`; } if (k === 'circle' || k === 'ellipse') { // Two-arc circle so the path closes correctly at every scale. @@ -7849,7 +7867,7 @@ // valid child and browsers drop it (so the clip fails). dInner = ``; } else { - const d = clipShapeToPathD(src, 1); + const d = clipShapeToPathD(src, 1, layer.w, layer.h); if (!d) continue; dInner = ``; } @@ -8049,17 +8067,21 @@ closeShapePathModal(); } - function roundedRectPath(ctx, x, y, w, h, r) { - r = Math.max(0, Math.min(r, w / 2, h / 2)); - ctx.moveTo(x + r, y); - ctx.lineTo(x + w - r, y); - ctx.arcTo(x + w, y, x + w, y + r, r); - ctx.lineTo(x + w, y + h - r); - ctx.arcTo(x + w, y + h, x + w - r, y + h, r); - ctx.lineTo(x + r, y + h); - ctx.arcTo(x, y + h, x, y + h - r, r); - ctx.lineTo(x, y + r); - ctx.arcTo(x, y, x + r, y, r); + // Rounded-rect path with independent corner radii (rx/ry) so corners can + // be pre-distorted to render circular under a non-uniform scale. + function roundedRectPath(ctx, x, y, w, h, rx, ry) { + rx = Math.max(0, Math.min(rx, w / 2)); + ry = Math.max(0, Math.min(ry, h / 2)); + const HP = Math.PI / 2; + ctx.moveTo(x + rx, y); + ctx.lineTo(x + w - rx, y); + ctx.ellipse(x + w - rx, y + ry, rx, ry, 0, -HP, 0); + ctx.lineTo(x + w, y + h - ry); + ctx.ellipse(x + w - rx, y + h - ry, rx, ry, 0, 0, HP); + ctx.lineTo(x + rx, y + h); + ctx.ellipse(x + rx, y + h - ry, rx, ry, 0, HP, Math.PI); + ctx.lineTo(x, y + ry); + ctx.ellipse(x + rx, y + ry, rx, ry, 0, Math.PI, Math.PI + HP); ctx.closePath(); } @@ -8118,9 +8140,12 @@ const fill = masking ? { type: 'none' } : layer?.fill; if (!shape) return; ctx.save(); - // Map the 0–100 shape viewBox to (-w/2..w/2, -h/2..h/2) pixel space. + // Map a 100×vbH viewBox (aspect-matched to the layer) into the layer + // box with a uniform scale, so strokes and corners don't squish. + const vbH = 100 * h / (w || 1); + const cy = vbH / 2; ctx.translate(-w / 2, -h / 2); - ctx.scale(w / 100, h / 100); + ctx.scale(w / 100, w / 100); const paint = buildCanvasFillStyle(ctx, fill); const kind = shape.kind; @@ -8131,8 +8156,8 @@ const y2 = clamp01(Number(shape.y2) ?? 50, 0, 100); const sw = Math.max(0.5, Math.min(50, Number(shape.strokeWidth) || 6)); ctx.beginPath(); - ctx.moveTo(x1, y1); - ctx.lineTo(x2, y2); + ctx.moveTo(x1, y1 * vbH / 100); + ctx.lineTo(x2, y2 * vbH / 100); ctx.strokeStyle = paint || '#000'; ctx.lineWidth = sw; ctx.lineCap = 'round'; @@ -8148,7 +8173,7 @@ const d = String(shape.d || ''); if (!d) { ctx.restore(); return; } const vb = shape.viewBox || { x: 0, y: 0, w: 100, h: 100 }; - ctx.scale(vb.w > 0 ? 100 / vb.w : 1, vb.h > 0 ? 100 / vb.h : 1); + ctx.scale(vb.w > 0 ? 100 / vb.w : 1, vb.h > 0 ? vbH / vb.h : 1); ctx.translate(-vb.x, -vb.y); let p2d; try { p2d = new Path2D(d); } catch { ctx.restore(); return; } @@ -8169,35 +8194,35 @@ ctx.beginPath(); if (kind === 'rect') { - ctx.rect(0, 0, 100, 100); + ctx.rect(0, 0, 100, vbH); } else if (kind === 'rounded-rect') { const r = Math.max(0, Math.min(50, Number(shape.cornerRadius) || 0)); - roundedRectPath(ctx, 0, 0, 100, 100, r); + // Uniform scale now, so equal rx/ry render as circular corners. + roundedRectPath(ctx, 0, 0, 100, vbH, r, r); } else if (kind === 'circle' || kind === 'ellipse') { - ctx.ellipse(50, 50, 50, 50, 0, 0, Math.PI * 2); + ctx.ellipse(50, cy, 50, cy, 0, 0, Math.PI * 2); } else if (kind === 'triangle') { ctx.moveTo(50, 0); - ctx.lineTo(100, 100); - ctx.lineTo(0, 100); + ctx.lineTo(100, vbH); + ctx.lineTo(0, vbH); ctx.closePath(); } else if (kind === 'polygon') { const sides = Math.max(3, Math.min(20, Number(shape.sides) || 6)); for (let i = 0; i < sides; i++) { const angle = (i / sides) * Math.PI * 2 - Math.PI / 2; const px = 50 + 50 * Math.cos(angle); - const py = 50 + 50 * Math.sin(angle); + const py = cy + cy * Math.sin(angle); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); } else if (kind === 'star') { const points = Math.max(3, Math.min(20, Number(shape.points) || 5)); - const inner = Math.max(0.05, Math.min(0.95, Number(shape.innerRadiusRatio) || 0.45)) * 50; - const outer = 50; + const ratio = Math.max(0.05, Math.min(0.95, Number(shape.innerRadiusRatio) || 0.45)); for (let i = 0; i < points * 2; i++) { const angle = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2; - const r = i % 2 === 0 ? outer : inner; - const px = 50 + r * Math.cos(angle); - const py = 50 + r * Math.sin(angle); + const f = i % 2 === 0 ? 1 : ratio; + const px = 50 + f * 50 * Math.cos(angle); + const py = cy + f * cy * Math.sin(angle); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); @@ -10392,11 +10417,7 @@ // the layer's draw region. const clipSrc = resolveClipSourceShape(layer, state.layers); if (clipSrc) { - let clipD = clipShapeToPathD(clipSrc, 100); - if (clipSrc.kind === 'path') { - // Path shapes are stored in their own viewBox; first scale that - // into 0–100 manually via a sub-matrix. - } + let clipD = clipShapeToPathD(clipSrc, 100, layer.w, layer.h); if (clipD) { try { const matrix = new DOMMatrix().translate(-w / 2, -h / 2).scale(w / 100, h / 100);