Use Path to click-drag a line; balloons will be auto-placed along it.
diff --git a/organic.js b/organic.js
index ff72725..4a28cb1 100644
--- a/organic.js
+++ b/organic.js
@@ -107,6 +107,31 @@
};
const QUERY_KEY = 'd';
+ const BALLOON_MASK_URL = 'images/balloon-mask.svg';
+ const BALLOON_24_MASK_URL = 'images/24-balloon_mask.svg';
+ const WEIGHT_MASK_URL = 'images/weight-mask.svg';
+ const WEIGHT_IMAGE_URL = 'images/weight.webp';
+ const WEIGHT_VISUAL_SCALE = 0.56;
+ const HELIUM_CUFT_BY_SIZE = { 11: 0.5, 18: 2, 24: 5 };
+ const HELIUM_CUFT_BASE_SIZE = 11;
+ const HELIUM_CUFT_BASE_VALUE = 0.5;
+ let balloonMaskPath = null;
+ let balloonMaskPathData = '';
+ let balloonMaskViewBox = { x: 0, y: 0, w: 1, h: 1 };
+ let balloonMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ let balloonMaskLoaded = false;
+ let balloonMaskLoading = false;
+ let balloon24MaskPath = null;
+ let balloon24MaskPathData = '';
+ let balloon24MaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ let balloon24MaskLoaded = false;
+ let balloon24MaskLoading = false;
+ let balloonMaskDrawFailed = false;
+ let weightMaskPath = null;
+ let weightMaskPathData = '';
+ let weightMaskBounds = { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ let weightMaskLoaded = false;
+ let weightMaskLoading = false;
// ====== DOM ======
const canvas = document.getElementById('balloon-canvas');
@@ -134,6 +159,12 @@
const nudgeSelectedBtns = Array.from(document.querySelectorAll('.nudge-selected'));
const bringForwardBtn = document.getElementById('bring-forward');
const sendBackwardBtn = document.getElementById('send-backward');
+ const rotateSelectedLeftBtn = document.getElementById('rotate-selected-left');
+ const rotateSelectedResetBtn = document.getElementById('rotate-selected-reset');
+ const rotateSelectedRightBtn = document.getElementById('rotate-selected-right');
+ const ribbonLengthDownBtn = document.getElementById('ribbon-length-down');
+ const ribbonLengthUpBtn = document.getElementById('ribbon-length-up');
+ const ribbonAttachWeightBtn = document.getElementById('ribbon-attach-weight');
const applyColorBtn = document.getElementById('apply-selected-color');
const fitViewBtn = document.getElementById('fit-view-btn');
const garlandDensityInput = document.getElementById('garland-density');
@@ -152,6 +183,11 @@
const updateGarlandSwatches = () => {}; // stub for layouts without dropdown swatches
const sizePresetGroup = document.getElementById('size-preset-group');
+ const heliumPlacementRow = document.getElementById('helium-placement-row');
+ const heliumPlaceBalloonBtn = document.getElementById('helium-place-balloon');
+ const heliumPlaceCurlBtn = document.getElementById('helium-place-curl');
+ const heliumPlaceRibbonBtn = document.getElementById('helium-place-ribbon');
+ const heliumPlaceWeightBtn = document.getElementById('helium-place-weight');
const toggleShineBtn = null;
const toggleShineCheckbox = document.getElementById('toggle-shine-checkbox');
const toggleBorderCheckbox = document.getElementById('toggle-border-checkbox');
@@ -198,6 +234,195 @@
if (!canvas || !ctx) return; // nothing to do if organic UI isn't on page
+ const loadBalloonMask = async () => {
+ if (balloonMaskLoaded || balloonMaskLoading) return;
+ balloonMaskLoading = true;
+ try {
+ const res = await fetch(BALLOON_MASK_URL, { cache: 'force-cache' });
+ const text = await res.text();
+ let pathD = '';
+ let viewBoxRaw = '';
+ try {
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
+ const svgEl = doc.querySelector('svg');
+ const pathEl = doc.querySelector('path[d]');
+ pathD = pathEl?.getAttribute('d') || '';
+ viewBoxRaw = svgEl?.getAttribute('viewBox') || '';
+ } catch {}
+ // Fallback if DOM parsing fails for any reason
+ if (!pathD) {
+ const dMatch = text.match(/
]*\sd="([^"]+)"/i);
+ pathD = dMatch?.[1] || '';
+ }
+ if (!viewBoxRaw) {
+ const vbMatch = text.match(/viewBox="([^"]+)"/i);
+ viewBoxRaw = vbMatch?.[1] || '';
+ }
+ if (pathD) {
+ balloonMaskPath = new Path2D(pathD);
+ balloonMaskPathData = pathD;
+ }
+ if (pathD) {
+ try {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svgTmp = document.createElementNS(ns, 'svg');
+ const pTmp = document.createElementNS(ns, 'path');
+ pTmp.setAttribute('d', pathD);
+ svgTmp.setAttribute('xmlns', ns);
+ svgTmp.setAttribute('width', '0');
+ svgTmp.setAttribute('height', '0');
+ svgTmp.style.position = 'absolute';
+ svgTmp.style.left = '-9999px';
+ svgTmp.style.top = '-9999px';
+ svgTmp.style.opacity = '0';
+ svgTmp.appendChild(pTmp);
+ document.body.appendChild(svgTmp);
+ const bb = pTmp.getBBox();
+ svgTmp.remove();
+ if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
+ balloonMaskBounds = {
+ x: bb.x,
+ y: bb.y,
+ w: bb.width,
+ h: bb.height,
+ cx: bb.x + bb.width / 2,
+ cy: bb.y + bb.height / 2
+ };
+ }
+ } catch {}
+ }
+ if (viewBoxRaw) {
+ const parts = viewBoxRaw.split(/\s+/).map(Number);
+ if (parts.length === 4 && parts.every(Number.isFinite)) {
+ balloonMaskViewBox = { x: parts[0], y: parts[1], w: parts[2], h: parts[3] };
+ }
+ }
+ balloonMaskLoaded = !!balloonMaskPath;
+ if (balloonMaskLoaded) requestDraw();
+ } catch (err) {
+ console.warn('Failed to load balloon mask:', err);
+ } finally {
+ balloonMaskLoading = false;
+ }
+ };
+ const loadBalloon24Mask = async () => {
+ if (balloon24MaskLoaded || balloon24MaskLoading) return;
+ balloon24MaskLoading = true;
+ try {
+ const res = await fetch(BALLOON_24_MASK_URL, { cache: 'force-cache' });
+ const text = await res.text();
+ let pathD = '';
+ try {
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
+ const pathEl = doc.querySelector('path[d]');
+ pathD = pathEl?.getAttribute('d') || '';
+ } catch {}
+ if (!pathD) {
+ const dMatch = text.match(/]*\sd="([^"]+)"/i);
+ pathD = dMatch?.[1] || '';
+ }
+ if (pathD) {
+ balloon24MaskPath = new Path2D(pathD);
+ balloon24MaskPathData = pathD;
+ }
+ if (pathD) {
+ try {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svgTmp = document.createElementNS(ns, 'svg');
+ const pTmp = document.createElementNS(ns, 'path');
+ pTmp.setAttribute('d', pathD);
+ svgTmp.setAttribute('xmlns', ns);
+ svgTmp.setAttribute('width', '0');
+ svgTmp.setAttribute('height', '0');
+ svgTmp.style.position = 'absolute';
+ svgTmp.style.left = '-9999px';
+ svgTmp.style.top = '-9999px';
+ svgTmp.style.opacity = '0';
+ svgTmp.appendChild(pTmp);
+ document.body.appendChild(svgTmp);
+ const bb = pTmp.getBBox();
+ svgTmp.remove();
+ if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
+ balloon24MaskBounds = {
+ x: bb.x,
+ y: bb.y,
+ w: bb.width,
+ h: bb.height,
+ cx: bb.x + bb.width / 2,
+ cy: bb.y + bb.height / 2
+ };
+ }
+ } catch {}
+ }
+ balloon24MaskLoaded = !!balloon24MaskPath;
+ if (balloon24MaskLoaded) requestDraw();
+ } catch (err) {
+ console.warn('Failed to load 24in balloon mask:', err);
+ } finally {
+ balloon24MaskLoading = false;
+ }
+ };
+ const loadWeightMask = async () => {
+ if (weightMaskLoaded || weightMaskLoading) return;
+ weightMaskLoading = true;
+ try {
+ const res = await fetch(WEIGHT_MASK_URL, { cache: 'force-cache' });
+ const text = await res.text();
+ let pathD = '';
+ try {
+ const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
+ const pathEl = doc.querySelector('path[d]');
+ pathD = pathEl?.getAttribute('d') || '';
+ } catch {}
+ if (!pathD) {
+ const dMatch = text.match(/]*\sd="([^"]+)"/i);
+ pathD = dMatch?.[1] || '';
+ }
+ if (pathD) {
+ weightMaskPath = new Path2D(pathD);
+ weightMaskPathData = pathD;
+ }
+ if (pathD) {
+ try {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svgTmp = document.createElementNS(ns, 'svg');
+ const pTmp = document.createElementNS(ns, 'path');
+ pTmp.setAttribute('d', pathD);
+ svgTmp.setAttribute('xmlns', ns);
+ svgTmp.setAttribute('width', '0');
+ svgTmp.setAttribute('height', '0');
+ svgTmp.style.position = 'absolute';
+ svgTmp.style.left = '-9999px';
+ svgTmp.style.top = '-9999px';
+ svgTmp.style.opacity = '0';
+ svgTmp.appendChild(pTmp);
+ document.body.appendChild(svgTmp);
+ const bb = pTmp.getBBox();
+ svgTmp.remove();
+ if (Number.isFinite(bb.x) && Number.isFinite(bb.y) && bb.width > 0 && bb.height > 0) {
+ weightMaskBounds = {
+ x: bb.x,
+ y: bb.y,
+ w: bb.width,
+ h: bb.height,
+ cx: bb.x + bb.width / 2,
+ cy: bb.y + bb.height / 2
+ };
+ }
+ } catch {}
+ }
+ weightMaskLoaded = !!weightMaskPath;
+ if (weightMaskLoaded) requestDraw();
+ } catch (err) {
+ console.warn('Failed to load weight mask:', err);
+ } finally {
+ weightMaskLoading = false;
+ }
+ };
+ loadBalloonMask();
+ loadBalloon24Mask();
+ loadWeightMask();
+
// ====== State ======
let balloons = [];
let selectedColorIdx = 0;
@@ -217,6 +442,10 @@
let garlandDensity = parseFloat(garlandDensityInput?.value || '1') || 1;
let garlandMainIdx = [0];
let garlandAccentIdx = -1;
+ let heliumPlacementType = 'balloon';
+ let ribbonDraftStart = null;
+ let ribbonDraftMouse = null;
+ let ribbonAttachMode = false;
let lastCommitMode = '';
let lastAddStatus = '';
let evtStats = { down: 0, up: 0, cancel: 0, touchEnd: 0, addBalloon: 0, addGarland: 0, lastType: '' };
@@ -327,6 +556,364 @@
}
return best;
}
+ function hashString32(str) {
+ let h = 2166136261 >>> 0;
+ const s = String(str || '');
+ for (let i = 0; i < s.length; i++) {
+ h ^= s.charCodeAt(i);
+ h = Math.imul(h, 16777619);
+ }
+ return h >>> 0;
+ }
+ function normalizeRibbonStyle(v) {
+ return v === 'spiral' ? 'spiral' : 'wave';
+ }
+ function getObjectRotationRad(b) {
+ const deg = Number(b?.rotationDeg) || 0;
+ return (deg * Math.PI) / 180;
+ }
+ function normalizeRotationDeg(deg) {
+ let d = Number(deg) || 0;
+ while (d > 180) d -= 360;
+ while (d <= -180) d += 360;
+ return d;
+ }
+ function withObjectRotation(b, fn) {
+ const ang = getObjectRotationRad(b);
+ if (!ang) { fn(); return; }
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.rotate(ang);
+ ctx.translate(-b.x, -b.y);
+ fn();
+ ctx.restore();
+ }
+ function rotatedAabb(minX, minY, maxX, maxY, cx, cy, ang) {
+ if (!ang) return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
+ const c = Math.cos(ang);
+ const s = Math.sin(ang);
+ const corners = [
+ [minX, minY], [maxX, minY], [minX, maxY], [maxX, maxY]
+ ];
+ let rMinX = Infinity, rMinY = Infinity, rMaxX = -Infinity, rMaxY = -Infinity;
+ corners.forEach(([x, y]) => {
+ const dx = x - cx;
+ const dy = y - cy;
+ const rx = cx + dx * c - dy * s;
+ const ry = cy + dx * s + dy * c;
+ rMinX = Math.min(rMinX, rx);
+ rMinY = Math.min(rMinY, ry);
+ rMaxX = Math.max(rMaxX, rx);
+ rMaxY = Math.max(rMaxY, ry);
+ });
+ return { minX: rMinX, minY: rMinY, maxX: rMaxX, maxY: rMaxY, w: rMaxX - rMinX, h: rMaxY - rMinY };
+ }
+ function getBalloonMaskShape(sizePreset, activeTab = getActiveOrganicTab()) {
+ if (activeTab === '#tab-helium' && sizePreset === 24 && balloon24MaskPath) {
+ return { path: balloon24MaskPath, bounds: balloon24MaskBounds };
+ }
+ return { path: balloonMaskPath, bounds: balloonMaskBounds };
+ }
+ function getHeliumVolumeVisualBoost(sizePreset, activeTab = getActiveOrganicTab()) {
+ if (activeTab !== '#tab-helium') return 1;
+ const cuft = HELIUM_CUFT_BY_SIZE[sizePreset];
+ if (!Number.isFinite(cuft) || cuft <= 0) return 1;
+ const desiredRatio = Math.sqrt(cuft / HELIUM_CUFT_BASE_VALUE);
+ const currentRatio = sizePreset / HELIUM_CUFT_BASE_SIZE;
+ if (!Number.isFinite(currentRatio) || currentRatio <= 0) return 1;
+ return desiredRatio / currentRatio;
+ }
+ function getBalloonVisualRadius(b, activeTab = getActiveOrganicTab()) {
+ if (!b || b.kind !== 'balloon') return b?.radius || 0;
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ return b.radius * getHeliumVolumeVisualBoost(sizePreset, activeTab);
+ }
+ function getWeightMaskTransform(b) {
+ const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const scale = (b.radius * 2 * WEIGHT_VISUAL_SCALE) / Math.max(1, mb.w);
+ const minX = b.x - mb.cx * scale;
+ const minY = b.y - mb.cy * scale;
+ return {
+ scale,
+ minX,
+ minY,
+ maxX: minX + mb.w * scale,
+ maxY: minY + mb.h * scale
+ };
+ }
+ function isPointInWeightMask(b, x, y) {
+ if (!weightMaskPath) return false;
+ const wt = getWeightMaskTransform(b);
+ const mb = weightMaskBounds || { cx: 0.5, cy: 0.5 };
+ const ang = getObjectRotationRad(b);
+ const c = Math.cos(-ang);
+ const s = Math.sin(-ang);
+ const dx = x - b.x;
+ const dy = y - b.y;
+ const ux = dx * c - dy * s;
+ const uy = dx * s + dy * c;
+ const localX = (ux / wt.scale) + mb.cx;
+ const localY = (uy / wt.scale) + mb.cy;
+ return !!ctx.isPointInPath(weightMaskPath, localX, localY);
+ }
+ function weightHitTest(b, x, y, pad = 0, { loose = false } = {}) {
+ if (!weightMaskPath) {
+ return Math.hypot(x - b.x, y - b.y) <= (b.radius * 1.4 + pad);
+ }
+ const wt = getWeightMaskTransform(b);
+ if (x < wt.minX - pad || x > wt.maxX + pad || y < wt.minY - pad || y > wt.maxY + pad) return false;
+ if (loose) return true;
+ if (isPointInWeightMask(b, x, y)) return true;
+ if (pad <= 0) return false;
+ const sampleCount = 8;
+ for (let i = 0; i < sampleCount; i++) {
+ const a = (Math.PI * 2 * i) / sampleCount;
+ if (isPointInWeightMask(b, x + Math.cos(a) * pad, y + Math.sin(a) * pad)) return true;
+ }
+ return false;
+ }
+ function getObjectBounds(b) {
+ if (!b) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 };
+ if (b.kind === 'ribbon') {
+ const pts = getRibbonPoints(b);
+ if (!pts || !pts.length) return { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0 };
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ pts.forEach(p => {
+ minX = Math.min(minX, p.x);
+ minY = Math.min(minY, p.y);
+ maxX = Math.max(maxX, p.x);
+ maxY = Math.max(maxY, p.y);
+ });
+ return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
+ }
+ const ang = getObjectRotationRad(b);
+ if (b.kind === 'curl260') {
+ const r = b.radius * 1.35;
+ return rotatedAabb(b.x - r, b.y - r, b.x + r, b.y + r * 2.4, b.x, b.y, ang);
+ }
+ if (b.kind === 'weight') {
+ if (weightMaskPath) {
+ const wt = getWeightMaskTransform(b);
+ return rotatedAabb(wt.minX, wt.minY, wt.maxX, wt.maxY, b.x, b.y, ang);
+ }
+ const r = b.radius * WEIGHT_VISUAL_SCALE;
+ return rotatedAabb(b.x - r * 1.2, b.y - r * 2.8, b.x + r * 1.2, b.y + r * 2.4, b.x, b.y, ang);
+ }
+ const vr = getBalloonVisualRadius(b, getActiveOrganicTab());
+ return { minX: b.x - vr, minY: b.y - vr, maxX: b.x + vr, maxY: b.y + vr, w: vr * 2, h: vr * 2 };
+ }
+ function rotatePointAround(px, py, cx, cy, ang) {
+ if (!ang) return { x: px, y: py };
+ const c = Math.cos(ang);
+ const s = Math.sin(ang);
+ const dx = px - cx;
+ const dy = py - cy;
+ return { x: cx + dx * c - dy * s, y: cy + dx * s + dy * c };
+ }
+ function getRibbonNodePosition(b) {
+ if (!b) return null;
+ if (b.kind === 'weight') {
+ let p;
+ if (weightMaskPath) {
+ const wt = getWeightMaskTransform(b);
+ const mb = weightMaskBounds || { y: 0, h: 1, cy: 0.5 };
+ // Slightly below the very top so ribbons land on the weight tie point.
+ const localY = mb.y + mb.h * 0.145;
+ p = { x: b.x, y: b.y + (localY - mb.cy) * wt.scale };
+ } else {
+ const bb = getObjectBounds(b);
+ p = { x: b.x, y: bb.minY + Math.max(4, bb.h * 0.15) };
+ }
+ const ang = getObjectRotationRad(b);
+ return rotatePointAround(p.x, p.y, b.x, b.y, ang);
+ }
+ if (b.kind === 'balloon') {
+ let p;
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const maskShape = getBalloonMaskShape(sizePreset, getActiveOrganicTab());
+ const useBalloonMaskNode = !!(maskShape.path && getActiveOrganicTab() === '#tab-helium');
+ if (useBalloonMaskNode) {
+ const mb = maskShape.bounds || { y: 0, h: 1, cy: 0.5, w: 1 };
+ const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, getActiveOrganicTab());
+ const scaleY = ((b.radius * 2) / Math.max(1, mb.h)) * heliumBoost;
+ const localY = mb.y + mb.h * 0.972;
+ p = { x: b.x, y: b.y + (localY - mb.cy) * scaleY };
+ } else {
+ p = { x: b.x, y: b.y + b.radius * 0.96 };
+ }
+ const ang = getObjectRotationRad(b);
+ return rotatePointAround(p.x, p.y, b.x, b.y, ang);
+ }
+ return null;
+ }
+ function findRibbonNodeAt(x, y) {
+ const hitR = Math.max(8 / view.s, 5);
+ for (let i = balloons.length - 1; i >= 0; i--) {
+ const b = balloons[i];
+ if (b?.kind !== 'balloon' && b?.kind !== 'weight') continue;
+ const p = getRibbonNodePosition(b);
+ if (!p) continue;
+ if (Math.hypot(x - p.x, y - p.y) <= hitR) {
+ return { kind: b.kind, id: b.id, x: p.x, y: p.y };
+ }
+ }
+ return null;
+ }
+ function resolveRibbonEndpoint(endpoint) {
+ if (!endpoint || !endpoint.id) return null;
+ const b = balloons.find(obj => obj.id === endpoint.id);
+ return getRibbonNodePosition(b);
+ }
+ function getRibbonPoints(ribbon) {
+ const start = resolveRibbonEndpoint(ribbon.from);
+ if (!start) return null;
+ const rawEnd = ribbon.to ? resolveRibbonEndpoint(ribbon.to) : (ribbon.freeEnd ? { x: ribbon.freeEnd.x, y: ribbon.freeEnd.y } : null);
+ const scale = clamp(Number(ribbon.lengthScale) || 1, 0.4, 2.2);
+ const end = rawEnd ? {
+ x: start.x + (rawEnd.x - start.x) * scale,
+ y: start.y + (rawEnd.y - start.y) * scale
+ } : null;
+ if (!end) return null;
+ const points = [];
+ const tight = !!(ribbon.to && ((ribbon.from?.kind === 'balloon' && ribbon.to?.kind === 'weight') || (ribbon.from?.kind === 'weight' && ribbon.to?.kind === 'balloon')));
+ if (tight) return [start, end];
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const len = Math.max(1, Math.hypot(dx, dy));
+ const nx = -dy / len;
+ const ny = dx / len;
+ const steps = 28;
+ const amp = Math.max(4 / view.s, Math.min(16 / view.s, len * 0.085)) * (0.9 + scale * 0.25);
+ const waves = 3;
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps;
+ const sx = start.x + dx * t;
+ const sy = start.y + dy * t;
+ const off = Math.sin(t * Math.PI * 2 * waves) * amp * (0.35 + 0.65 * t);
+ points.push({ x: sx + nx * off, y: sy + ny * off + (1 - t) * (2 / view.s) });
+ }
+ return points;
+ }
+ function drawRibbonObject(ribbon, meta) {
+ const pts = getRibbonPoints(ribbon);
+ if (!pts || pts.length < 2) return;
+ const rgb = hexToRgb(normalizeHex(meta?.hex || ribbon.color || '#999999')) || { r: 120, g: 120, b: 120 };
+ const w = Math.max(1.9, 2.2 / view.s);
+ const trace = () => {
+ ctx.moveTo(pts[0].x, pts[0].y);
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
+ };
+ ctx.save();
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ if (isBorderEnabled) {
+ ctx.beginPath();
+ trace();
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = w + Math.max(0.9, 1.2 / view.s);
+ ctx.stroke();
+ }
+ ctx.beginPath();
+ trace();
+ ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.lineWidth = w;
+ ctx.stroke();
+
+ // Small curled ribbon detail at balloon nozzle connection.
+ if (ribbon?.from?.kind === 'balloon') {
+ const p0 = pts[0];
+ const p1 = pts[Math.min(1, pts.length - 1)];
+ const vx = p1.x - p0.x;
+ const vy = p1.y - p0.y;
+ const vl = Math.max(1e-6, Math.hypot(vx, vy));
+ const ux = vx / vl;
+ const uy = vy / vl;
+ const nx = -uy;
+ const ny = ux;
+ const side = (hashString32(ribbon.id || '') & 1) ? 1 : -1;
+ const amp = Math.max(3.2 / view.s, 2.3);
+ const len = Math.max(12 / view.s, 8.5);
+ const steps = 14;
+ const drawCurl = (lineW, stroke) => {
+ ctx.beginPath();
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps;
+ const d = len * t;
+ const x = p0.x + ux * d + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
+ const y = p0.y + uy * d + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
+ }
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = lineW;
+ ctx.stroke();
+ };
+ if (isBorderEnabled) drawCurl(w + Math.max(0.9, 1.2 / view.s), '#111827');
+ drawCurl(w, `rgb(${rgb.r},${rgb.g},${rgb.b})`);
+ }
+ ctx.restore();
+ }
+ function ribbonDistanceToPoint(ribbon, x, y) {
+ const pts = getRibbonPoints(ribbon);
+ if (!pts || pts.length < 2) return Infinity;
+ let best = Infinity;
+ for (let i = 1; i < pts.length; i++) {
+ const a = pts[i - 1];
+ const b = pts[i];
+ const vx = b.x - a.x;
+ const vy = b.y - a.y;
+ const vv = vx * vx + vy * vy || 1;
+ const t = clamp(((x - a.x) * vx + (y - a.y) * vy) / vv, 0, 1);
+ const px = a.x + vx * t;
+ const py = a.y + vy * t;
+ best = Math.min(best, Math.hypot(x - px, y - py));
+ }
+ return best;
+ }
+ function drawRibbonSelectionRing(ribbon) {
+ const pts = getRibbonPoints(ribbon);
+ if (!pts || pts.length < 2) return false;
+ const trace = () => {
+ ctx.moveTo(pts[0].x, pts[0].y);
+ for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
+ };
+ ctx.save();
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.beginPath();
+ trace();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.lineWidth = Math.max(5 / view.s, 3);
+ ctx.stroke();
+ ctx.beginPath();
+ trace();
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = Math.max(2.2 / view.s, 1.4);
+ ctx.stroke();
+ ctx.restore();
+ return true;
+ }
+ function normalizeHeliumPlacementType(v) {
+ return (v === 'curl260' || v === 'weight' || v === 'ribbon') ? v : 'balloon';
+ }
+ function buildCurrentRibbonConfig() {
+ return {
+ enabled: true,
+ style: 'wave',
+ length: 0.7,
+ turns: 3
+ };
+ }
+ function getCurlObjectConfig(b) {
+ if (!b || b.kind !== 'curl260' || !b.curl) return null;
+ return {
+ enabled: true,
+ style: normalizeRibbonStyle(b.curl.style),
+ length: clamp(Number(b.curl.length) || 0.7, 0.7, 2.4),
+ turns: Math.max(1, Math.min(5, Math.round(Number(b.curl.turns) || 3)))
+ };
+ }
function showModal(msg, opts = {}) {
if (window.Swal) {
Swal.fire({
@@ -378,9 +965,11 @@
};
function setMode(next) {
+ if (next === 'garland' && getActiveOrganicTab() === '#tab-helium') next = 'draw';
if (mode === 'garland' && next !== 'garland') {
garlandPath = [];
}
+ if (next !== 'draw') resetRibbonDraft();
mode = next;
toolDrawBtn?.setAttribute('aria-pressed', String(mode === 'draw'));
toolGarlandBtn?.setAttribute('aria-pressed', String(mode === 'garland'));
@@ -410,6 +999,42 @@
draw();
persist();
}
+ function syncHeliumToolUi() {
+ const isHelium = getActiveOrganicTab() === '#tab-helium';
+ if (toolGarlandBtn) {
+ toolGarlandBtn.classList.toggle('hidden', isHelium);
+ toolGarlandBtn.disabled = isHelium;
+ toolGarlandBtn.setAttribute('aria-hidden', String(isHelium));
+ toolGarlandBtn.tabIndex = isHelium ? -1 : 0;
+ }
+ if (isHelium && mode === 'garland') setMode('draw');
+ if (isHelium) garlandControls?.classList.add('hidden');
+ }
+ function syncHeliumPlacementUi() {
+ const isHelium = getActiveOrganicTab() === '#tab-helium';
+ heliumPlacementRow?.classList.toggle('hidden', !isHelium);
+ const setBtn = (btn, active) => {
+ if (!btn) return;
+ btn.classList.toggle('tab-active', !!active);
+ btn.classList.toggle('tab-idle', !active);
+ btn.setAttribute('aria-pressed', String(!!active));
+ };
+ setBtn(heliumPlaceBalloonBtn, heliumPlacementType === 'balloon');
+ setBtn(heliumPlaceCurlBtn, heliumPlacementType === 'curl260');
+ setBtn(heliumPlaceRibbonBtn, heliumPlacementType === 'ribbon');
+ setBtn(heliumPlaceWeightBtn, heliumPlacementType === 'weight');
+ if (isHelium && sizePresetGroup) {
+ const sizeBtns = Array.from(sizePresetGroup.querySelectorAll('button'));
+ if (heliumPlacementType !== 'balloon') {
+ sizeBtns.forEach(btn => btn.setAttribute('aria-pressed', 'false'));
+ } else {
+ sizeBtns.forEach(btn => {
+ const isMatch = (btn.textContent || '').trim() === `${currentDiameterInches}"`;
+ btn.setAttribute('aria-pressed', String(isMatch));
+ });
+ }
+ }
+ }
function selectionArray() { return Array.from(selectedIds); }
function selectionBalloons() {
@@ -426,7 +1051,12 @@
const first = selectedIds.values().next();
return first.done ? null : first.value;
}
- function clearSelection() { selectedIds.clear(); updateSelectButtons(); draw(); }
+ function clearSelection() {
+ selectedIds.clear();
+ ribbonAttachMode = false;
+ updateSelectButtons();
+ draw();
+ }
function updateSelectButtons() {
const has = selectedIds.size > 0;
if (deleteSelectedBtn) deleteSelectedBtn.disabled = !has;
@@ -439,14 +1069,31 @@
}
if (bringForwardBtn) bringForwardBtn.disabled = !has;
if (sendBackwardBtn) sendBackwardBtn.disabled = !has;
+ if (rotateSelectedLeftBtn) rotateSelectedLeftBtn.disabled = !has;
+ if (rotateSelectedResetBtn) rotateSelectedResetBtn.disabled = !has;
+ if (rotateSelectedRightBtn) rotateSelectedRightBtn.disabled = !has;
if (applyColorBtn) applyColorBtn.disabled = !has;
+ const isHelium = getActiveOrganicTab() === '#tab-helium';
+ const selectedCurlCount = selectionBalloons().filter(b => b?.kind === 'curl260').length;
+ const selectedRibbonCount = selectionBalloons().filter(b => b?.kind === 'ribbon').length;
+ if (ribbonAttachMode && selectedRibbonCount === 0) ribbonAttachMode = false;
+ if (ribbonLengthDownBtn) ribbonLengthDownBtn.disabled = selectedRibbonCount === 0;
+ if (ribbonLengthUpBtn) ribbonLengthUpBtn.disabled = selectedRibbonCount === 0;
+ if (ribbonAttachWeightBtn) {
+ ribbonAttachWeightBtn.disabled = selectedRibbonCount === 0;
+ ribbonAttachWeightBtn.textContent = ribbonAttachMode ? 'Click Weight…' : 'Attach to Weight';
+ }
if (selectedSizeInput && selectedSizeLabel) {
if (has) {
- const first = balloons.find(bb => selectedIds.has(bb.id));
+ const first = balloons.find(bb => selectedIds.has(bb.id) && Number.isFinite(bb.radius));
if (first) {
const diam = radiusPxToInches(first.radius);
selectedSizeInput.value = String(Math.min(32, Math.max(5, diam)));
selectedSizeLabel.textContent = fmtInches(diam);
+ selectedSizeInput.disabled = false;
+ } else {
+ selectedSizeInput.disabled = true;
+ selectedSizeLabel.textContent = '—';
}
} else {
selectedSizeLabel.textContent = '0"';
@@ -513,6 +1160,20 @@
if (mode === 'select') {
pointerDown = true;
+ if (ribbonAttachMode) {
+ const clickedIdx = findWeightIndexAt(mousePos.x, mousePos.y);
+ const target = clickedIdx >= 0 ? balloons[clickedIdx] : null;
+ if (target?.kind === 'weight') {
+ attachSelectedRibbonsToWeight(target.id);
+ } else {
+ ribbonAttachMode = false;
+ updateSelectButtons();
+ showModal('Attach mode canceled. Click "Attach to Weight" and then click a weight.');
+ }
+ requestDraw();
+ pointerDown = false;
+ return;
+ }
const clickedIdx = findBalloonIndexAt(mousePos.x, mousePos.y);
if (clickedIdx !== -1) {
const b = balloons[clickedIdx];
@@ -540,6 +1201,12 @@
return;
}
+ if (getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon') {
+ handleRibbonPlacementAt(mousePos.x, mousePos.y);
+ pointerDown = false;
+ return;
+ }
+
addBalloon(mousePos.x, mousePos.y);
pointerDown = true;
}
@@ -570,6 +1237,11 @@
}
}
+ if (mode === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType === 'ribbon' && ribbonDraftStart) {
+ ribbonDraftMouse = { ...mousePos };
+ requestDraw();
+ }
+
if (mode === 'garland') {
if (pointerDown) {
const last = garlandPath[garlandPath.length - 1];
@@ -638,6 +1310,9 @@
garlandPath = [];
requestDraw();
}
+ if (mode === 'draw' && getActiveOrganicTab() === '#tab-helium' && heliumPlacementType !== 'ribbon') {
+ resetRibbonDraft();
+ }
}
// Avoid touch scrolling stealing pointer events.
@@ -756,98 +1431,641 @@
ctx.save();
ctx.scale(view.s, view.s);
ctx.translate(view.tx, view.ty);
+ const activeOrgTab = getActiveOrganicTab();
+ const isHeliumTab = activeOrgTab === '#tab-helium';
+ const canRenderHeliumRibbons = isHeliumTab;
- balloons.forEach(b => {
- if (b.image) {
- const img = getImage(b.image);
- if (img && img.complete && img.naturalWidth > 0) {
- const meta = FLAT_COLORS[b.colorIdx] || {};
+ const drawMaskedBalloon = (b, meta) => {
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const shape = getBalloonMaskShape(sizePreset, activeOrgTab);
+ if (!shape.path) return;
+ const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
+ const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
+ const strokeW = Math.max(0.35, 0.5 / view.s);
+ const destX = b.x - (mb.cx - mb.x) * scale;
+ const destY = b.y - (mb.cy - mb.y) * scale;
+ const destW = Math.max(1, mb.w * scale);
+ const destH = Math.max(1, mb.h * scale);
+ const drawFill = () => {
+ if (b.image) {
+ const img = getImage(b.image);
+ if (!img || !img.complete || img.naturalWidth === 0) return;
const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
-
const srcW = img.naturalWidth / zoom;
const srcH = img.naturalHeight / zoom;
const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
- const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
-
- ctx.save();
- ctx.beginPath();
- ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
- ctx.clip();
- const lum = luminance(meta.hex || b.color);
- if (lum > 0.6) {
- const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors
- ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
- ctx.shadowBlur = 4 + 4 * strength;
- ctx.shadowOffsetY = 1 + 2 * strength;
+ const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
+ ctx.drawImage(img, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
+ } else {
+ ctx.fillStyle = b.color;
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
+ ctx.shadowBlur = 10;
+ ctx.fillRect(destX, destY, destW, destH);
+ ctx.shadowBlur = 0;
}
- ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
+ };
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(scale, scale);
+ ctx.translate(-mb.cx, -mb.cy);
+ ctx.clip(shape.path);
+ ctx.translate(mb.cx, mb.cy);
+ ctx.scale(1 / scale, 1 / scale);
+ ctx.translate(-b.x, -b.y);
+ const lum = luminance(meta.hex || b.color);
+ if (b.image && lum > 0.6) {
+ const strength = clamp01((lum - 0.6) / 0.4);
+ ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
+ ctx.shadowBlur = 4 + 4 * strength;
+ ctx.shadowOffsetY = 1 + 2 * strength;
+ }
+ drawFill();
+ ctx.restore();
+
+ if (isBorderEnabled) {
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(scale, scale);
+ ctx.translate(-mb.cx, -mb.cy);
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = strokeW / scale;
+ ctx.stroke(shape.path);
+ ctx.restore();
+ }
+ };
+ const tryDrawMaskedBalloon = (b, meta) => {
+ try {
+ drawMaskedBalloon(b, meta);
+ return true;
+ } catch (err) {
+ if (!balloonMaskDrawFailed) {
+ balloonMaskDrawFailed = true;
+ console.warn('Masked balloon draw failed; falling back to circular balloons.', err);
+ }
+ return false;
+ }
+ };
+
+ const drawHeliumRibbon = (b, meta, ribbonCfg) => {
+ if (!canRenderHeliumRibbons || !ribbonCfg) return;
+ const seed = hashString32(b.id || `${b.x},${b.y}`);
+ const baseColor = normalizeHex(meta?.hex || b.color || '#999999');
+ const rgb = hexToRgb(baseColor) || { r: 120, g: 120, b: 120 };
+ const side = (seed & 1) ? 1 : -1;
+ const phase = ((seed >>> 3) % 628) / 100;
+ const len = Math.max(20, b.radius * 2 * ribbonCfg.length);
+ const stem = Math.max(6, b.radius * 0.28);
+ const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
+ const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0));
+ const anchorX = (b.kind === 'curl260') ? b.x : (b.x + side * Math.max(1, b.radius * 0.06));
+ const anchorY = (b.kind === 'curl260') ? (b.y - b.radius * 0.55) : (b.y + b.radius * 0.54);
+ const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s);
+ const isSpiralRibbon = ribbonCfg.style === 'spiral';
+ const steps = isSpiralRibbon ? 42 : 28;
+
+ const traceRibbon = () => {
+ ctx.moveTo(anchorX, anchorY);
+ ctx.lineTo(anchorX, anchorY + stem);
+ if (isSpiralRibbon) {
+ const topLead = Math.max(8, len * 0.28);
+ const topAmp = Math.max(6, b.radius * 0.95);
+ // Lead-in swoop like a hand-curled ribbon before the tighter coils.
+ for (let i = 1; i <= Math.floor(steps * 0.33); i++) {
+ const t = i / Math.floor(steps * 0.33);
+ const y = anchorY + stem + topLead * t;
+ const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
+ ctx.lineTo(x, y);
+ }
+ const startY = anchorY + stem + topLead;
+ const remain = Math.max(8, len - topLead);
+ const coilAmp = Math.max(5, b.radius * 0.5);
+ const coilSteps = steps - Math.floor(steps * 0.33);
+ for (let i = 1; i <= coilSteps; i++) {
+ const t = i / coilSteps;
+ const angle = (Math.PI * 2 * turns * t) + phase;
+ const decay = 1 - t * 0.55;
+ const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
+ const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
+ ctx.lineTo(x, y);
+ }
+ return;
+ }
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const falloff = 1 - t * 0.25;
+ const y = anchorY + stem + len * t;
+ const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
+ ctx.lineTo(x, y);
+ }
+ };
+
+ ctx.save();
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ if (isBorderEnabled) {
+ ctx.beginPath();
+ traceRibbon();
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = width + Math.max(0.8, 1.2 / view.s);
+ ctx.stroke();
+ }
+
+ // ribbon (thicker 260-style)
+ ctx.beginPath();
+ traceRibbon();
+ ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.lineWidth = width;
+ ctx.stroke();
+
+ // tiny tie under the neck only for attached balloon ribbons/curls
+ if (b.kind !== 'curl260') {
+ ctx.beginPath();
+ ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s);
+ ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s);
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
- ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
+ ctx.lineWidth = Math.max(1.2, 1.8 / view.s);
ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(anchorX - 2.2 / view.s, anchorY + 1.2 / view.s);
+ ctx.lineTo(anchorX + 2.2 / view.s, anchorY + 1.2 / view.s);
+ }
+ ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.lineWidth = Math.max(0.6, 0.9 / view.s);
+ ctx.stroke();
+ }
+ ctx.restore();
+ };
+ const drawWeightObject = (b, meta) => {
+ if (weightMaskPath) {
+ const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
+ const wt = getWeightMaskTransform(b);
+ const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const weightImg = getImage(WEIGHT_IMAGE_URL);
+ const hasWeightImg = !!(weightImg && weightImg.complete && weightImg.naturalWidth > 0);
+ if (hasWeightImg) {
+ const nw = Math.max(1, weightImg.naturalWidth || 1);
+ const nh = Math.max(1, weightImg.naturalHeight || 1);
+ const dstW = Math.max(1, mb.w);
+ const dstH = Math.max(1, mb.h);
+ const srcAspect = nw / nh;
+ const dstAspect = dstW / dstH;
+ let srcX = 0, srcY = 0, srcW = nw, srcH = nh;
+ if (srcAspect > dstAspect) {
+ srcW = nh * dstAspect;
+ srcX = (nw - srcW) * 0.5;
+ } else if (srcAspect < dstAspect) {
+ srcH = nw / dstAspect;
+ srcY = (nh - srcH) * 0.5;
+ }
+ const inset = Math.max(0.8, Math.min(dstW, dstH) * 0.018);
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(wt.scale, wt.scale);
+ ctx.translate(-mb.cx, -mb.cy);
+ ctx.save();
+ ctx.clip(weightMaskPath);
+
+ // Base photo texture (cover fit so it doesn't squash/blur).
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = 'high';
+ ctx.filter = 'saturate(1.15) contrast(1.04)';
+ ctx.drawImage(
+ weightImg,
+ srcX, srcY, srcW, srcH,
+ mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2)
+ );
+ ctx.filter = 'none';
+ // Tint only existing image pixels (no rectangular washout).
+ ctx.globalCompositeOperation = 'source-atop';
+ ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.46)`;
+ ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2));
+ // Bring back a little foil highlight after tint.
+ ctx.globalCompositeOperation = 'screen';
+ ctx.fillStyle = 'rgba(255,255,255,0.08)';
+ ctx.fillRect(mb.x + inset, mb.y + inset, Math.max(1, dstW - inset * 2), Math.max(1, dstH - inset * 2));
+ ctx.globalCompositeOperation = 'source-over';
+ ctx.restore();
+ ctx.restore();
+ return;
+ }
+ const hi = {
+ r: Math.round(clamp(rgb.r + 78, 0, 255)),
+ g: Math.round(clamp(rgb.g + 78, 0, 255)),
+ b: Math.round(clamp(rgb.b + 78, 0, 255))
+ };
+ const midHi = {
+ r: Math.round(clamp(rgb.r + 38, 0, 255)),
+ g: Math.round(clamp(rgb.g + 38, 0, 255)),
+ b: Math.round(clamp(rgb.b + 38, 0, 255))
+ };
+ const lo = {
+ r: Math.round(clamp(rgb.r - 52, 0, 255)),
+ g: Math.round(clamp(rgb.g - 52, 0, 255)),
+ b: Math.round(clamp(rgb.b - 52, 0, 255))
+ };
+ const midLo = {
+ r: Math.round(clamp(rgb.r - 24, 0, 255)),
+ g: Math.round(clamp(rgb.g - 24, 0, 255)),
+ b: Math.round(clamp(rgb.b - 24, 0, 255))
+ };
+ const seed = hashString32(b.id || `${b.x},${b.y}`);
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(wt.scale, wt.scale);
+ ctx.translate(-mb.cx, -mb.cy);
+
+ // Base color
+ ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.fill(weightMaskPath);
+
+ // Metallic foil shading and specular highlights.
+ ctx.save();
+ ctx.clip(weightMaskPath);
+ // 45deg, multi-stop metallic ramp inspired by ibelick's CSS metal effect.
+ const foilGrad = ctx.createLinearGradient(mb.x, mb.y, mb.x + mb.w, mb.y + mb.h);
+ foilGrad.addColorStop(0.05, `rgba(${lo.r},${lo.g},${lo.b},0.34)`);
+ foilGrad.addColorStop(0.10, `rgba(${hi.r},${hi.g},${hi.b},0.42)`);
+ foilGrad.addColorStop(0.30, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`);
+ foilGrad.addColorStop(0.50, `rgba(${midHi.r},${midHi.g},${midHi.b},0.22)`);
+ foilGrad.addColorStop(0.70, `rgba(${midLo.r},${midLo.g},${midLo.b},0.26)`);
+ foilGrad.addColorStop(0.80, `rgba(${hi.r},${hi.g},${hi.b},0.40)`);
+ foilGrad.addColorStop(0.95, `rgba(${lo.r},${lo.g},${lo.b},0.34)`);
+ ctx.fillStyle = foilGrad;
+ ctx.fillRect(mb.x - 2, mb.y - 2, mb.w + 4, mb.h + 4);
+
+ // Diagonal foil crinkle bands from the corner direction.
+ ctx.save();
+ const bandCount = 8;
+ const diag = Math.hypot(mb.w, mb.h);
+ const cx = mb.x + mb.w * 0.16;
+ const cy = mb.y + mb.h * 0.14;
+ ctx.translate(cx, cy);
+ ctx.rotate(-Math.PI / 4);
+ for (let i = 0; i < bandCount; i++) {
+ const t = (i + 0.5) / bandCount;
+ const jitter = ((((seed >>> (i * 3)) & 7) - 3) / 7) * (diag * 0.03);
+ const bx = -diag * 0.2 + diag * t + jitter;
+ const bandW = diag * (0.05 + (i % 3) * 0.01);
+ const band = ctx.createLinearGradient(bx - bandW, 0, bx + bandW, 0);
+ band.addColorStop(0, `rgba(${hi.r},${hi.g},${hi.b},0.00)`);
+ band.addColorStop(0.5, `rgba(${hi.r},${hi.g},${hi.b},0.22)`);
+ band.addColorStop(1, `rgba(${lo.r},${lo.g},${lo.b},0.00)`);
+ ctx.fillStyle = band;
+ ctx.fillRect(bx - bandW, -diag * 0.7, bandW * 2, diag * 1.8);
}
ctx.restore();
- } else {
- // fallback solid
- ctx.beginPath();
- ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
- ctx.fillStyle = b.color;
- ctx.shadowColor = 'rgba(0,0,0,0.2)';
- ctx.shadowBlur = 10;
- ctx.fill();
+
+ // Corner-entry sheen (top-left toward center) for a directional metallic hit.
+ const cornerX = mb.x + mb.w * 0.14;
+ const cornerY = mb.y + mb.h * 0.16;
+ const cornerSpec = ctx.createRadialGradient(cornerX, cornerY, mb.w * 0.03, cornerX, cornerY, mb.w * 0.72);
+ cornerSpec.addColorStop(0.0, 'rgba(255,255,255,0.34)');
+ cornerSpec.addColorStop(0.28, 'rgba(255,255,255,0.16)');
+ cornerSpec.addColorStop(1.0, 'rgba(255,255,255,0)');
+ ctx.fillStyle = cornerSpec;
+ ctx.fillRect(mb.x, mb.y, mb.w, mb.h);
+ ctx.restore();
+
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
- ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.lineWidth = (0.7 / view.s) / wt.scale;
+ ctx.stroke(weightMaskPath);
+ }
+ ctx.restore();
+ return;
+ }
+ const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
+ const base = b.radius;
+ const bagW = Math.max(18, base * 1.6);
+ const bagH = Math.max(20, base * 2.2);
+ const knotW = bagW * 0.36;
+ const knotH = Math.max(5, bagH * 0.14);
+ const topY = b.y - bagH * 0.95;
+ const bagTopY = topY + knotH + 2;
+
+ ctx.save();
+ // Tinsel burst
+ const burstCount = 26;
+ for (let i = 0; i < burstCount; i++) {
+ const a = (Math.PI * 2 * i) / burstCount;
+ const len = bagW * (0.45 + ((i % 5) / 8));
+ const x1 = b.x + Math.cos(a) * 3;
+ const y1 = topY + Math.sin(a) * 3;
+ const x2 = b.x + Math.cos(a) * len;
+ const y2 = topY + Math.sin(a) * len * 0.7;
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.strokeStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},0.9)`;
+ ctx.lineWidth = Math.max(1, 1.8 / view.s);
+ ctx.stroke();
+ if (isBorderEnabled) {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = Math.max(0.6, 0.9 / view.s);
ctx.stroke();
}
- ctx.shadowBlur = 0;
}
- } else {
- // solid fill
+
+ // top loop
ctx.beginPath();
- ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
- ctx.fillStyle = b.color;
- ctx.shadowColor = 'rgba(0,0,0,0.2)';
- ctx.shadowBlur = 10;
+ ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2);
+ if (isBorderEnabled) {
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = Math.max(1.2, 1.8 / view.s);
+ ctx.stroke();
+ }
+ ctx.strokeStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.lineWidth = Math.max(2, 3 / view.s);
+ ctx.stroke();
+
+ // knot
+ ctx.beginPath();
+ ctx.roundRect?.(b.x - knotW/2, topY, knotW, knotH, Math.max(2, knotH * 0.35));
+ if (!ctx.roundRect) {
+ ctx.rect(b.x - knotW/2, topY, knotW, knotH);
+ }
+ ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
ctx.fill();
if (isBorderEnabled) {
ctx.strokeStyle = '#111827';
- ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
+ ctx.lineWidth = Math.max(0.9, 1.3 / view.s);
ctx.stroke();
}
- ctx.shadowBlur = 0;
- }
- if (isShineEnabled) {
- const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
- const sx = b.x - b.radius * SHINE_OFFSET;
- const sy = b.y - b.radius * SHINE_OFFSET;
- const rx = b.radius * SHINE_RX;
- const ry = b.radius * SHINE_RY;
- const rotRad = SHINE_ROT * Math.PI / 180;
- ctx.save();
- ctx.shadowColor = 'rgba(0,0,0,0.1)';
- ctx.shadowBlur = 3; // SHINE_BLUR
- ctx.beginPath();
- if (ctx.ellipse) {
- ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2);
- } else {
- ctx.translate(sx, sy);
- ctx.rotate(rotRad);
- ctx.scale(rx / ry, 1);
- ctx.arc(0, 0, ry, 0, Math.PI * 2);
+ // bag body
+ ctx.beginPath();
+ ctx.moveTo(b.x - bagW * 0.42, bagTopY);
+ ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78);
+ ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78);
+ ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY);
+ ctx.closePath();
+ ctx.fillStyle = `rgb(${rgb.r},${rgb.g},${rgb.b})`;
+ ctx.fill();
+ if (isBorderEnabled) {
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = Math.max(1, 1.4 / view.s);
+ ctx.stroke();
+ }
+ ctx.restore();
+ };
+ const drawMaskedSelectionRing = (b) => {
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const shape = getBalloonMaskShape(sizePreset, activeOrgTab);
+ if (!shape.path) return false;
+ const mb = shape.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
+ const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(scale, scale);
+ ctx.translate(-mb.cx, -mb.cy);
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.lineWidth = (4 / view.s) / scale;
+ ctx.stroke(shape.path);
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = (2 / view.s) / scale;
+ ctx.stroke(shape.path);
+ ctx.restore();
+ return true;
+ };
+ const drawCurlSelectionRing = (b) => {
+ const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
+ const meta = FLAT_COLORS[b.colorIdx] || {};
+ const seed = hashString32(b.id || `${b.x},${b.y}`);
+ const side = (seed & 1) ? 1 : -1;
+ const phase = ((seed >>> 3) % 628) / 100;
+ const len = Math.max(20, b.radius * 2 * ribbonCfg.length);
+ const stem = Math.max(6, b.radius * 0.28);
+ const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
+ const turns = Math.max(1, Math.min(5, ribbonCfg.turns | 0));
+ const anchorX = b.x;
+ const anchorY = b.y - b.radius * 0.55;
+ const width = Math.max(2.6, (2.4 + b.radius * 0.14) / view.s);
+ const isSpiralRibbon = ribbonCfg.style === 'spiral';
+ const steps = isSpiralRibbon ? 42 : 28;
+ const traceCurl = () => {
+ ctx.moveTo(anchorX, anchorY);
+ ctx.lineTo(anchorX, anchorY + stem);
+ if (isSpiralRibbon) {
+ const topLead = Math.max(8, len * 0.28);
+ const topAmp = Math.max(6, b.radius * 0.95);
+ for (let i = 1; i <= Math.floor(steps * 0.33); i++) {
+ const t = i / Math.floor(steps * 0.33);
+ const y = anchorY + stem + topLead * t;
+ const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
+ ctx.lineTo(x, y);
+ }
+ const startY = anchorY + stem + topLead;
+ const remain = Math.max(8, len - topLead);
+ const coilAmp = Math.max(5, b.radius * 0.5);
+ const coilSteps = steps - Math.floor(steps * 0.33);
+ for (let i = 1; i <= coilSteps; i++) {
+ const t = i / coilSteps;
+ const angle = (Math.PI * 2 * turns * t) + phase;
+ const decay = 1 - t * 0.55;
+ const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
+ const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
+ ctx.lineTo(x, y);
+ }
+ return;
}
- ctx.fillStyle = shineFill;
- if (shineStroke) {
- ctx.strokeStyle = shineStroke;
- ctx.lineWidth = 1.5;
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const falloff = 1 - t * 0.25;
+ const y = anchorY + stem + len * t;
+ const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
+ ctx.lineTo(x, y);
+ }
+ };
+ ctx.save();
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ traceCurl();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.lineWidth = width + Math.max(6 / view.s, 3);
ctx.stroke();
- }
- ctx.fill();
- ctx.restore();
- }
+ ctx.beginPath();
+ traceCurl();
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = width + Math.max(3 / view.s, 1.6);
+ ctx.stroke();
+ ctx.restore();
+ // Redraw curl so the selection halo does not visually tint/fill the curl body.
+ drawHeliumRibbon(b, meta, ribbonCfg);
+ return true;
+ };
+ const drawWeightSelectionRing = (b) => {
+ if (weightMaskPath) {
+ const wt = getWeightMaskTransform(b);
+ ctx.save();
+ ctx.translate(b.x, b.y);
+ ctx.scale(wt.scale, wt.scale);
+ ctx.translate(-weightMaskBounds.cx, -weightMaskBounds.cy);
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.lineWidth = (4 / view.s) / wt.scale;
+ ctx.stroke(weightMaskPath);
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = (2 / view.s) / wt.scale;
+ ctx.stroke(weightMaskPath);
+ ctx.restore();
+ return true;
+ }
+ const base = b.radius;
+ const bagW = Math.max(18, base * 1.6);
+ const bagH = Math.max(20, base * 2.2);
+ const knotH = Math.max(5, bagH * 0.14);
+ const topY = b.y - bagH * 0.95;
+ const bagTopY = topY + knotH + 2;
+ const pathWeight = () => {
+ // loop
+ ctx.moveTo(b.x + bagW * 0.18, topY - bagH * 0.2);
+ ctx.ellipse(b.x, topY - bagH * 0.2, bagW * 0.18, bagW * 0.24, 0, 0, Math.PI * 2);
+ // bag
+ ctx.moveTo(b.x - bagW * 0.42, bagTopY);
+ ctx.quadraticCurveTo(b.x - bagW * 0.62, b.y + bagH * 0.05, b.x - bagW * 0.34, b.y + bagH * 0.78);
+ ctx.quadraticCurveTo(b.x, b.y + bagH * 0.98, b.x + bagW * 0.34, b.y + bagH * 0.78);
+ ctx.quadraticCurveTo(b.x + bagW * 0.62, b.y + bagH * 0.05, b.x + bagW * 0.42, bagTopY);
+ ctx.closePath();
+ };
+ ctx.save();
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.beginPath();
+ pathWeight();
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.lineWidth = Math.max(4 / view.s, 2.4);
+ ctx.stroke();
+ ctx.beginPath();
+ pathWeight();
+ ctx.strokeStyle = '#3b82f6';
+ ctx.lineWidth = Math.max(2 / view.s, 1.3);
+ ctx.stroke();
+ ctx.restore();
+ return true;
+ };
+ balloons.forEach(b => {
+ if (b?.kind === 'ribbon') {
+ const meta = FLAT_COLORS[b.colorIdx] || {};
+ drawRibbonObject(b, meta);
+ return;
+ }
+ withObjectRotation(b, () => {
+ if (b?.kind === 'curl260') {
+ const meta = FLAT_COLORS[b.colorIdx] || {};
+ const curlCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
+ drawHeliumRibbon(b, meta, curlCfg);
+ return;
+ }
+ if (b?.kind === 'weight') {
+ const meta = FLAT_COLORS[b.colorIdx] || {};
+ drawWeightObject(b, meta);
+ return;
+ }
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab);
+ const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium');
+ const meta = FLAT_COLORS[b.colorIdx] || {};
+ if (b.image) {
+ const img = getImage(b.image);
+ if (img && img.complete && img.naturalWidth > 0) {
+ const zoom = Math.max(1, meta.imageZoom ?? TEXTURE_ZOOM_DEFAULT);
+ const fx = clamp01(meta.imageFocus?.x ?? TEXTURE_FOCUS_DEFAULT.x);
+ const fy = clamp01(meta.imageFocus?.y ?? TEXTURE_FOCUS_DEFAULT.y);
+
+ const srcW = img.naturalWidth / zoom;
+ const srcH = img.naturalHeight / zoom;
+ const srcX = clamp(fx * img.naturalWidth - srcW/2, 0, img.naturalWidth - srcW);
+ const srcY = clamp(fy * img.naturalHeight - srcH/2, 0, img.naturalHeight - srcH);
+
+ if (useMask && tryDrawMaskedBalloon(b, meta)) {
+ // masked draw succeeded
+ } else {
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
+ ctx.clip();
+ const lum = luminance(meta.hex || b.color);
+ if (lum > 0.6) {
+ const strength = clamp01((lum - 0.6) / 0.4); // more shadow for lighter colors
+ ctx.shadowColor = `rgba(0,0,0,${0.05 + 0.07 * strength})`;
+ ctx.shadowBlur = 4 + 4 * strength;
+ ctx.shadowOffsetY = 1 + 2 * strength;
+ }
+ ctx.drawImage(img, srcX, srcY, srcW, srcH, b.x - b.radius, b.y - b.radius, b.radius * 2, b.radius * 2);
+ if (isBorderEnabled) {
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
+ ctx.stroke();
+ }
+ ctx.restore();
+ }
+ }
+ } else if (useMask && tryDrawMaskedBalloon(b, { hex: b.color })) {
+ // masked draw succeeded
+ } else {
+ ctx.beginPath();
+ ctx.arc(b.x, b.y, b.radius, 0, Math.PI * 2);
+ ctx.fillStyle = b.color;
+ ctx.shadowColor = 'rgba(0,0,0,0.2)';
+ ctx.shadowBlur = 10;
+ ctx.fill();
+ if (isBorderEnabled) {
+ ctx.strokeStyle = '#111827';
+ ctx.lineWidth = Math.max(0.35, 0.5 / view.s);
+ ctx.stroke();
+ }
+ ctx.shadowBlur = 0;
+ }
+ if (isShineEnabled) {
+ const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
+ const shineScale = useMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1;
+ const shineOffsetScale = useMask ? 0.78 : 1;
+ const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
+ const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale;
+ const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale;
+ const rx = visualRadius * SHINE_RX * shineScale;
+ const ry = visualRadius * SHINE_RY * shineScale;
+ const rotRad = SHINE_ROT * Math.PI / 180;
+ ctx.save();
+ ctx.shadowColor = 'rgba(0,0,0,0.1)';
+ ctx.shadowBlur = 3; // SHINE_BLUR
+ ctx.beginPath();
+ if (ctx.ellipse) {
+ ctx.ellipse(sx, sy, rx, ry, rotRad, 0, Math.PI * 2);
+ } else {
+ ctx.translate(sx, sy);
+ ctx.rotate(rotRad);
+ ctx.scale(rx / ry, 1);
+ ctx.arc(0, 0, ry, 0, Math.PI * 2);
+ }
+ ctx.fillStyle = shineFill;
+ if (shineStroke) {
+ ctx.strokeStyle = shineStroke;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+ }
+ ctx.fill();
+ ctx.restore();
+ }
+ });
});
// garland path preview
@@ -879,15 +2097,68 @@
selectedIds.forEach(id => {
const b = balloons.find(bb => bb.id === id);
if (!b) return;
+ if (b.kind === 'ribbon') {
+ if (drawRibbonSelectionRing(b)) return;
+ }
+ withObjectRotation(b, () => {
+ if (b.kind === 'curl260') {
+ if (drawCurlSelectionRing(b)) return;
+ } else if (b.kind === 'weight') {
+ if (drawWeightSelectionRing(b)) return;
+ } else {
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const maskShape = getBalloonMaskShape(sizePreset, activeOrgTab);
+ const useMask = !!(maskShape.path && activeOrgTab === '#tab-helium');
+ if (useMask && drawMaskedSelectionRing(b)) return;
+ }
+ ctx.beginPath();
+ const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
+ ctx.arc(b.x, b.y, visualRadius + 3, 0, Math.PI * 2);
+ ctx.lineWidth = 4 / view.s;
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
+ ctx.stroke();
+ ctx.lineWidth = 2 / view.s;
+ ctx.strokeStyle = '#3b82f6';
+ ctx.stroke();
+ });
+ });
+ ctx.restore();
+ }
+
+ if (activeOrgTab === '#tab-helium' && mode === 'draw' && heliumPlacementType === 'ribbon') {
+ // Show allowed connection nodes (balloon nozzles + weights) while in ribbon mode.
+ ctx.save();
+ const nodeR = Math.max(3, 4 / view.s);
+ balloons.forEach(b => {
+ if (b?.kind !== 'balloon' && b?.kind !== 'weight') return;
+ const p = getRibbonNodePosition(b);
+ if (!p) return;
ctx.beginPath();
- ctx.arc(b.x, b.y, b.radius + 3, 0, Math.PI * 2);
- ctx.lineWidth = 4 / view.s;
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
- ctx.stroke();
- ctx.lineWidth = 2 / view.s;
- ctx.strokeStyle = '#3b82f6';
+ ctx.arc(p.x, p.y, nodeR, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(59,130,246,0.65)';
+ ctx.fill();
+ ctx.strokeStyle = 'rgba(255,255,255,0.95)';
+ ctx.lineWidth = Math.max(1 / view.s, 0.7);
ctx.stroke();
});
+ if (ribbonDraftStart) {
+ const draftStartPos = resolveRibbonEndpoint(ribbonDraftStart);
+ const draftEndPos = ribbonDraftMouse || mousePos;
+ if (draftStartPos && draftEndPos) {
+ const draftRibbon = {
+ kind: 'ribbon',
+ from: ribbonDraftStart,
+ to: null,
+ freeEnd: { x: draftEndPos.x, y: draftEndPos.y },
+ color: '#60a5fa',
+ colorIdx: selectedColorIdx
+ };
+ ctx.globalAlpha = 0.9;
+ drawRibbonObject(draftRibbon, FLAT_COLORS[selectedColorIdx] || {});
+ ctx.globalAlpha = 1;
+ }
+ }
ctx.restore();
}
@@ -954,8 +2225,16 @@
// ====== State Persistence ======
const APP_STATE_KEY = 'obd:state:v3';
+ const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
+ const getActiveOrganicTab = () => {
+ const active = document.body?.dataset?.activeTab || localStorage.getItem(ACTIVE_TAB_KEY) || '#tab-organic';
+ return (active === '#tab-helium') ? '#tab-helium' : '#tab-organic';
+ };
+ const getAppStateKey = (tabId) => (tabId === '#tab-helium')
+ ? `${APP_STATE_KEY}:helium`
+ : `${APP_STATE_KEY}:organic`;
- function saveAppState() {
+ function saveAppState(tabId = getActiveOrganicTab()) {
// Note: isShineEnabled is managed globally.
const state = {
balloons,
@@ -967,16 +2246,52 @@
garlandDensity,
garlandMainIdx,
garlandAccentIdx,
- isBorderEnabled
+ isBorderEnabled,
+ heliumPlacementType
};
- try { localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); } catch {}
+ try { localStorage.setItem(getAppStateKey(tabId), JSON.stringify(state)); } catch {}
}
const persist = (() => { let t; return () => { clearTimeout(t); t = setTimeout(saveAppState, 120); }; })();
- function loadAppState() {
+ function resetAppState() {
+ balloons = [];
+ selectedColorIdx = 0;
+ currentDiameterInches = 11;
+ currentRadius = inchesToRadiusPx(currentDiameterInches);
+ eraserRadius = parseInt(eraserSizeInput?.value || '40', 10);
+ view = { s: 1, tx: 0, ty: 0 };
+ usedSortDesc = true;
+ garlandPath = [];
+ garlandDensity = 1;
+ garlandMainIdx = [selectedColorIdx];
+ garlandAccentIdx = -1;
+ isBorderEnabled = true;
+ heliumPlacementType = 'balloon';
+ if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
+ selectedIds.clear();
+ setMode('draw');
+ }
+
+ function loadAppState(tabId = getActiveOrganicTab()) {
try {
- const s = JSON.parse(localStorage.getItem(APP_STATE_KEY) || '{}');
+ const raw = localStorage.getItem(getAppStateKey(tabId));
+ if (!raw) {
+ resetAppState();
+ updateCurrentColorChip();
+ return;
+ }
+ const s = JSON.parse(raw || '{}');
if (Array.isArray(s.balloons)) balloons = s.balloons;
+ if (Array.isArray(balloons)) {
+ balloons.forEach(b => {
+ if (!b || typeof b !== 'object') return;
+ b.rotationDeg = normalizeRotationDeg(b.rotationDeg);
+ if (b.kind === 'curl260') {
+ const cfg = getCurlObjectConfig(b);
+ if (cfg) b.curl = cfg;
+ }
+ });
+ }
if (typeof s.selectedColorIdx === 'number') selectedColorIdx = s.selectedColorIdx;
if (typeof s.currentDiameterInches === 'number') {
currentDiameterInches = s.currentDiameterInches;
@@ -1004,7 +2319,12 @@
if (typeof s.garlandAccentIdx === 'number') garlandAccentIdx = s.garlandAccentIdx;
if (typeof s.isBorderEnabled === 'boolean') isBorderEnabled = s.isBorderEnabled;
if (toggleBorderCheckbox) toggleBorderCheckbox.checked = isBorderEnabled;
+ if (s.heliumPlacementType === 'balloon' || s.heliumPlacementType === 'curl260' || s.heliumPlacementType === 'weight' || s.heliumPlacementType === 'ribbon') {
+ heliumPlacementType = s.heliumPlacementType;
+ }
+ syncHeliumPlacementUi();
updateCurrentColorChip();
+ updateSelectButtons();
} catch {}
}
@@ -1182,10 +2502,11 @@
updateChip('current-color-chip', 'current-color-label', { showLabel: true });
updateChip('current-color-chip-global', 'current-color-label-global', { showLabel: false });
updateChip('mobile-active-color-chip', null, { showLabel: false });
+ updateChip('quick-color-chip', null, { showLabel: false });
}
function bindActiveChipPicker() {
- const chips = ['current-color-chip', 'mobile-active-color-chip'];
+ const chips = ['current-color-chip', 'mobile-active-color-chip', 'quick-color-chip', 'quick-color-btn'];
chips.forEach(id => {
const el = document.getElementById(id);
if (!el) return;
@@ -1229,14 +2550,124 @@
// ====== Balloon Ops & Data/Export ======
function buildBalloon(meta, x, y, radius) {
- return {
+ const b = {
+ kind: 'balloon',
x, y,
radius,
+ rotationDeg: 0,
color: meta.hex,
image: meta.image || null,
colorIdx: meta._idx,
id: makeId()
};
+ return b;
+ }
+ function buildCurlObject(meta, x, y, radius) {
+ return {
+ kind: 'curl260',
+ x, y,
+ radius,
+ rotationDeg: 0,
+ color: meta.hex,
+ image: null,
+ colorIdx: meta._idx,
+ id: makeId(),
+ curl: buildCurrentRibbonConfig()
+ };
+ }
+ function buildWeightObject(meta, x, y, radius) {
+ return {
+ kind: 'weight',
+ x, y,
+ radius,
+ rotationDeg: 0,
+ color: meta.hex,
+ image: null,
+ colorIdx: meta._idx,
+ id: makeId()
+ };
+ }
+ function buildRibbonObject(meta, fromNode, toNode, freeEnd) {
+ return {
+ kind: 'ribbon',
+ color: meta.hex,
+ image: null,
+ colorIdx: meta._idx,
+ id: makeId(),
+ from: fromNode ? { kind: fromNode.kind, id: fromNode.id } : null,
+ to: toNode ? { kind: toNode.kind, id: toNode.id } : null,
+ freeEnd: freeEnd ? { x: freeEnd.x, y: freeEnd.y } : null,
+ lengthScale: 1,
+ rotationDeg: 0
+ };
+ }
+ function resetRibbonDraft() {
+ ribbonDraftStart = null;
+ ribbonDraftMouse = null;
+ }
+ function handleRibbonPlacementAt(x, y) {
+ const meta = FLAT_COLORS[selectedColorIdx] || FLAT_COLORS[0];
+ if (!meta) return;
+ const node = findRibbonNodeAt(x, y);
+ if (!ribbonDraftStart) {
+ if (!node || node.kind !== 'balloon') {
+ showModal('Start ribbon on a balloon nozzle.');
+ return;
+ }
+ ribbonDraftStart = { kind: node.kind, id: node.id };
+ ribbonDraftMouse = { x, y };
+ requestDraw();
+ return;
+ }
+ const startObj = balloons.find(b => b.id === ribbonDraftStart.id);
+ if (!startObj) { resetRibbonDraft(); return; }
+ const targetNode = node && node.id !== ribbonDraftStart.id ? node : null;
+ if (targetNode && targetNode.kind !== 'weight') {
+ showModal('Ribbon can connect from balloon nozzle to a weight.');
+ return;
+ }
+ if (balloons.length >= MAX_BALLOONS) {
+ showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
+ resetRibbonDraft();
+ return;
+ }
+ const ribbon = buildRibbonObject(meta, ribbonDraftStart, targetNode, targetNode ? null : { x, y });
+ balloons.push(ribbon);
+ resetRibbonDraft();
+ refreshAll();
+ pushHistory();
+ }
+ function scaleSelectedRibbons(multiplier) {
+ const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
+ if (!sel.length) return;
+ sel.forEach(r => {
+ const base = Number(r.lengthScale) || 1;
+ r.lengthScale = clamp(base * multiplier, 0.4, 2.2);
+ });
+ refreshAll();
+ pushHistory();
+ }
+ function startAttachSelectedRibbonsToWeight() {
+ const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
+ if (!sel.length) return;
+ ribbonAttachMode = true;
+ showModal('Attach mode: click a weight to connect selected ribbon(s).');
+ }
+ function attachSelectedRibbonsToWeight(weightId) {
+ const sel = selectionBalloons().filter(b => b?.kind === 'ribbon');
+ if (!sel.length) return false;
+ let changed = false;
+ sel.forEach(r => {
+ r.to = { kind: 'weight', id: weightId };
+ r.freeEnd = null;
+ changed = true;
+ });
+ ribbonAttachMode = false;
+ if (changed) {
+ refreshAll();
+ pushHistory();
+ }
+ return changed;
}
function addBalloon(x, y) {
@@ -1246,7 +2677,14 @@
showModal(`Balloon limit reached (${MAX_BALLOONS}). Delete some to add more.`);
return;
}
- balloons.push(buildBalloon(meta, x, y, currentRadius));
+ const isHelium = getActiveOrganicTab() === '#tab-helium';
+ const placingCurl = isHelium && heliumPlacementType === 'curl260';
+ const placingWeight = isHelium && heliumPlacementType === 'weight';
+ balloons.push(
+ placingCurl
+ ? buildCurlObject(meta, x, y, currentRadius)
+ : (placingWeight ? buildWeightObject(meta, x, y, currentRadius) : buildBalloon(meta, x, y, currentRadius))
+ );
lastAddStatus = 'balloon';
evtStats.addBalloon += 1;
ensureVisibleAfterAdd(balloons[balloons.length - 1]);
@@ -1388,7 +2826,29 @@
function findBalloonIndexAt(x, y) {
for (let i = balloons.length - 1; i >= 0; i--) {
const b = balloons[i];
- if (Math.hypot(x - b.x, y - b.y) <= b.radius) return i;
+ if (b?.kind === 'ribbon') {
+ const hit = ribbonDistanceToPoint(b, x, y);
+ if (hit <= Math.max(8 / view.s, 5)) return i;
+ continue;
+ }
+ if (b?.kind === 'weight') {
+ const grabR = Math.max(b.radius * 2.1, 22);
+ if (Math.hypot(x - b.x, y - b.y) <= grabR) return i;
+ if (weightHitTest(b, x, y, 0, { loose: true })) return i;
+ continue;
+ }
+ const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab());
+ if (Math.hypot(x - b.x, y - b.y) <= hitR) return i;
+ }
+ return -1;
+ }
+ function findWeightIndexAt(x, y) {
+ for (let i = balloons.length - 1; i >= 0; i--) {
+ const b = balloons[i];
+ if (b?.kind !== 'weight') continue;
+ const grabR = Math.max(b.radius * 2.1, 22);
+ if (Math.hypot(x - b.x, y - b.y) <= grabR) return i;
+ if (weightHitTest(b, x, y, 0, { loose: true })) return i;
}
return -1;
}
@@ -1404,7 +2864,17 @@
function moveSelected(dx, dy) {
const sel = selectionBalloons();
if (!sel.length) return;
- sel.forEach(b => { b.x += dx; b.y += dy; });
+ sel.forEach(b => {
+ if (b?.kind === 'ribbon') {
+ if (b.freeEnd) {
+ b.freeEnd.x += dx;
+ b.freeEnd.y += dy;
+ }
+ return;
+ }
+ b.x += dx;
+ b.y += dy;
+ });
refreshAll();
pushHistory();
}
@@ -1414,7 +2884,13 @@
if (!sel.length) return;
const diam = clamp(newDiamInches, 5, 32);
const newRadius = inchesToRadiusPx(diam);
- sel.forEach(b => { b.radius = newRadius; });
+ let changed = false;
+ sel.forEach(b => {
+ if (!Number.isFinite(b.radius)) return;
+ b.radius = newRadius;
+ changed = true;
+ });
+ if (!changed) return;
refreshAll();
updateSelectButtons();
resizeChanged = true;
@@ -1426,6 +2902,17 @@
}
}, 200);
}
+ function rotateSelected(deltaDeg, { absolute = false } = {}) {
+ const sel = selectionBalloons();
+ if (!sel.length) return;
+ sel.forEach(b => {
+ if (b?.kind === 'ribbon') return;
+ const next = absolute ? Number(deltaDeg || 0) : ((Number(b.rotationDeg) || 0) + Number(deltaDeg || 0));
+ b.rotationDeg = normalizeRotationDeg(next);
+ });
+ refreshAll();
+ pushHistory();
+ }
function bringSelectedForward() {
const sel = selectionArray();
@@ -1476,7 +2963,25 @@
function duplicateSelected() {
const sel = selectionBalloons();
if (!sel.length) return;
- const copies = sel.map(b => ({ ...b, x: b.x + 10, y: b.y + 10, id: makeId() }));
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ sel.forEach(b => {
+ const bb = getObjectBounds(b);
+ minX = Math.min(minX, bb.minX);
+ maxX = Math.max(maxX, bb.maxX);
+ minY = Math.min(minY, bb.minY);
+ maxY = Math.max(maxY, bb.maxY);
+ });
+ const width = Math.max(1, maxX - minX);
+ const dx = width + 18;
+ const dy = 0;
+ const copies = sel.map(b => {
+ const c = { ...b, id: makeId() };
+ if (Number.isFinite(c.x) && Number.isFinite(c.y)) { c.x += dx; c.y += dy; }
+ if (c.freeEnd && Number.isFinite(c.freeEnd.x) && Number.isFinite(c.freeEnd.y)) {
+ c.freeEnd = { x: c.freeEnd.x + dx, y: c.freeEnd.y + dy };
+ }
+ return c;
+ });
copies.forEach(c => balloons.push(c));
selectedIds = new Set(copies.map(c => c.id));
refreshAll({ autoFit: true });
@@ -1486,7 +2991,12 @@
function eraseAt(x, y) {
const before = balloons.length;
- balloons = balloons.filter(b => Math.hypot(x - b.x, y - b.y) > eraserRadius);
+ balloons = balloons.filter(b => {
+ if (b?.kind === 'ribbon') return ribbonDistanceToPoint(b, x, y) > Math.max(eraserRadius * 0.5, 6 / view.s);
+ if (b?.kind === 'weight') return !weightHitTest(b, x, y, eraserRadius * 0.8);
+ const hitR = (b?.kind === 'curl260') ? (b.radius * 1.35) : getBalloonVisualRadius(b, getActiveOrganicTab());
+ return Math.hypot(x - b.x, y - b.y) > (eraserRadius + hitR * 0.15);
+ });
const removed = balloons.length !== before;
if (selectedIds.size) {
const set = new Set(balloons.map(b => b.id));
@@ -1574,7 +3084,7 @@
}
// ====== Export helpers ======
- let lastActiveTab = '#tab-organic';
+ let lastActiveTab = getActiveOrganicTab();
const getImageHref = getImageHrefShared;
function setImageHref(el, val) {
@@ -1597,10 +3107,70 @@
return svgEl;
}
+ function pointsToPathD(points) {
+ if (!Array.isArray(points) || points.length < 2) return '';
+ let d = `M ${points[0].x} ${points[0].y}`;
+ for (let i = 1; i < points.length; i++) d += ` L ${points[i].x} ${points[i].y}`;
+ return d;
+ }
+
+ function buildCurlPathPoints(b, ribbonCfg) {
+ const cfg = ribbonCfg || buildCurrentRibbonConfig();
+ const seed = hashString32(b.id || `${b.x},${b.y}`);
+ const side = (seed & 1) ? 1 : -1;
+ const phase = ((seed >>> 3) % 628) / 100;
+ const len = Math.max(20, b.radius * 2 * cfg.length);
+ const stem = Math.max(6, b.radius * 0.28);
+ const amp = Math.max(5, b.radius * (0.16 + (((seed >>> 6) % 6) * 0.012)));
+ const turns = Math.max(1, Math.min(5, cfg.turns | 0));
+ const anchorX = b.x;
+ const anchorY = b.y - b.radius * 0.55;
+ const isSpiral = cfg.style === 'spiral';
+ const steps = isSpiral ? 42 : 28;
+ const pts = [{ x: anchorX, y: anchorY }, { x: anchorX, y: anchorY + stem }];
+ if (isSpiral) {
+ const leadSteps = Math.floor(steps * 0.33);
+ const topLead = Math.max(8, len * 0.28);
+ const topAmp = Math.max(6, b.radius * 0.95);
+ for (let i = 1; i <= leadSteps; i++) {
+ const t = i / leadSteps;
+ const y = anchorY + stem + topLead * t;
+ const x = anchorX + side * topAmp * Math.sin((Math.PI * 0.65 * t) + 0.15);
+ pts.push({ x, y });
+ }
+ const startY = anchorY + stem + topLead;
+ const remain = Math.max(8, len - topLead);
+ const coilAmp = Math.max(5, b.radius * 0.5);
+ const coilSteps = Math.max(1, steps - leadSteps);
+ for (let i = 1; i <= coilSteps; i++) {
+ const t = i / coilSteps;
+ const angle = (Math.PI * 2 * turns * t) + phase;
+ const decay = 1 - t * 0.55;
+ const x = anchorX + side * (coilAmp * decay) * Math.sin(angle);
+ const y = startY + remain * t + (coilAmp * 0.42 * decay) * Math.cos(angle);
+ pts.push({ x, y });
+ }
+ } else {
+ for (let i = 1; i <= steps; i++) {
+ const t = i / steps;
+ const falloff = 1 - t * 0.25;
+ const y = anchorY + stem + len * t;
+ const x = anchorX + Math.sin((Math.PI * 2 * turns * t) + phase) * amp * falloff * side;
+ pts.push({ x, y });
+ }
+ }
+ return pts;
+ }
+
async function buildOrganicSvgPayload() {
if (balloons.length === 0) throw new Error('Canvas is empty. Add some balloons first.');
- const uniqueImageUrls = [...new Set(balloons.map(b => b.image).filter(Boolean))];
+ const activeOrgTab = getActiveOrganicTab();
+ const needsWeightImage = balloons.some(b => b?.kind === 'weight');
+ const uniqueImageUrls = [...new Set([
+ ...balloons.map(b => b.image).filter(Boolean),
+ ...(needsWeightImage ? [WEIGHT_IMAGE_URL] : [])
+ ])];
const dataUrlMap = new Map();
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
@@ -1614,6 +3184,7 @@
let elements = '';
const patterns = new Map();
const shadowFilters = new Map();
+ let clipCounter = 0;
const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
const key = `${dx}|${dy}|${blurPx}|${alpha}`;
@@ -1632,9 +3203,113 @@
return shadowFilters.get(key);
};
const shineShadowId = ensureShadowFilter(0, 0, 3, 0.1);
+ const weightImageHref = dataUrlMap.get(WEIGHT_IMAGE_URL) || WEIGHT_IMAGE_URL;
+ const ensureClipPath = (pathD, transform = '') => {
+ const id = `clip-${clipCounter++}`;
+ const tAttr = transform ? ` transform="${transform}"` : '';
+ defs += ``;
+ return id;
+ };
+ const wrapWithRotation = (markup, b) => {
+ const deg = Number(b?.rotationDeg) || 0;
+ if (!deg) return markup;
+ return `${markup}`;
+ };
balloons.forEach(b => {
+ const kind = b?.kind || 'balloon';
const meta = FLAT_COLORS[b.colorIdx] || {};
+ if (kind === 'ribbon') {
+ const pts = getRibbonPoints(b);
+ if (!pts || pts.length < 2) return;
+ const d = pointsToPathD(pts);
+ const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 };
+ const w = Math.max(1.9, 2.2);
+ if (isBorderEnabled) {
+ elements += ``;
+ }
+ elements += ``;
+ if (b?.from?.kind === 'balloon') {
+ const p0 = pts[0];
+ const p1 = pts[Math.min(1, pts.length - 1)];
+ const vx = p1.x - p0.x;
+ const vy = p1.y - p0.y;
+ const vl = Math.max(1e-6, Math.hypot(vx, vy));
+ const ux = vx / vl;
+ const uy = vy / vl;
+ const nx = -uy;
+ const ny = ux;
+ const side = (hashString32(b.id || '') & 1) ? 1 : -1;
+ const amp = Math.max(3.2, 2.3);
+ const len = Math.max(12, 8.5);
+ const steps = 14;
+ const curlPts = [];
+ for (let i = 0; i <= steps; i++) {
+ const t = i / steps;
+ const dd = len * t;
+ const x = p0.x + ux * dd + nx * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
+ const y = p0.y + uy * dd + ny * side * Math.sin(t * Math.PI * 2.2) * amp * (1 - t * 0.2);
+ curlPts.push({ x, y });
+ }
+ const curlD = pointsToPathD(curlPts);
+ if (curlD) {
+ if (isBorderEnabled) {
+ elements += ``;
+ }
+ elements += ``;
+ }
+ }
+ return;
+ }
+ if (kind === 'curl260') {
+ const ribbonCfg = getCurlObjectConfig(b) || buildCurrentRibbonConfig();
+ const pts = buildCurlPathPoints(b, ribbonCfg);
+ const d = pointsToPathD(pts);
+ if (!d) return;
+ const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#999999')) || { r: 120, g: 120, b: 120 };
+ const w = Math.max(2.6, 2.4 + b.radius * 0.14);
+ let markup = '';
+ if (isBorderEnabled) {
+ markup += ``;
+ }
+ markup += ``;
+ elements += wrapWithRotation(markup, b);
+ return;
+ }
+ if (kind === 'weight') {
+ const rgb = hexToRgb(normalizeHex(meta?.hex || b.color || '#808080')) || { r: 128, g: 128, b: 128 };
+ if (weightMaskPathData) {
+ const wt = getWeightMaskTransform(b);
+ const mb = weightMaskBounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const pathTx = `translate(${b.x} ${b.y}) scale(${wt.scale}) translate(${-mb.cx} ${-mb.cy})`;
+ const clipId = ensureClipPath(weightMaskPathData, pathTx);
+ let markup = '';
+ if (weightImageHref) {
+ markup += ``;
+ markup += ``;
+ } else {
+ markup += ``;
+ }
+ if (isBorderEnabled) {
+ markup += ``;
+ }
+ elements += wrapWithRotation(markup, b);
+ return;
+ }
+ const base = b.radius;
+ const bagW = Math.max(18, base * 1.6);
+ const bagH = Math.max(20, base * 2.2);
+ const bagTopY = b.y - bagH * 0.95 + Math.max(5, bagH * 0.14) + 2;
+ const bagD = `M ${b.x - bagW * 0.42} ${bagTopY}
+ Q ${b.x - bagW * 0.62} ${b.y + bagH * 0.05} ${b.x - bagW * 0.34} ${b.y + bagH * 0.78}
+ Q ${b.x} ${b.y + bagH * 0.98} ${b.x + bagW * 0.34} ${b.y + bagH * 0.78}
+ Q ${b.x + bagW * 0.62} ${b.y + bagH * 0.05} ${b.x + bagW * 0.42} ${bagTopY} Z`;
+ let markup = ``;
+ if (isBorderEnabled) markup += ``;
+ elements += wrapWithRotation(markup, b);
+ return;
+ }
+
let fill = b.color;
if (b.image) {
const patternKey = `${b.colorIdx}|${b.image}`;
@@ -1670,18 +3345,50 @@
const id = ensureShadowFilter(0, 0, 10, 0.2);
filterAttr = ` filter="url(#${id})"`;
}
- const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
- elements += ``;
+ const sizeIndex = radiusToSizeIndex(b.radius);
+ const sizePreset = SIZE_PRESETS[sizeIndex] ?? 11;
+ const maskInfo = getBalloonMaskShape(sizePreset, activeOrgTab);
+ const hasMask = activeOrgTab === '#tab-helium' && !!maskInfo.path;
+ const maskPathD = (activeOrgTab === '#tab-helium' && sizePreset === 24) ? balloon24MaskPathData : balloonMaskPathData;
+ if (hasMask && maskPathD) {
+ const mb = maskInfo.bounds || { x: 0, y: 0, w: 1, h: 1, cx: 0.5, cy: 0.5 };
+ const heliumBoost = getHeliumVolumeVisualBoost(sizePreset, activeOrgTab);
+ const scale = ((b.radius * 2) / Math.max(1, mb.w)) * heliumBoost;
+ const pathTx = `translate(${b.x} ${b.y}) scale(${scale}) translate(${-mb.cx} ${-mb.cy})`;
+ const clipId = ensureClipPath(maskPathD, pathTx);
+ const destX = b.x - (mb.cx - mb.x) * scale;
+ const destY = b.y - (mb.cy - mb.y) * scale;
+ const destW = Math.max(1, mb.w * scale);
+ const destH = Math.max(1, mb.h * scale);
+ let markup = '';
+ if (b.image) {
+ const imageHref = dataUrlMap.get(b.image) || b.image;
+ markup += ``;
+ } else {
+ markup += ``;
+ }
+ if (isBorderEnabled) {
+ markup += ``;
+ }
+ elements += wrapWithRotation(markup, b);
+ } else {
+ const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
+ elements += wrapWithRotation(``, b);
+ }
if (isShineEnabled) {
- const sx = b.x - b.radius * SHINE_OFFSET;
- const sy = b.y - b.radius * SHINE_OFFSET;
- const rx = b.radius * SHINE_RX;
- const ry = b.radius * SHINE_RY;
+ const visualRadius = getBalloonVisualRadius(b, activeOrgTab);
+ const shineScale = hasMask ? ((sizePreset === 11) ? 0.68 : 0.8) : 1;
+ const shineOffsetScale = hasMask ? 0.78 : 1;
+ const sx = b.x - visualRadius * SHINE_OFFSET * shineOffsetScale;
+ const sy = b.y - visualRadius * SHINE_OFFSET * shineOffsetScale;
+ const rx = visualRadius * SHINE_RX * shineScale;
+ const ry = visualRadius * SHINE_RY * shineScale;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : '';
- elements += ``;
+ const shineMarkup = ``;
+ elements += wrapWithRotation(shineMarkup, b);
}
});
@@ -1749,10 +3456,11 @@
if (balloons.length === 0) return { minX: 0, minY: 0, maxX: 500, maxY: 500, w: 500, h: 500 };
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const b of balloons) {
- minX = Math.min(minX, b.x - b.radius);
- minY = Math.min(minY, b.y - b.radius);
- maxX = Math.max(maxX, b.x + b.radius);
- maxY = Math.max(maxY, b.y + b.radius);
+ const bb = getObjectBounds(b);
+ minX = Math.min(minX, bb.minX);
+ minY = Math.min(minY, bb.minY);
+ maxX = Math.max(maxX, bb.maxX);
+ maxY = Math.max(maxY, bb.maxY);
}
return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY };
}
@@ -1778,10 +3486,11 @@
}
function balloonScreenBounds(b) {
- const left = (b.x - b.radius + view.tx) * view.s;
- const right = (b.x + b.radius + view.tx) * view.s;
- const top = (b.y - b.radius + view.ty) * view.s;
- const bottom = (b.y + b.radius + view.ty) * view.s;
+ const bb = getObjectBounds(b);
+ const left = (bb.minX + view.tx) * view.s;
+ const right = (bb.maxX + view.tx) * view.s;
+ const top = (bb.minY + view.ty) * view.s;
+ const bottom = (bb.maxY + view.ty) * view.s;
return { left, right, top, bottom };
}
@@ -1791,8 +3500,9 @@
const ch = canvas.height / dpr;
// zoom out only if needed to keep the new balloon visible
- const needSx = (cw - 2*pad) / (2*b.radius);
- const needSy = (ch - 2*pad) / (2*b.radius);
+ const bb = getObjectBounds(b);
+ const needSx = (cw - 2*pad) / Math.max(1, bb.w);
+ const needSy = (ch - 2*pad) / Math.max(1, bb.h);
const sNeeded = Math.min(needSx, needSy);
if (isFinite(sNeeded) && sNeeded > 0 && sNeeded < view.s) {
view.s = Math.max(VIEW_MIN_SCALE, sNeeded);
@@ -1880,16 +3590,52 @@
bringForwardBtn?.addEventListener('click', bringSelectedForward);
sendBackwardBtn?.addEventListener('click', sendSelectedBackward);
+ rotateSelectedLeftBtn?.addEventListener('click', () => rotateSelected(-15));
+ rotateSelectedResetBtn?.addEventListener('click', () => rotateSelected(0, { absolute: true }));
+ rotateSelectedRightBtn?.addEventListener('click', () => rotateSelected(15));
+ ribbonLengthDownBtn?.addEventListener('click', () => scaleSelectedRibbons(0.9));
+ ribbonLengthUpBtn?.addEventListener('click', () => scaleSelectedRibbons(1.1));
+ ribbonAttachWeightBtn?.addEventListener('click', startAttachSelectedRibbonsToWeight);
applyColorBtn?.addEventListener('click', applyColorToSelected);
fitViewBtn?.addEventListener('click', () => refreshAll({ refit: true }));
+ heliumPlaceBalloonBtn?.addEventListener('click', () => {
+ heliumPlacementType = 'balloon';
+ resetRibbonDraft();
+ syncHeliumPlacementUi();
+ persist();
+ });
+ heliumPlaceCurlBtn?.addEventListener('click', () => {
+ heliumPlacementType = 'curl260';
+ resetRibbonDraft();
+ syncHeliumPlacementUi();
+ persist();
+ });
+ heliumPlaceRibbonBtn?.addEventListener('click', () => {
+ heliumPlacementType = 'ribbon';
+ resetRibbonDraft();
+ syncHeliumPlacementUi();
+ persist();
+ });
+ heliumPlaceWeightBtn?.addEventListener('click', () => {
+ heliumPlacementType = 'weight';
+ resetRibbonDraft();
+ syncHeliumPlacementUi();
+ persist();
+ });
document.addEventListener('keydown', e => {
if (document.activeElement && document.activeElement.tagName === 'INPUT') return;
if (e.key === 'e' || e.key === 'E') setMode('erase');
else if (e.key === 'v' || e.key === 'V') setMode('draw');
else if (e.key === 's' || e.key === 'S') setMode('select');
- else if (e.key === 'g' || e.key === 'G') setMode('garland');
+ else if ((e.key === 'g' || e.key === 'G') && getActiveOrganicTab() !== '#tab-helium') setMode('garland');
else if (e.key === 'Escape') {
+ if (ribbonAttachMode) {
+ ribbonAttachMode = false;
+ updateSelectButtons();
+ requestDraw();
+ return;
+ }
if (selectedIds.size) {
clearSelection();
} else if (mode !== 'draw') {
@@ -1906,6 +3652,10 @@
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'd') {
e.preventDefault();
duplicateSelected();
+ } else if (e.key === '[') {
+ if (selectedIds.size) { e.preventDefault(); rotateSelected(-15); }
+ } else if (e.key === ']') {
+ if (selectedIds.size) { e.preventDefault(); rotateSelected(15); }
}
});
@@ -2111,8 +3861,10 @@
btn.addEventListener('click', () => {
currentDiameterInches = di;
currentRadius = inchesToRadiusPx(di);
+ if (getActiveOrganicTab() === '#tab-helium') heliumPlacementType = 'balloon';
[...sizePresetGroup.querySelectorAll('button')].forEach(b => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
+ syncHeliumPlacementUi();
persist();
});
sizePresetGroup?.appendChild(btn);
@@ -2132,6 +3884,7 @@
window.WallDesigner?.init?.();
setMode('draw');
updateSelectButtons();
+ syncHeliumPlacementUi();
populateReplaceTo();
populateGarlandColorSelects();
@@ -2159,7 +3912,27 @@
const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall');
const tabBtns = document.querySelectorAll('#mode-tabs .tab-btn');
- const ACTIVE_TAB_KEY = 'balloonDesigner:activeTab:v1';
+ const handleOrganicTabChange = (nextTab) => {
+ if (nextTab === lastActiveTab) return;
+ saveAppState(lastActiveTab);
+ lastActiveTab = nextTab;
+ loadAppState(lastActiveTab);
+ resetHistory();
+ refreshAll({ refit: true });
+ renderUsedPalette();
+ updateSelectButtons();
+ renderGarlandMainChips();
+ updateAccentChip();
+ syncHeliumToolUi();
+ syncHeliumPlacementUi();
+ };
+ const observer = new MutationObserver(() => {
+ const next = getActiveOrganicTab();
+ handleOrganicTabChange(next);
+ });
+ if (document.body) observer.observe(document.body, { attributes: true, attributeFilter: ['data-active-tab'] });
+ syncHeliumToolUi();
+ syncHeliumPlacementUi();
// Tab/mobile logic lives in script.js
});
diff --git a/script.js b/script.js
index a1f2f7a..8785c2e 100644
--- a/script.js
+++ b/script.js
@@ -28,9 +28,11 @@
const orgSheet = document.getElementById('controls-panel');
const claSheet = document.getElementById('classic-controls-panel');
const wallSheet = document.getElementById('wall-controls-panel');
+ const heliumSheet = document.getElementById('helium-controls-panel');
const orgSection = document.getElementById('tab-organic');
const claSection = document.getElementById('tab-classic');
const wallSection = document.getElementById('tab-wall');
+ const heliumSection = document.getElementById('tab-helium');
const tabBtns = Array.from(document.querySelectorAll('#mode-tabs .tab-btn'));
const mobileActionBar = document.getElementById('mobile-action-bar');
@@ -383,12 +385,14 @@
const classicVisible = !document.getElementById('tab-classic')?.classList.contains('hidden');
const organicVisible = !document.getElementById('tab-organic')?.classList.contains('hidden');
const wallVisible = !document.getElementById('tab-wall')?.classList.contains('hidden');
+ const heliumVisible = !document.getElementById('tab-helium')?.classList.contains('hidden');
let id = bodyActive || activeBtn?.dataset?.target;
if (!id) {
- if (classicVisible && !organicVisible && !wallVisible) id = '#tab-classic';
- else if (organicVisible && !classicVisible && !wallVisible) id = '#tab-organic';
- else if (wallVisible && !classicVisible && !organicVisible) id = '#tab-wall';
+ if (classicVisible && !organicVisible && !wallVisible && !heliumVisible) id = '#tab-classic';
+ else if (organicVisible && !classicVisible && !wallVisible && !heliumVisible) id = '#tab-organic';
+ else if (wallVisible && !classicVisible && !organicVisible && !heliumVisible) id = '#tab-wall';
+ else if (heliumVisible && !classicVisible && !organicVisible && !wallVisible) id = '#tab-helium';
}
if (!id) id = '#tab-organic';
if (document.body) document.body.dataset.activeTab = id;
@@ -399,17 +403,30 @@
function updateSheets() {
const tab = detectCurrentTab();
const hide = !window.matchMedia('(min-width: 1024px)').matches && document.body?.dataset?.controlsHidden === '1';
- if (orgSheet) orgSheet.classList.toggle('hidden', hide || tab !== '#tab-organic');
+ const usesOrganicWorkspace = tab === '#tab-organic' || tab === '#tab-helium';
+ if (orgSheet) orgSheet.classList.toggle('hidden', hide || !usesOrganicWorkspace);
if (claSheet) claSheet.classList.toggle('hidden', hide || tab !== '#tab-classic');
if (wallSheet) wallSheet.classList.toggle('hidden', hide || tab !== '#tab-wall');
+ if (heliumSheet) heliumSheet.classList.add('hidden');
}
- function updateMobileStacks(tabName) {
+ function getCurrentMobilePanel(currentTab) {
const orgPanel = document.getElementById('controls-panel');
const claPanel = document.getElementById('classic-controls-panel');
const wallPanel = document.getElementById('wall-controls-panel');
+ const heliumPanel = document.getElementById('helium-controls-panel');
+ if (currentTab === '#tab-classic') return claPanel;
+ if (currentTab === '#tab-wall') return wallPanel;
+ if (currentTab === '#tab-helium') {
+ const heliumHasStacks = !!heliumPanel?.querySelector?.('.control-stack[data-mobile-tab]');
+ return heliumHasStacks ? heliumPanel : orgPanel;
+ }
+ return orgPanel;
+ }
+
+ function updateMobileStacks(tabName) {
const currentTab = detectCurrentTab();
- const panel = currentTab === '#tab-classic' ? claPanel : (currentTab === '#tab-wall' ? wallPanel : orgPanel);
+ const panel = getCurrentMobilePanel(currentTab);
const target = tabName || document.body?.dataset?.mobileTab || MOBILE_TAB_DEFAULT;
const isHidden = document.body?.dataset?.controlsHidden === '1';
const isDesktop = window.matchMedia('(min-width: 1024px)').matches;
@@ -440,11 +457,7 @@
delete document.body.dataset.controlsHidden;
}
// Ensure the current panel is not minimized/hidden when we select a tab.
- const panel = activeMainTab === '#tab-classic'
- ? document.getElementById('classic-controls-panel')
- : (activeMainTab === '#tab-wall'
- ? document.getElementById('wall-controls-panel')
- : document.getElementById('controls-panel'));
+ const panel = getCurrentMobilePanel(activeMainTab);
panel?.classList.remove('minimized');
if (panel) panel.style.display = '';
updateSheets();
@@ -524,15 +537,20 @@
// Tab switching
if (orgSection && claSection && tabBtns.length > 0) {
let current = '#tab-organic';
+ const usesOrganicWorkspace = () => current === '#tab-organic' || current === '#tab-helium';
+ const syncOrganicWorkspaceLabels = () => {
+ const title = document.querySelector('#controls-panel .panel-title');
+ if (title) title.textContent = current === '#tab-helium' ? 'Helium Controls' : 'Organic Controls';
+ };
const isMobileView = () => window.matchMedia('(max-width: 1023px)').matches;
const updateMobileActionBarVisibility = () => {
const modalOpen = !!document.querySelector('.color-modal:not(.hidden)');
const isMobile = isMobileView();
- const showOrganic = isMobile && !modalOpen && current === '#tab-organic';
+ const showOrganic = isMobile && !modalOpen && usesOrganicWorkspace();
if (mobileActionBar) mobileActionBar.classList.toggle('hidden', !showOrganic);
};
const wireMobileActionButtons = () => {
- const guardOrganic = () => current === '#tab-organic';
+ const guardOrganic = () => usesOrganicWorkspace();
const clickBtn = (sel) => { if (!guardOrganic()) return; document.querySelector(sel)?.click(); };
const on = (id, fn) => document.getElementById(id)?.addEventListener('click', fn);
on('mobile-act-undo', () => clickBtn('#tool-undo'));
@@ -558,9 +576,11 @@
orgSheet?.classList.remove('minimized');
claSheet?.classList.remove('minimized');
wallSheet?.classList.remove('minimized');
- orgSection.classList.toggle('hidden', id !== '#tab-organic');
+ const useOrganicWorkspace = id === '#tab-organic' || id === '#tab-helium';
+ orgSection.classList.toggle('hidden', !useOrganicWorkspace);
claSection.classList.toggle('hidden', id !== '#tab-classic');
wallSection?.classList.toggle('hidden', id !== '#tab-wall');
+ heliumSection?.classList.add('hidden');
updateSheets();
updateFloatingNudge();
tabBtns.forEach(btn => {
@@ -573,7 +593,7 @@
try { localStorage.setItem(ACTIVE_TAB_KEY, id); } catch {}
}
if (document.body) delete document.body.dataset.controlsHidden;
- const isOrganic = id === '#tab-organic';
+ const isOrganic = useOrganicWorkspace;
const showHeaderColor = id !== '#tab-classic';
const clearTop = document.getElementById('clear-canvas-btn-top');
if (clearTop) {
@@ -588,9 +608,11 @@
})();
if (document.body) document.body.dataset.mobileTab = savedMobile;
setMobileTab(savedMobile, id, true);
- orgSheet?.classList.toggle('hidden', id !== '#tab-organic');
+ orgSheet?.classList.toggle('hidden', !useOrganicWorkspace);
claSheet?.classList.toggle('hidden', id !== '#tab-classic');
wallSheet?.classList.toggle('hidden', id !== '#tab-wall');
+ heliumSheet?.classList.add('hidden');
+ syncOrganicWorkspaceLabels();
window.updateExportButtonVisibility();
updateMobileActionBarVisibility();
}
@@ -625,12 +647,9 @@
btn.addEventListener('click', () => {
const tab = btn.dataset.mobileTab || 'controls';
const activeTabId = detectCurrentTab();
- const panel = activeTabId === '#tab-classic'
- ? document.getElementById('classic-controls-panel')
- : (activeTabId === '#tab-wall'
- ? document.getElementById('wall-controls-panel')
- : document.getElementById('controls-panel'));
+ const panel = getCurrentMobilePanel(activeTabId);
const currentTab = document.body.dataset.mobileTab;
+ if (!panel) return;
if (tab === currentTab) {
panel.classList.toggle('minimized');
} else {
diff --git a/style.css b/style.css
index d1f59b9..bd85fa9 100644
--- a/style.css
+++ b/style.css
@@ -1,7 +1,8 @@
/* Minimal extras (Tailwind handles most styling) */
body { color: #1f2937; }
body[data-active-tab="#tab-classic"] #clear-canvas-btn-top,
-body[data-active-tab="#tab-wall"] #clear-canvas-btn-top {
+body[data-active-tab="#tab-wall"] #clear-canvas-btn-top,
+body[data-active-tab="#tab-helium"] #clear-canvas-btn-top {
display: none !important;
}
@@ -399,7 +400,7 @@ height: 95%}
z-index: 30;
-webkit-overflow-scrolling: touch;
transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1);
-height: 92%;
+ height: auto;
}
.control-sheet.hidden { display: none; }
.control-sheet.minimized { transform: translateY(100%); }
@@ -433,7 +434,7 @@ height: 92%;
}
@media (max-width: 1023px) {
- body { padding-bottom: 0; overflow: auto; }
+ body { padding-bottom: calc(6rem + env(safe-area-inset-bottom, 0px)); overflow: auto; }
html, body { height: auto; overflow: auto; }
#current-color-chip-global { display: none; }
#clear-canvas-btn-top { display: none !important; }
@@ -454,10 +455,16 @@ height: 92%;
body[data-mobile-tab="save"] #classic-controls-panel [data-mobile-tab="save"],
body[data-mobile-tab="controls"] #wall-controls-panel [data-mobile-tab="controls"],
body[data-mobile-tab="colors"] #wall-controls-panel [data-mobile-tab="colors"],
- body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"] {
+ body[data-mobile-tab="save"] #wall-controls-panel [data-mobile-tab="save"],
+ body[data-mobile-tab="controls"] #helium-controls-panel [data-mobile-tab="controls"],
+ body[data-mobile-tab="colors"] #helium-controls-panel [data-mobile-tab="colors"],
+ body[data-mobile-tab="save"] #helium-controls-panel [data-mobile-tab="save"] {
display: block;
}
- .control-sheet { bottom: 4.5rem; max-height: 55vh; }
+ .control-sheet {
+ bottom: calc(4.5rem + env(safe-area-inset-bottom, 0px));
+ max-height: min(72vh, calc(100dvh - 8.5rem - env(safe-area-inset-bottom, 0px)));
+ }
.control-sheet.minimized { transform: translateY(115%); }
/* Larger tap targets and spacing */
@@ -484,8 +491,8 @@ height: 92%;
position: fixed;
left: 0;
right: 0;
- bottom: 4.75rem;
- padding: 0.35rem 0.75rem 0.7rem;
+ bottom: calc(4.75rem + env(safe-area-inset-bottom, 0px));
+ padding: 0.35rem 0.75rem calc(0.7rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(255,255,255,0.72) 0%, rgba(255,255,255,0.96) 100%);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
@@ -679,7 +686,7 @@ height: 92%;
display: flex;
justify-content: space-around;
align-items: center;
- padding: .6rem .9rem .9rem;
+ padding: .6rem .9rem calc(.9rem + env(safe-area-inset-bottom, 0px));
background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(224,242,254,0.92));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
@@ -700,11 +707,8 @@ height: 92%;
#wall-display,
#balloon-canvas {
margin-bottom: 0;
- height: calc(100vh - 190px) !important; /* tie to viewport minus header/controls */
- max-height: calc(100vh - 190px) !important;
- }
- #classic-display{
- height: 92%;
+ height: calc(100dvh - 190px) !important; /* tie to viewport minus header/controls */
+ max-height: calc(100dvh - 190px) !important;
}
/* Keep the main canvas panels above the tabbar/action bar */
#canvas-panel,