function drawSource() { syncActiveConfig(); if (state.imageMode === 'whole') { renderConfig(state.globalConfig); state.sourcePixels = state.globalConfig.pixels; } else { state.bladeConfigs.forEach( (config) => { if (config.image || config.editDirty) renderConfig(config); else config.pixels = null; } ); } drawMaskPreview(); } function modelToCanvas(x, y, canvas) { var bounds = modelBounds(); return [(y - bounds.yMin) / (bounds.yMax - bounds.yMin) * canvas.width, (x - bounds.xMin) / (bounds.xMax - bounds.xMin) * canvas.height]; } function appendBladePath(ctx, angle, canvas) { FAN.outline.forEach( (point, index) => { var world = rotatePoint(point[0], point[1], angle); var p = modelToCanvas(world[0], world[1], canvas); if (index === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); } ); ctx.closePath(); } function currentReliefMargin() { return ui.reliefMargin ? Number(ui.reliefMargin.value) : FAN.margin; } function reliefHalfAt(radius, margin=currentReliefMargin()) { return Math.max(.25, widthAt(radius) + (FAN.edgeOverlap || 0) - margin); } function reliefRadiusAt(index, count) { if (!FAN.headArc || count < 4) { return FAN.rMin + (FAN.rMax - FAN.rMin) * index / (count - 1); } var arc = FAN.headArc; var startAngle = Math.acos(clamp((arc.startRadius - arc.centerRadius) / arc.radius, -1, 1)); var endAngle = Math.acos(clamp((FAN.rMax - arc.centerRadius) / arc.radius, -1, 1)); var headSegments = clamp(Math.max(24, Math.round((count - 1) * .15)), 2, count - 3); var bodySegments = count - 1 - headSegments; if (index <= bodySegments) { return FAN.rMin + (arc.startRadius - FAN.rMin) * index / bodySegments; } var headProgress = (index - bodySegments) / (count - 1 - bodySegments); var angle = startAngle + (endAngle - startAngle) * headProgress; return arc.centerRadius + arc.radius * Math.cos(angle); } function rootCapTriangleCount() { return FAN.rootInnerUpper.length * 8 + 4; } function outerReliefTipRadius() { return FAN.profile[FAN.profile.length - 1][0] + (FAN.tipOverlap || 0); } function rootReliefPolygon(margin=currentReliefMargin()) { var insetUpper = FAN.rootInnerUpper.map( ([radius,half]) => [radius, Math.max(.25, half - margin)]); var joinHalf = reliefHalfAt(FAN.rMin, margin); return [[FAN.rMin, -joinHalf], ...insetUpper.slice().reverse().map( ([radius,half]) => [radius, -half]), [FAN.rootTipRadius, 0], ...insetUpper, [FAN.rMin, joinHalf]]; } function pointInPolygon2D(x, y, polygon) { var inside = false; for (let index = 0, previous = polygon.length - 1; index < polygon.length; previous = index++) { const [xi,yi] = polygon[index]; const [xj,yj] = polygon[previous]; var crosses = (yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi; if (crosses) inside = !inside; } return inside; } function appendReliefPath(ctx, angle, canvas) { var steps = 96; var margin = currentReliefMargin(); var pointIndex = 0; for (let index = 0; index < steps; index++) { var radius = reliefRadiusAt(index, steps); var half = reliefHalfAt(radius, margin); var world = rotatePoint(-radius, half, angle); var point = modelToCanvas(world[0], world[1], canvas); if (pointIndex++ === 0) ctx.moveTo(point[0], point[1]); else ctx.lineTo(point[0], point[1]); } for (let index = steps - 1; index >= 0; index--) { var radius = reliefRadiusAt(index, steps); var half = reliefHalfAt(radius, margin); var world = rotatePoint(-radius, -half, angle); var point = modelToCanvas(world[0], world[1], canvas); ctx.lineTo(point[0], point[1]); } var rootPolygon = rootReliefPolygon(margin); for (let index = 1; index < rootPolygon.length; index++) { const [radius,localY] = rootPolygon[index]; var world = rotatePoint(-radius, localY, angle); var point = modelToCanvas(world[0], world[1], canvas); ctx.lineTo(point[0], point[1]); } ctx.closePath(); } function drawBladePath(ctx, angle, canvas) { ctx.beginPath(); appendBladePath(ctx, angle, canvas); } function ensureMaskLookup(canvas) { var margin = currentReliefMargin(); if (state.maskLookup && state.maskLookup.width === canvas.width && state.maskLookup.height === canvas.height && state.maskLookup.margin === margin) { return state.maskLookup; } var length = canvas.width * canvas.height; var blade = new Int8Array(length); blade.fill(-1); var u = new Float32Array(length); var v = new Float32Array(length); var bounds = modelBounds(); var modelW = bounds.xMax - bounds.xMin; var modelH = bounds.yMax - bounds.yMin; var rootPolygon = rootReliefPolygon(margin); for (let py = 0; py < canvas.height; py++) { var worldX = bounds.xMin + (py + .5) / canvas.height * modelW; for (let px = 0; px < canvas.width; px++) { var worldY = bounds.yMin + (px + .5) / canvas.width * modelH; var out = py * canvas.width + px; for (let bladeIndex = 0; bladeIndex < FAN.angles.length; bladeIndex++) { var local = rotatePoint(worldX, worldY, -FAN.angles[bladeIndex]); var radius = -local[0]; if (radius < FAN.rootMinRadius || radius > FAN.rMax) continue; var half = reliefHalfAt(radius, margin); var inMainPanel = radius >= FAN.rMin && Math.abs(local[1]) <= half; var inRootCap = radius < FAN.rMin && pointInPolygon2D(radius, local[1], rootPolygon); if (!inMainPanel && !inRootCap) continue; blade[out] = bladeIndex; u[out] = clamp((radius - FAN.rMin) / (FAN.rMax - FAN.rMin), 0, 1); v[out] = clamp((half - local[1]) / (2 * half), 0, 1); break; } } } state.maskLookup = { width: canvas.width, height: canvas.height, margin, blade, u, v }; return state.maskLookup; } function drawWholeMask(ctx, canvas) { var pattern = document.createElement('canvas'); pattern.width = canvas.width; pattern.height = canvas.height; pattern.getContext('2d').drawImage(ui.sourceCanvas, 0, 0, pattern.width, pattern.height); ctx.save(); ctx.beginPath(); FAN.angles.forEach( (angle) => appendReliefPath(ctx, angle, canvas)); ctx.clip('nonzero'); ctx.drawImage(pattern, 0, 0); ctx.restore(); } function drawIndividualMask(ctx, canvas) { var lookup = ensureMaskLookup(canvas); var output = ctx.createImageData(canvas.width, canvas.height); var data = output.data; for (let i = 0; i < lookup.blade.length; i++) { var out = i * 4; var bladeIndex = lookup.blade[i]; if (bladeIndex < 0) { data[out] = 213; data[out + 1] = 226; data[out + 2] = 224; data[out + 3] = 255; continue; } var pixels = state.bladeConfigs[bladeIndex].pixels; if (!pixels) { data[out] = 244; data[out + 1] = 244; data[out + 2] = 238; data[out + 3] = 255; continue; } var sx = clamp(Math.round(lookup.u[i] * (pixels.width - 1)), 0, pixels.width - 1); var sy = clamp(Math.round(lookup.v[i] * (pixels.height - 1)), 0, pixels.height - 1); var source = (sy * pixels.width + sx) * 4; data[out] = pixels.data[source]; data[out + 1] = pixels.data[source + 1]; data[out + 2] = pixels.data[source + 2]; data[out + 3] = 255; } ctx.putImageData(output, 0, 0); } function drawMaskPreview() { var canvas = ui.maskCanvas; var ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#d5e2e0'; ctx.fillRect(0, 0, canvas.width, canvas.height); if (state.imageMode === 'whole') drawWholeMask(ctx, canvas); else drawIndividualMask(ctx, canvas); ctx.save(); ctx.lineJoin = 'round'; FAN.angles.forEach( (angle, bladeIndex) => { drawBladePath(ctx, angle, canvas); var selected = state.imageMode === 'blade' && bladeIndex === state.selectedBlade; ctx.strokeStyle = selected ? '#c64c3f' : 'rgba(16,38,50,.68)'; ctx.lineWidth = selected ? 3 : 1.25; ctx.stroke(); } ); var pivot = modelToCanvas(0, 0, canvas); ctx.beginPath(); ctx.arc(pivot[0], pivot[1], canvas.width * .035, 0, Math.PI * 2); ctx.fillStyle = '#244f59'; ctx.fill(); ctx.beginPath(); ctx.arc(pivot[0], pivot[1], canvas.width * .022, 0, Math.PI * 2); ctx.fillStyle = '#b3c9c5'; ctx.fill(); ctx.restore(); drawEditorObjectOverlay(ctx, canvas); } function pointerOnActiveEditCanvas(event) { var rect = ui.maskCanvas.getBoundingClientRect(); var px = clamp((event.clientX - rect.left) / rect.width * ui.maskCanvas.width, 0, ui.maskCanvas.width - .001); var py = clamp((event.clientY - rect.top) / rect.height * ui.maskCanvas.height, 0, ui.maskCanvas.height - .001); var config = activeConfig(); var lookup = ensureMaskLookup(ui.maskCanvas); var index = Math.floor(py) * ui.maskCanvas.width + Math.floor(px); if (lookup.blade[index] < 0) return null; if (state.imageMode === 'whole') { return [px / ui.maskCanvas.width * config.editCanvas.width, py / ui.maskCanvas.height * config.editCanvas.height]; } if (lookup.blade[index] !== state.selectedBlade) return null; return [lookup.u[index] * (config.editCanvas.width - 1), lookup.v[index] * (config.editCanvas.height - 1)]; } function pointerOnTextEditCanvas(event) { var rect = ui.maskCanvas.getBoundingClientRect(); var screenX = event.clientX - rect.left; var screenY = event.clientY - rect.top; if (screenX < 0 || screenY < 0 || screenX > rect.width || screenY > rect.height) return null; var px = screenX / rect.width * ui.maskCanvas.width; var py = screenY / rect.height * ui.maskCanvas.height; var config = activeConfig(); if (state.imageMode === 'whole') { return [px / ui.maskCanvas.width * config.editCanvas.width, py / ui.maskCanvas.height * config.editCanvas.height]; } var bounds = modelBounds(); var worldX = bounds.xMin + py / ui.maskCanvas.height * (bounds.xMax - bounds.xMin); var worldY = bounds.yMin + px / ui.maskCanvas.width * (bounds.yMax - bounds.yMin); var local = rotatePoint(worldX, worldY, -FAN.angles[state.selectedBlade]); var radius = -local[0]; var half = reliefHalfAt(clamp(radius, FAN.rMin, FAN.rMax)); var u = (radius - FAN.rMin) / (FAN.rMax - FAN.rMin); var v = (half - local[1]) / Math.max(.5, 2 * half); return [u * (config.editCanvas.width - 1), v * (config.editCanvas.height - 1)]; } function editPointToMask(config, point, canvas=ui.maskCanvas) { if (state.imageMode === 'whole') { return [point[0] / config.editCanvas.width * canvas.width, point[1] / config.editCanvas.height * canvas.height]; } var u = point[0] / Math.max(1, config.editCanvas.width - 1); var v = point[1] / Math.max(1, config.editCanvas.height - 1); var radius = FAN.rMin + u * (FAN.rMax - FAN.rMin); var half = reliefHalfAt(clamp(radius, FAN.rMin, FAN.rMax)); var localY = half - v * 2 * half; var world = rotatePoint(-radius, localY, FAN.angles[state.selectedBlade]); return modelToCanvas(world[0], world[1], canvas); } function vectorObjectBounds(config, object) { var minX, minY, maxX, maxY; if (object.type === 'text') { var ctx = config.editCanvas.getContext('2d'); var metrics = textLayoutMetrics(ctx, object); var vertical = object.fontSize * .72 + Math.abs(metrics.sagitta); minX = object.x - metrics.total / 2 - object.fontSize * .2; maxX = object.x + metrics.total / 2 + object.fontSize * .2; minY = object.y - vertical - object.fontSize * .2; maxY = object.y + object.fontSize * .65 + Math.abs(metrics.sagitta) * .12; } else if (object.type === 'spline') { var points = object.points || []; if (!points.length) return null; minX = Math.min(...points.map( (point) => point[0])); minY = Math.min(...points.map( (point) => point[1])); maxX = Math.max(...points.map( (point) => point[0])); maxY = Math.max(...points.map( (point) => point[1])); } else { minX = Math.min(object.x1, object.x2); minY = Math.min(object.y1, object.y2); maxX = Math.max(object.x1, object.x2); maxY = Math.max(object.y1, object.y2); } var pad = Math.max(5, object.lineWidth || object.fontSize * .08 || 5); return { minX: minX - pad, minY: minY - pad, maxX: maxX + pad, maxY: maxY + pad }; } function objectControlPoints(object, bounds) { if (object.type === 'spline') return object.points || []; if (object.type === 'text') return [[object.x, object.y]]; if (['triangle', 'polygon', 'star', 'diamond'].includes(object.type)) { var count = object.type === 'triangle' ? 3 : object.type === 'polygon' ? (object.sides || 6) : object.type === 'star' ? 5 : 4; return regularShapePoints(object, count); } return [[bounds.minX, bounds.minY], [bounds.maxX, bounds.minY], [bounds.maxX, bounds.maxY], [bounds.minX, bounds.maxY]]; } function drawEditorObjectOverlay(ctx, canvas) { var config = activeConfig(); var object = state.editorTool === 'select' ? selectedVectorObject(config) : null; if (object) { var bounds = vectorObjectBounds(config, object); if (bounds) { var corners = [[bounds.minX, bounds.minY], [bounds.maxX, bounds.minY], [bounds.maxX, bounds.maxY], [bounds.minX, bounds.maxY]].map( (point) => editPointToMask(config, point, canvas)); ctx.save(); ctx.strokeStyle = 'rgba(198,76,63,.96)'; ctx.fillStyle = '#fff'; ctx.lineWidth = 1.5; ctx.setLineDash([6, 4]); ctx.beginPath(); tracePoints(ctx, corners); ctx.stroke(); ctx.setLineDash([]); objectControlPoints(object, bounds).forEach( (point) => { var screen = editPointToMask(config, point, canvas); ctx.beginPath(); ctx.arc(screen[0], screen[1], 3.5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); } ); ctx.restore(); } } var preview = state.editorPreview; if (preview && preview.config === config && preview.object) { var point = preview.object.type === 'text' ? [preview.object.x, preview.object.y] : preview.object.type === 'spline' ? preview.object.points[preview.object.points.length - 1] : [preview.object.x2, preview.object.y2]; if (point) { var screen = editPointToMask(config, point, canvas); ctx.save(); ctx.strokeStyle = 'rgba(198,76,63,.96)'; ctx.fillStyle = 'rgba(255,255,255,.84)'; ctx.lineWidth = 1.25; ctx.beginPath(); ctx.arc(screen[0], screen[1], 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.beginPath(); ctx.moveTo(screen[0] - 9, screen[1]); ctx.lineTo(screen[0] + 9, screen[1]); ctx.moveTo(screen[0], screen[1] - 9); ctx.lineTo(screen[0], screen[1] + 9); ctx.stroke(); ctx.restore(); } } } function hitTestVectorObject(config, point) { var scale = config.editCanvas.width / ui.maskCanvas.width; var tolerance = 10 * scale; for (let index = config.vectorObjects.length - 1; index >= 0; index--) { var object = config.vectorObjects[index]; var bounds = vectorObjectBounds(config, object); if (!bounds) continue; if (point[0] >= bounds.minX - tolerance && point[0] <= bounds.maxX + tolerance && point[1] >= bounds.minY - tolerance && point[1] <= bounds.maxY + tolerance) return object; } return null; } function translateVectorObject(object, dx, dy) { if (object.type === 'text') { object.x += dx; object.y += dy; } else if (object.type === 'spline') { object.points = object.points.map( (point) => [point[0] + dx, point[1] + dy]); } else { object.x1 += dx; object.y1 += dy; object.x2 += dx; object.y2 += dy; } } function cloneVectorObjects(objects) { return objects.map( (object) => JSON.parse(JSON.stringify(object))); } function updateConfigEditDirty(config) { config.editDirty = Boolean(config.paintDirty || config.vectorObjects.length); } function currentTextFont(key=state.textFont) { return TEXT_FONTS[key] || TEXT_FONTS.xingkai; } function textLayoutMetrics(ctx, object) { var font = currentTextFont(object.fontKey); ctx.font = `${font.weight} ${object.fontSize}px ${font.family}`; var characters = Array.from(object.value || ''); var spacing = object.fontSize * .045; var widths = characters.map( (character) => Math.max(1, ctx.measureText(character).width)); var total = widths.reduce( (sum, width) => sum + width, 0) + Math.max(0, characters.length - 1) * spacing; var sagitta = object.arc / 100 * Math.max(object.fontSize * 1.7, total * .22); return { characters, widths, spacing, total: Math.max(1, total), sagitta }; } function drawCurvedText(ctx, object) { var font = currentTextFont(object.fontKey); var metrics = textLayoutMetrics(ctx, object); var cursor = -metrics.total / 2; ctx.save(); ctx.fillStyle = object.color; ctx.strokeStyle = object.color; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round'; ctx.lineWidth = Math.max(1, object.fontSize * .085); ctx.font = `${font.weight} ${object.fontSize}px ${font.family}`; metrics.characters.forEach( (character, index) => { var center = cursor + metrics.widths[index] / 2; var normalized = center / metrics.total; var yOffset = -metrics.sagitta * (1 - 4 * normalized * normalized); var tangent = 8 * metrics.sagitta * normalized / metrics.total; ctx.save(); ctx.translate(object.x + center, object.y + yOffset); ctx.rotate(Math.atan(tangent)); if (object.textStyle === 'outline') ctx.strokeText(character, 0, 0); else ctx.fillText(character, 0, 0); ctx.restore(); cursor += metrics.widths[index] + metrics.spacing; } ); ctx.restore(); } function regularShapePoints(object, count, innerRatio=1) { var cx = (object.x1 + object.x2) / 2; var cy = (object.y1 + object.y2) / 2; var rx = Math.max(1, Math.abs(object.x2 - object.x1) / 2); var ry = Math.max(1, Math.abs(object.y2 - object.y1) / 2); var points = []; var total = innerRatio < 1 ? count * 2 : count; for (let index = 0; index < total; index++) { var angle = -Math.PI / 2 + index / total * Math.PI * 2; var ratio = innerRatio < 1 && index % 2 ? innerRatio : 1; points.push([cx + Math.cos(angle) * rx * ratio, cy + Math.sin(angle) * ry * ratio]); } return points; } function tracePoints(ctx, points, close=true) { if (!points.length) return; ctx.moveTo(points[0][0], points[0][1]); for (let index = 1; index < points.length; index++) ctx.lineTo(points[index][0], points[index][1]); if (close) ctx.closePath(); } function traceSpline(ctx, points, tension=.55) { if (!points.length) return; ctx.moveTo(points[0][0], points[0][1]); if (points.length === 1) return; if (points.length === 2) { ctx.lineTo(points[1][0], points[1][1]); return; } var factor = clamp(tension, 0, 1) / 6; for (let index = 0; index < points.length - 1; index++) { var p0 = points[Math.max(0, index - 1)]; var p1 = points[index]; var p2 = points[index + 1]; var p3 = points[Math.min(points.length - 1, index + 2)]; var cp1 = [p1[0] + (p2[0] - p0[0]) * factor, p1[1] + (p2[1] - p0[1]) * factor]; var cp2 = [p2[0] - (p3[0] - p1[0]) * factor, p2[1] - (p3[1] - p1[1]) * factor]; ctx.bezierCurveTo(cp1[0], cp1[1], cp2[0], cp2[1], p2[0], p2[1]); } } function drawVectorObject(ctx, object) { if (!object) return; if (object.type === 'text') { drawCurvedText(ctx, object); return; } ctx.save(); ctx.strokeStyle = object.color; ctx.fillStyle = object.color; ctx.lineWidth = Math.max(1, object.lineWidth); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); if (object.type === 'line') { ctx.moveTo(object.x1, object.y1); ctx.lineTo(object.x2, object.y2); } else if (object.type === 'ellipse') { var cx = (object.x1 + object.x2) / 2 , cy = (object.y1 + object.y2) / 2; ctx.ellipse(cx, cy, Math.max(1, Math.abs(object.x2 - object.x1) / 2), Math.max(1, Math.abs(object.y2 - object.y1) / 2), 0, 0, Math.PI * 2); } else if (object.type === 'rect') { ctx.rect(Math.min(object.x1, object.x2), Math.min(object.y1, object.y2), Math.abs(object.x2 - object.x1), Math.abs(object.y2 - object.y1)); } else if (object.type === 'triangle') { tracePoints(ctx, regularShapePoints(object, 3)); } else if (object.type === 'polygon') { tracePoints(ctx, regularShapePoints(object, object.sides || 6)); } else if (object.type === 'star') { tracePoints(ctx, regularShapePoints(object, 5, .43)); } else if (object.type === 'diamond') { tracePoints(ctx, regularShapePoints(object, 4)); } else if (object.type === 'arrow') { var dx = object.x2 - object.x1 , dy = object.y2 - object.y1; var length = Math.max(1, Math.hypot(dx, dy)); var ux = dx / length , uy = dy / length; var head = Math.min(length * .42, Math.max(object.lineWidth * 5, 14)); var wing = head * .55; ctx.moveTo(object.x1, object.y1); ctx.lineTo(object.x2, object.y2); ctx.moveTo(object.x2, object.y2); ctx.lineTo(object.x2 - ux * head - uy * wing, object.y2 - uy * head + ux * wing); ctx.moveTo(object.x2, object.y2); ctx.lineTo(object.x2 - ux * head + uy * wing, object.y2 - uy * head - ux * wing); } else if (object.type === 'spline') { traceSpline(ctx, object.points || [], object.tension); } ctx.stroke(); ctx.restore(); } function renderEditorComposite(config, previewObject=null) { var ctx = config.editCanvas.getContext('2d'); ctx.clearRect(0, 0, config.editCanvas.width, config.editCanvas.height); if (config.paintDirty) ctx.drawImage(config.paintCanvas, 0, 0); config.vectorObjects.forEach( (object) => drawVectorObject(ctx, object)); if (previewObject) drawVectorObject(ctx, previewObject); } function refreshCommittedEditor(config, redrawMask=true) { renderEditorComposite(config); updateConfigEditDirty(config); renderConfig(config); if (state.imageMode === 'whole') state.sourcePixels = config.pixels; if (redrawMask) drawMaskPreview(); } function renderEditorPreview(config, previewObject) { state.editorPreview = previewObject ? { config, object: previewObject } : null; renderEditorComposite(config, previewObject); renderConfig(config, Boolean(previewObject)); if (state.imageMode === 'whole') state.sourcePixels = config.pixels; drawMaskPreview(); renderEditorComposite(config); renderConfig(config); if (state.imageMode === 'whole') state.sourcePixels = config.pixels; } function clearEditorPreview(redrawMask=true) { var preview = state.editorPreview; state.editorPreview = null; if (!preview) return; refreshCommittedEditor(preview.config, redrawMask); } function pushEditHistory(config) { var ctx = config.paintCanvas.getContext('2d', { willReadFrequently: true }); config.editHistory.push({ paintPixels: ctx.getImageData(0, 0, config.paintCanvas.width, config.paintCanvas.height), paintDirty: config.paintDirty, vectorObjects: cloneVectorObjects(config.vectorObjects), nextVectorId: config.nextVectorId, selectedObjectId: config.selectedObjectId }); if (config.editHistory.length > 20) config.editHistory.shift(); } function previewEditorChange(config) { refreshCommittedEditor(config, true); } function paintEditorSegment(config, from, to, erasing=false) { if (!from || !to) return; var canvas = config.paintCanvas; var ctx = canvas.getContext('2d'); var scale = canvas.width / ui.maskCanvas.width; ctx.save(); ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'; ctx.strokeStyle = ui.editorColor.value; ctx.lineWidth = Math.max(1, Number(ui.brushSize.value) * scale); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; var distance = Math.hypot(to[0] - from[0], to[1] - from[1]); if (distance < .01) { ctx.fillStyle = erasing ? '#000' : ui.editorColor.value; ctx.beginPath(); ctx.arc(to[0], to[1], ctx.lineWidth / 2, 0, Math.PI * 2); ctx.fill(); } else { ctx.beginPath(); ctx.moveTo(from[0], from[1]); ctx.lineTo(to[0], to[1]); ctx.stroke(); } ctx.restore(); config.paintDirty = true; previewEditorChange(config); } function createTextObject(config, point) { var value = ui.textValue.value.trim(); if (!value) return null; var scale = config.editCanvas.width / ui.maskCanvas.width; return { id: null, type: 'text', x: point[0], y: point[1], value, fontKey: state.textFont, textStyle: state.textStyle, fontSize: Math.max(8, Number(ui.textSize.value) * scale), arc: Number(ui.textArc.value), color: ui.editorColor.value }; } function createShapeObject(config, type, start, end, points=null) { var scale = config.editCanvas.width / ui.maskCanvas.width; var shapeEnd = end; var ratioMode = RATIO_SHAPE_TOOLS.includes(type) ? state.shapeRatioMode : 'free'; if (ratioMode === 'regular') { var dx = end[0] - start[0] , dy = end[1] - start[1]; var side = Math.max(Math.abs(dx), Math.abs(dy), 1); shapeEnd = [start[0] + (dx < 0 ? -side : side), start[1] + (dy < 0 ? -side : side)]; } var object = { id: null, type, color: ui.editorColor.value, lineWidth: Math.max(1, Number(ui.brushSize.value) * scale), ratioMode, x1: start[0], y1: start[1], x2: shapeEnd[0], y2: shapeEnd[1] }; if (type === 'polygon') object.sides = Number(ui.polygonSides.value); if (type === 'spline') { object.points = (points || [start, end]).map( (point) => [point[0], point[1]]); object.tension = Number(ui.splineTension.value) / 100; } return object; } function commitVectorObject(config, object) { if (!object) return false; object.id = config.nextVectorId++; config.vectorObjects.push(object); config.selectedObjectId = object.id; state.editorPreview = null; refreshCommittedEditor(config, true); updateSelectionUi(); scheduleRebuild(); return true; } function placeEditorText(config, point) { var object = createTextObject(config, point); if (!object) { ui.status.textContent = '请先输入要添加的文字。'; return false; } return commitVectorObject(config, object); } function finishEditorStroke() { if (!state.editorDrawing) return; state.editorDrawing = false; state.editorLastPoint = null; state.editorPointerId = null; scheduleRebuild(); } function selectedVectorObject(config=activeConfig()) { return config.vectorObjects.find( (object) => object.id === config.selectedObjectId) || null; } function updateSelectionUi() { var object = selectedVectorObject(); var selected = Boolean(object); ui.selectionInfo.hidden = !selected; ui.deleteSelected.disabled = !selected; ui.floatDeleteSelected.disabled = !selected; if (selected) ui.selectionLabel.textContent = `已选中${vectorObjectDisplayName(object)},可在扇面中拖动位置`; } function vectorObjectDisplayName(object) { if (!object || !RATIO_SHAPE_TOOLS.includes(object.type)) return VECTOR_TOOL_LABELS[object?.type] || '对象'; var regular = object.ratioMode === 'regular'; var names = { ellipse: regular ? '正圆' : '椭圆', rect: regular ? '正方形' : '矩形', triangle: regular ? '正三角形' : '自由三角形', polygon: regular ? `正${object.sides || 6}边形` : `自由比例${object.sides || 6}边形`, star: regular ? '正星形' : '自由比例星形', diamond: regular ? '正菱形' : '自由比例菱形' }; return names[object.type]; } function deleteSelectedObject() { var config = activeConfig(); var index = config.vectorObjects.findIndex( (object) => object.id === config.selectedObjectId); if (index < 0) return; pushEditHistory(config); config.vectorObjects.splice(index, 1); config.selectedObjectId = null; refreshCommittedEditor(config, true); updateSelectionUi(); scheduleRebuild(); } function undoEditorChange() { var config = activeConfig(); var previous = config.editHistory.pop(); if (!previous) { ui.status.textContent = '当前图片没有可撤销的编辑。'; return; } var ctx = config.paintCanvas.getContext('2d'); ctx.clearRect(0, 0, config.paintCanvas.width, config.paintCanvas.height); ctx.putImageData(previous.paintPixels, 0, 0); config.paintDirty = previous.paintDirty; config.vectorObjects = cloneVectorObjects(previous.vectorObjects); config.nextVectorId = previous.nextVectorId; config.selectedObjectId = previous.selectedObjectId; refreshCommittedEditor(config, true); updateSelectionUi(); scheduleRebuild(); } function clearEditorChanges() { var config = activeConfig(); if (!config.editDirty) return; pushEditHistory(config); config.paintCanvas.getContext('2d').clearRect(0, 0, config.paintCanvas.width, config.paintCanvas.height); config.paintDirty = false; config.vectorObjects = []; config.selectedObjectId = null; refreshCommittedEditor(config, true); updateSelectionUi(); scheduleRebuild(); } function shapeRatioDisplayName(tool=state.editorTool, mode=state.shapeRatioMode) { var regular = mode === 'regular'; var names = { ellipse: regular ? '正圆' : '椭圆', rect: regular ? '正方形' : '矩形', triangle: regular ? '正三角形' : '自由比例三角形', polygon: regular ? '正多边形' : '自由比例多边形', star: regular ? '正星形' : '自由比例星形', diamond: regular ? '正菱形' : '自由比例菱形' }; return names[tool] || VECTOR_TOOL_LABELS[tool] || '几何图形'; } function setShapeRatioMode(mode) { state.shapeRatioMode = mode === 'free' ? 'free' : 'regular'; document.querySelectorAll('[data-shape-ratio]').forEach( (button) => { var active = button.dataset.shapeRatio === state.shapeRatioMode; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); } ); ui.floatShapeRatio.value = state.shapeRatioMode; ui.shapeRatioHint.textContent = `${shapeRatioDisplayName()} · ${state.shapeRatioMode === 'regular' ? '等比例' : '可拉伸'}`; if (RATIO_SHAPE_TOOLS.includes(state.editorTool)) { ui.editorHint.textContent = `${shapeRatioDisplayName()}:按住拖动绘制;可随时切换“正图形”与“自由比例”。`; ui.adjustHint.textContent = `${shapeRatioDisplayName()} · 按住拖动`; } } function setEditorTool(tool) { clearEditorPreview(false); state.editorTool = tool; document.querySelectorAll('[data-editor-tool]').forEach( (button) => { var active = button.dataset.editorTool === tool; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); } ); var messages = { move: '移动模式:直接拖动图片,滚轮缩放;上方滑杆或按钮可旋转。', select: '选择模式:单击文字或几何,再拖动到新位置;选中文字后可继续修改内容、字体和弧度。', brush: '画笔模式:圆圈就是实际笔画大小;按住拖动绘制,单击可落一个圆点。', eraser: '橡皮模式:虚线圆圈就是擦除范围;擦除画笔内容,文字和几何请用“选择”后删除。', text: '文字模式:扇面中显示的就是最终排版;锚点可移到叶片外侧,预览会保留,单击确认放置。', line: '直线:在扇面内按住拖动,松开完成;之后可用“选择”移动。', ellipse: '圆形:选择“正圆”或“自由比例椭圆”,再按住拖动绘制。', rect: '方形:选择“正方形”或“自由比例矩形”,再按住拖动绘制。', triangle: '三角:选择“正三角形”或“自由比例三角形”,再按住拖动绘制。', polygon: '多边形:设置边数,并选择“正多边形”或“自由比例多边形”。', star: '星形:选择“正星形”或“自由比例星形”,再按住拖动绘制。', diamond: '菱形:选择“正菱形”或“自由比例菱形”,再按住拖动绘制。', arrow: '箭头:从起点拖向箭头方向,松开完成。', spline: '样条:按住自由绘制,系统自动拟合平滑曲线。' }; ui.editorHint.textContent = messages[tool]; ui.adjustHint.textContent = tool === 'move' ? '拖动图片 · 滚轮缩放' : tool === 'select' ? '单击选择 · 拖动位置' : tool === 'text' ? '最终效果预览 · 单击放置' : VECTOR_SHAPE_TOOLS.includes(tool) ? `${VECTOR_TOOL_LABELS[tool]} · 按住拖动` : `${tool === 'brush' ? '画笔' : '橡皮'}圆圈 · 按住拖动`; ui.maskWrap.classList.toggle('is-editing', tool !== 'move'); ui.maskWrap.classList.toggle('is-selecting', tool === 'select'); var shapeTool = VECTOR_SHAPE_TOOLS.includes(tool); var ratioShapeTool = RATIO_SHAPE_TOOLS.includes(tool); var selectedText = tool === 'select' && selectedVectorObject()?.type === 'text'; ui.brushEditorOptions.hidden = !(tool === 'brush' || tool === 'eraser' || shapeTool); ui.shapeEditorOptions.hidden = !(ratioShapeTool || tool === 'spline'); ui.shapeRatioControl.hidden = !ratioShapeTool; ui.polygonSidesControl.hidden = tool !== 'polygon'; ui.splineTensionControl.hidden = tool !== 'spline'; ui.textEditorOptions.hidden = !(tool === 'text' || selectedText); ui.editorColorControl.hidden = !(tool === 'brush' || tool === 'text' || shapeTool || selectedText); updateFloatingToolContext(); syncBrushCursorMode(); setShapeRatioMode(state.shapeRatioMode); updateSelectionUi(); drawMaskPreview(); refreshEditorPointerPreview(); } function hideBrushCursor() { ui.brushCursor.hidden = true; } function hideTextPlacementPreview() { ui.textPlacementPreview.hidden = true; if (state.editorPreview?.object?.type === 'text') clearEditorPreview(true); } function hideEditorPointerPreviews() { hideBrushCursor(); hideTextPlacementPreview(); if (state.editorPreview) clearEditorPreview(true); state.editorHoverX = null; state.editorHoverY = null; } function refreshBrushCursorSize() { var rect = ui.maskCanvas.getBoundingClientRect(); if (!rect.width) return; var diameter = clamp(Number(ui.brushSize.value) * rect.width / ui.maskCanvas.width, 4, 180); ui.brushCursor.style.width = `${diameter}px`; ui.brushCursor.style.height = `${diameter}px`; ui.brushCursor.style.setProperty('--cursor-color', state.editorTool === 'eraser' ? '#c64c3f' : ui.editorColor.value); ui.brushCursor.dataset.label = `${state.editorTool === 'eraser' ? '橡皮' : '画笔'} ${ui.brushSize.value}px`; } function syncBrushCursorMode() { var active = state.editorTool === 'brush' || state.editorTool === 'eraser'; ui.maskWrap.classList.toggle('has-brush-cursor', active); ui.maskWrap.classList.toggle('has-text-preview', state.editorTool === 'text'); ui.brushCursor.classList.toggle('is-eraser', state.editorTool === 'eraser'); refreshBrushCursorSize(); if (!active) hideBrushCursor(); if (state.editorTool !== 'text') hideTextPlacementPreview(); } function updateBrushCursor(event) { if (state.editorTool !== 'brush' && state.editorTool !== 'eraser') { hideBrushCursor(); return; } var rect = ui.maskCanvas.getBoundingClientRect(); var x = event.clientX - rect.left; var y = event.clientY - rect.top; if (x < 0 || y < 0 || x > rect.width || y > rect.height || !pointerOnActiveEditCanvas(event)) { hideBrushCursor(); return; } refreshBrushCursorSize(); ui.brushCursor.style.left = `${x}px`; ui.brushCursor.style.top = `${y}px`; ui.brushCursor.hidden = false; } function textPreviewAngle() { if (state.imageMode !== 'blade') return 0; var angle = FAN.angles[state.selectedBlade]; var root = rotatePoint(-FAN.rMin, 0, angle); var tip = rotatePoint(-FAN.rMax, 0, angle); var rootCanvas = modelToCanvas(root[0], root[1], ui.maskCanvas); var tipCanvas = modelToCanvas(tip[0], tip[1], ui.maskCanvas); return Math.atan2(tipCanvas[1] - rootCanvas[1], tipCanvas[0] - rootCanvas[0]) * 180 / Math.PI; } function updateTextPlacementPreview(event) { if (state.editorTool !== 'text') { hideTextPlacementPreview(); return; } var value = ui.textValue.value.trim(); var point = value ? pointerOnTextEditCanvas(event) : null; if (!point) { hideTextPlacementPreview(); return; } ui.textPlacementPreview.hidden = true; renderEditorPreview(activeConfig(), createTextObject(activeConfig(), point)); } function updateEditorPointerPreview(event) { state.editorHoverX = event.clientX; state.editorHoverY = event.clientY; updateBrushCursor(event); updateTextPlacementPreview(event); } function refreshEditorPointerPreview() { if (state.editorDrawing || state.editorObjectDragging) return; if (state.editorHoverX === null || state.editorHoverY === null) return; updateEditorPointerPreview({ clientX: state.editorHoverX, clientY: state.editorHoverY }); } function setTextFont(key) { if (!TEXT_FONTS[key]) return; state.textFont = key; ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => { var active = button.dataset.textFont === key; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); } ); ui.floatTextFont.value = key; var font = currentTextFont(); if (document.fonts?.load) document.fonts.load(`${font.weight} 24px ${font.family}`).then(refreshEditorPointerPreview).catch( () => {} ); applyTextControlsToSelected(); refreshEditorPointerPreview(); } function setTextStyle(style) { state.textStyle = style === 'outline' ? 'outline' : 'fill'; ui.textStyle.value = state.textStyle; ui.floatTextStyle.value = state.textStyle; applyTextControlsToSelected(); refreshEditorPointerPreview(); } function setTextArc(value) { state.textArc = clamp(Number(value), -100, 100); ui.textArc.value = state.textArc; ui.floatTextArc.value = state.textArc; ui.floatTextArcValue.textContent = `${state.textArc}%`; updateOutput(ui.textArc, '%'); applyTextControlsToSelected(); refreshEditorPointerPreview(); } function applyTextControlsToSelected() { if (state.editorTool !== 'select') return; var config = activeConfig(); var object = selectedVectorObject(config); if (!object || object.type !== 'text') return; var scale = config.editCanvas.width / ui.maskCanvas.width; object.value = ui.textValue.value.trim() || object.value; object.fontKey = state.textFont; object.textStyle = state.textStyle; object.fontSize = Math.max(8, Number(ui.textSize.value) * scale); object.arc = Number(ui.textArc.value); object.color = ui.editorColor.value; refreshCommittedEditor(config, true); scheduleRebuild(); } function syncTextControlsFromSelected(object) { if (!object || object.type !== 'text') return; var config = activeConfig(); var scale = config.editCanvas.width / ui.maskCanvas.width; ui.textValue.value = object.value; ui.floatTextValue.value = object.value; ui.textSize.value = Math.round(object.fontSize / scale); ui.floatTextSize.value = ui.textSize.value; ui.floatTextSizeValue.textContent = ui.textSize.value; updateOutput(ui.textSize, ' px'); ui.editorColor.value = object.color; ui.floatEditorColor.value = object.color; state.textFont = object.fontKey; state.textStyle = object.textStyle; setTextArc(object.arc); ui.textFontChips.querySelectorAll('[data-text-font]').forEach( (button) => { var active = button.dataset.textFont === state.textFont; button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); } ); ui.floatTextFont.value = state.textFont; ui.textStyle.value = state.textStyle; ui.floatTextStyle.value = state.textStyle; } function updateFloatingTransformStatus() { ui.floatTransformValue.textContent = `${ui.imageScale.value}% · ${ui.imageRotation.value}°`; ui.floatImageRotation.value = ui.imageRotation.value; ui.floatImageRotationValue.textContent = `${ui.imageRotation.value}°`; } function syncFloatingEditorControls() { ui.floatBrushSize.value = ui.brushSize.value; ui.floatBrushSizeValue.textContent = ui.brushSize.value; ui.floatTextValue.value = ui.textValue.value; ui.floatTextFont.value = state.textFont; ui.floatTextStyle.value = state.textStyle; ui.floatTextSize.value = ui.textSize.value; ui.floatTextSizeValue.textContent = ui.textSize.value; ui.floatTextArc.value = ui.textArc.value; ui.floatTextArcValue.textContent = `${ui.textArc.value}%`; ui.floatPolygonSides.value = ui.polygonSides.value; ui.floatPolygonSidesValue.textContent = ui.polygonSides.value; ui.floatSplineTension.value = ui.splineTension.value; ui.floatSplineTensionValue.textContent = `${ui.splineTension.value}%`; ui.floatShapeRatio.value = state.shapeRatioMode; ui.floatEditorColor.value = ui.editorColor.value; updateFloatingTransformStatus(); updateFloatingToolContext(); } function updateFloatingToolContext() { var isBrush = state.editorTool === 'brush'; var isEraser = state.editorTool === 'eraser'; var isText = state.editorTool === 'text'; var isShape = VECTOR_SHAPE_TOOLS.includes(state.editorTool); var selectedText = state.editorTool === 'select' && selectedVectorObject()?.type === 'text'; ui.floatToolContext.hidden = !(isBrush || isEraser || isText || isShape || selectedText); ui.floatBrushControl.hidden = !(isBrush || isEraser || isShape); ui.floatTextControl.hidden = !(isText || selectedText); ui.floatTextFontControl.hidden = !(isText || selectedText); ui.floatTextStyleControl.hidden = !(isText || selectedText); ui.floatTextSizeControl.hidden = !(isText || selectedText); ui.floatTextArcControl.hidden = !(isText || selectedText); ui.floatPolygonSidesControl.hidden = state.editorTool !== 'polygon'; ui.floatSplineTensionControl.hidden = state.editorTool !== 'spline'; ui.floatShapeRatioControl.hidden = !RATIO_SHAPE_TOOLS.includes(state.editorTool); ui.floatColorControl.hidden = !(isBrush || isText || isShape || selectedText); } function fitExpandedMask() { if (!state.maskExpanded) { ui.maskWrap.style.removeProperty('width'); refreshBrushCursorSize(); return; } var aspect = ui.maskCanvas.width / ui.maskCanvas.height; var width = Math.max(300, Math.min(1000, window.innerWidth - 36, (window.innerHeight - 116) * aspect)); ui.maskWrap.style.width = `${width}px`; refreshBrushCursorSize(); } function setMaskExpanded(expanded) { hideEditorPointerPreviews(); state.maskExpanded = Boolean(expanded); ui.maskWrap.classList.toggle('is-expanded', state.maskExpanded); ui.maskBackdrop.hidden = !state.maskExpanded; ui.maskFloatingToolbar.hidden = !state.maskExpanded; ui.expandMask.setAttribute('aria-expanded', String(state.maskExpanded)); ui.expandMask.textContent = state.maskExpanded ? '× 退出放大' : '⛶ 放大编辑'; ui.expandMask.title = state.maskExpanded ? '退出放大编辑(Esc)' : '放大扇面编辑区'; document.body.classList.toggle('mask-expanded', state.maskExpanded); syncFloatingEditorControls(); fitExpandedMask(); if (state.maskExpanded) requestAnimationFrame( () => ui.maskFloatingToolbar.querySelector('[data-editor-tool].active')?.focus({ preventScroll: true })); }