From 2323cc451b7393118b3810a687344d6e3fe1aa63 Mon Sep 17 00:00:00 2001 From: VANSH3104 Date: Wed, 2 Sep 2026 13:59:27 +0530 Subject: [PATCH 1/5] docs(experimental): add p5.svg experimental warning message and contributor guide --- contributor_docs/p5.svg.md | 106 +++++++++++++++++++++++++++++++++++++ src/core/experimental.js | 1 + 2 files changed, 107 insertions(+) create mode 100644 contributor_docs/p5.svg.md diff --git a/contributor_docs/p5.svg.md b/contributor_docs/p5.svg.md new file mode 100644 index 0000000000..5af344e739 --- /dev/null +++ b/contributor_docs/p5.svg.md @@ -0,0 +1,106 @@ + + +# p5.svg Overview + +`p5.svg` is an experimental native vector graphics system provided in p5.js starting from version 2. It aims to bring resolution-independent vector rendering, SVG file importing, and SVG XML exporting directly into p5.js without requiring external third-party addons. It allows users to record 2D drawing operations using familiar p5 APIs (`rect`, `circle`, `path`, `fill`, `stroke`, `translate`, `rotate`, etc.) and convert them into scalable vector structures. + +The specifics of these APIs are currently experimental and subject to evolution based on community feedback. A valuable contribution to the project is testing these APIs, reporting edge cases, and sharing feedback on usability and performance! + +## Project Goals + +`p5.svg` addresses several key goals: + +- **Resolution-Independent Vector Output**: Traditional canvas rendering in p5.js is raster (pixel) based. `p5.svg` enables artists, designers, and educators to generate scalable vector graphics suitable for high-DPI displays, print, pen plotters, CNC routers, laser cutters, and embroidery machines. +- **Familiar p5 Drawing Workflow**: Rather than introducing a complex vector editing paradigm, `p5.svg` hooks directly into the existing 2D drawing pipeline. You can record shapes using standard p5 drawing functions inside `createShape()` or `buildShape()`. +- **Vector Asset Interoperability**: `p5.svg` provides a two-way pipeline for vector assets: importing external SVG files via `loadSVG()` into p5 `RecordedShape` structures, and exporting recorded p5 scenes to valid SVG 2.0 XML files using `saveSVG()` or `getSVG()`. +- **Integrated Native Addon**: Unlike p5 1.x addons that required extra script tags, `p5.svg` is built directly into core p5.js as an experimental module registered via `markExperimental('p5.svg', p5)`. + +## What Needs Feedback + +The main ways you can help develop `p5.svg` are: + +- **API Ergonomics & Shape API**: Test vector shape recording and rendering functions like `createSVG()`, `loadSVG()`, `buildShape()`, `createShape()`, `shape()`, `getSVG()`, and `saveSVG()`. Share feedback on `shape()` playback, coordinate bounds, positioning, scaling options, and alignment modes (`CORNER`, `CENTER`, `VIEWBOX`). +- **SVG Path Parsing**: Test importing SVGs generated by vector design tools (Adobe Illustrator, Inkscape, Figma). Report any unsupported path commands (`M`, `L`, `H`, `V`, `C`, `S`, `Q`, `T`, `A`, `Z`) or malformed elements. +- **Styling and CSS Properties**: Help verify that stroke weights, colors, fill rules, opacities, gradients, and transform stacks behave consistently across export and import pipelines. +- **Performance & Memory**: Test large or complex SVG scenes to help identify bottlenecks in DOM parsing, AST construction, or XML string generation. + +## Technical Overview + +Behind the scenes, `p5.svg` operates in three main layers: + +1. **Shape Recording (`src/shape/svg/svg_recorder.js`)**: + - Intercepts 2D drawing calls (`rect`, `ellipse`, `line`, `beginShape`/`endShape`, `fill`, `stroke`, `push`, `pop`, `translate`, `rotate`, `scale`, `applyMatrix`). + - Builds an Abstract Syntax Tree (AST) composed of node instances (`ScopeNode`, `ShapeNode`, `BackgroundNode`, `ClearNode`, `ImageNode`). + - Tracks coordinate transformations using an internal `TransformStack`. + +2. **SVG Export & Visitor (`src/shape/svg/svg_export.js`)**: + - Implements `SVGExportAddon` and `SVGVisitor` (extending `p5.PrimitiveVisitor`). + - Traverses the shape AST to output standard SVG 2.0 XML markup string via `getSVG()` or triggers browser file downloads via `saveSVG()`. + +3. **SVG Import & Parsing (`src/shape/svg/svg_import.js`)**: + - Implements `SVGImportAddon` to parse external SVG DOM trees. + - Tokenizes path data commands (`PATH_COMMANDS`) and converts SVG elements (``, ``, ``, ``, ``, ``, ``, ``) into internal p5 `RecordedShape` structures ready for playback via `shape()`. + +## Usage Example + +### Exporting an SVG + +```js +function setup() { + createCanvas(400, 400); + + // Record drawing commands into a vector shape + const record = buildShape(() => { + background(245); + fill(99, 102, 241); + stroke(0); + strokeWeight(2); + circle(200, 200, 150); + }); + + // Save as vector file + saveSVG(record, 'my-vector.svg'); +} +``` + +### Importing and Replaying an SVG + +```js +let botLogo; + +async function setup() { + createCanvas(500, 500); + + try { + // loadSVG returns a promise; await the resolved RecordedShape + botLogo = await loadSVG('assets/robot.svg'); + console.log('SVG Loaded successfully!'); + } catch (err) { + console.error('Failed to load SVG:', err); + } +} + +function draw() { + background(255); + + // Render the SVG once it is fully loaded + if (botLogo) { + shape(botLogo, 100, 100); + } else { + fill(100); + text('Loading SVG...', 20, 30); + } +} +``` + +## Contributing + +We welcome contributions to `p5.svg`! You can get involved by: + +* **Testing existing SVG workflows** and reporting bugs or unexpected behavior on GitHub. +* **Proposing and implementing new SVG features**, such as expanded SVG element support, clipping paths, gradients, filters, and custom SVG attributes. +* **Improving SVG import and parsing**, including support for additional path commands and CSS/SVG attributes. +* **Adding tests and examples** to validate new functionality and demonstrate real-world SVG workflows. +* **Creating tutorials and creative coding examples** that showcase how `p5.svg` can be used in p5.js projects. +* **Reviewing and providing feedback on experimental APIs** to help improve their usability, consistency, and performance. + diff --git a/src/core/experimental.js b/src/core/experimental.js index 3e7d13b902..9cf3eba496 100644 --- a/src/core/experimental.js +++ b/src/core/experimental.js @@ -34,6 +34,7 @@ import { FES } from '../friendly_errors/fes'; const experimentalMessages = { webgpu: 'WEBGPU mode is experimental, so its functions and constants may change in future versions. You can get involved by giving feedback to help direct its development!', 'p5.strands': 'p5.strands shaders are experimental, so functions for building shaders and the hooks available within them may change in future versions. You can get involved by giving feedback to help direct its development!', + 'p5.svg': 'SVG features are experimental, so SVG export, import, and shape recording functions may change in future versions. You can get involved by giving feedback to help direct its development!' }; // Just in case it's not possible to get access to the p5 instance from something, From e9bf36eec2614829d5a16f804cfbd50e2ed9062e Mon Sep 17 00:00:00 2001 From: VANSH3104 Date: Wed, 2 Sep 2026 14:03:44 +0530 Subject: [PATCH 2/5] feat(svg): add ShapeRecorder AST node hierarchy and transform stack interceptor --- src/shape/svg/svg_recorder.js | 235 ++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 src/shape/svg/svg_recorder.js diff --git a/src/shape/svg/svg_recorder.js b/src/shape/svg/svg_recorder.js new file mode 100644 index 0000000000..29f4592075 --- /dev/null +++ b/src/shape/svg/svg_recorder.js @@ -0,0 +1,235 @@ +// Abstract tree node hierarchy for the SVG Shape Recorder AST. +// As vector commands (shapes, transforms, backgrounds, images) are issued during a sketch's +// recording pass, they are captured into an object graph of NodeBase sub-classes. +// These nodes are later traversed by an SVG visitor to construct vector element trees. +export class NodeBase { + constructor() { + this.children = []; + } + add(child) { + this.children.push(child); + } +} + +// Scoping node representing matrix push/pop boundaries and nested transformation groups. +export class ScopeNode extends NodeBase { + constructor() { + super(); + this.type = 'scope'; + } + + toSVGElement(visitor) { + visitor.visitScope(this); + } +} + +export class ShapeNode extends NodeBase { + constructor(shape, state) { + super(); + this.type = 'shape'; + this.shape = shape; + this.state = state; + } + toSVGElement(visitor) { + visitor.currentState = this.state; + visitor.currentPathElement = null; + this.shape.accept(visitor); + visitor.currentPathElement = null; + } +} + +export class BackgroundNode extends NodeBase { + constructor(color) { + super(); + this.type = 'background'; + this.color = color; + } + toSVGElement(visitor) { + visitor.addBackground(this); + } +} + +export class ClearNode extends NodeBase { + constructor() { + super(); + this.type = 'clear'; + } + toSVGElement(visitor) { + visitor.clear(); + } +} + +export class ImageNode extends NodeBase { + constructor(img, args, state) { + super(); + this.type = 'image'; + this.img = img; + this.args = args; + this.state = state; + } + toSVGElement(visitor) { + visitor.currentState = this.state; + visitor.visitImage(this); + } +} + +// TransformStack maintains an active stack of DOMMatrix transformation state +// for translating, rotating, scaling, and matrix calculations during shape recording. +export class TransformStack { + constructor() { + this.stack = [new DOMMatrix()]; + } + + push() { + this.stack.push(new DOMMatrix(this.current)); + } + + pop() { + if (this.stack.length > 1) this.stack.pop(); + } + + translate(x, y) { + this.current.translateSelf(x, y); + } + + rotate(rad) { + this.current.rotateSelf(rad * 180 / Math.PI); + } + + scale(x, y) { + this.current.scaleSelf(x, y !== undefined ? y : x); + } + + get current() { + return this.stack[this.stack.length - 1]; + } +} + +// ShapeRecorder intercepts drawing and transformation calls (push, pop, translate, scale, rotate, applyMatrix) +// while active, generating an AST representation of recorded drawing calls. +export class ShapeRecorder { + constructor(pInst, options = {}) { + this.p5 = pInst; + this.active = false; + this.draw = options.draw ?? false; + this.root = new ScopeNode(); + this.scopeStack = [this.root]; + this.tStack = new TransformStack(); + this.restores = []; + this._isTransforming = false; + } + + start() { + this.active = true; + this.root = new ScopeNode(); + this.scopeStack = [this.root]; + this.restores = []; + this._interceptTransforms(); + const renderer = this.p5._renderer; + const adapters = this.p5._svgCaptureAdapters(); + if (renderer) { + for (const name in adapters) { + const restore = adapters[name].intercept(renderer, this); + if (restore) { + this.restores.push(restore); + } + } + } + } + + stop() { + this.active = false; + for (const restore of this.restores) { + restore(); + } + this.restores = []; + } + addNode(node) { + this.scopeStack[ + this.scopeStack.length - 1 + ].add(node); + } + enterScope() { + const scope = new ScopeNode(); + this.addNode(scope); + this.scopeStack.push(scope); + return scope; + } + + leaveScope() { + if (this.scopeStack.length > 1) { + this.scopeStack.pop(); + } + } + _interceptTransforms() { + const p = this.p5; + const renderer = p._renderer; + + const transformHandlers = { + push: () => { + this.tStack.push(); + this.enterScope(); + }, + pop: () => { + this.tStack.pop(); + this.leaveScope(); + }, + translate: (args) => { + this.tStack.translate(args[0] || 0, args[1] || 0); + }, + rotate: (args) => { + this.tStack.rotate(args[0] || 0); + }, + scale: (args) => { + this.tStack.scale(args[0] || 1, args[1]); + }, + applyMatrix: (args) => { + const [a, b, c, d, e, f] = args; + this.tStack.current.multiplySelf( + new DOMMatrix([a, b, c, d, e, f]) + ); + } + }; + + Object.keys(transformHandlers).forEach(method => { + const applyTransform = (origFn, context, args) => { + if (this._isTransforming) { + return origFn.apply(context, args); + } + this._isTransforming = true; + try { + if (this.active) { + transformHandlers[method](args); + } + return origFn.apply(context, args); + } finally { + this._isTransforming = false; + } + }; + + const origP5 = p[method]; + if (typeof origP5 === 'function') { + p[method] = (...args) => { + return applyTransform(origP5, p, args); + }; + this.restores.push(() => { + p[method] = origP5; + }); + } + + if (renderer && typeof renderer[method] === 'function') { + const origR = renderer[method]; + renderer[method] = (...args) => { + return applyTransform(origR, renderer, args); + }; + this.restores.push(() => { + renderer[method] = origR; + }); + } + }); + } + + getRecord() { + return this.root; + } +} From dfa6255ed62fab43f5971e59b76b93914e87f251 Mon Sep 17 00:00:00 2001 From: VANSH3104 Date: Wed, 2 Sep 2026 14:16:30 +0530 Subject: [PATCH 3/5] feat(svg): add SVGExportAddon and SVGVisitor renderer implementation --- src/shape/svg/svg_export.js | 1234 +++++++++++++++++++++++++++++++++++ 1 file changed, 1234 insertions(+) create mode 100644 src/shape/svg/svg_export.js diff --git a/src/shape/svg/svg_export.js b/src/shape/svg/svg_export.js new file mode 100644 index 0000000000..18494ad03f --- /dev/null +++ b/src/shape/svg/svg_export.js @@ -0,0 +1,1234 @@ +import { + ShapeNode, + BackgroundNode, + ClearNode, + ImageNode, + ShapeRecorder +} from "./svg_recorder.js"; + +// SVGExportAddon registers vector shape recording, SVG XML generation, and file download utilities +// on p5.prototype. It hooks into predraw and postdraw lifecycles to automatically capture drawing commands +// when saveSVG() is called without explicit shape parameters. +export function SVGExportAddon(p5, fn, lifecycles) { + let pendingExport = null; + + if (lifecycles) { + // Hook predraw lifecycle to begin recording when an automatic export is requested via saveSVG() + lifecycles.predraw = function () { + if (!pendingExport || pendingExport.shape) { + return; + } + + pendingExport.shape = this.createShape(); + pendingExport.shape.begin({ draw: true }); + }; + + // Hook postdraw lifecycle to finish recording and trigger SVG export/download at frame end + lifecycles.postdraw = function () { + if (!pendingExport || !pendingExport.shape) { + return; + } + + pendingExport.shape.end(); + + exportRecordedShape( + this, + pendingExport.shape, + pendingExport.filename + ); + + pendingExport = null; + }; + } + + // Defines renderer interceptor adapters that capture high-level p5 drawing operations + // (drawShape, background, clear, image) while a ShapeRecorder is active. + fn._svgCaptureAdapters = function () { + return { + + drawShape: { + intercept(renderer, recorder) { + const original = renderer.drawShape; + if (!original) return null; + + renderer.drawShape = function (shape) { + if (recorder.active) { + recorder.addNode( + new ShapeNode(shape, recorder.p5._svgCaptureState(recorder)) + ); + if (p5.Shape) { + renderer._currentShape = new p5.Shape(renderer.getCommonVertexProperties()); + } + if (!recorder.draw) { + return; + } + } + return original.call(renderer, shape); + }; + + // Return restore function + return () => { + renderer.drawShape = original; + }; + } + }, + + background: { + intercept(renderer, recorder) { + const original = renderer.background; + + renderer.background = (...args) => { + if (recorder.active) { + const c = recorder.p5.color(...args); + recorder.addNode(new BackgroundNode(c)); + if (!recorder.draw) { + return; + } + } + return original.apply(renderer, args); + }; + + return () => { + renderer.background = original; + }; + } + }, + + clear: { + intercept(renderer, recorder) { + const original = renderer.clear; + if (!original) return null; + + renderer.clear = (...args) => { + if (recorder.active) { + recorder.addNode(new ClearNode()); + if (!recorder.draw) { + return; + } + } + return original.apply(renderer, args); + }; + + return () => { + renderer.clear = original; + }; + } + }, + + image: { + intercept(renderer, recorder) { + const original = renderer.image; + if (!original) return null; + + renderer.image = function (img, sx, sy, sw, sh, dx, dy, dw, dh) { + if (img) { + if (img instanceof HTMLImageElement && !img.elt) { + img.elt = img; + } + if (img instanceof HTMLCanvasElement && !img.canvas) { + img.canvas = img; + } + } + + if (recorder.active) { + recorder.addNode( + new ImageNode( + img, + [sx, sy, sw, sh, dx, dy, dw, dh], + recorder.p5._svgCaptureState(recorder) + ) + ); + if (!recorder.draw) { + return; + } + } + return original.call(renderer, img, sx, sy, sw, sh, dx, dy, dw, dh); + }; + + return () => { + renderer.image = original; + }; + } + }, + } + } + + // Captures the current active drawing state (fill color, stroke color, stroke weight, stroke cap, + // and cumulative transformation matrix) at the exact moment a shape node is recorded. + fn._svgCaptureState = function (recorder) { + const states = this._renderer.states; + return { + transform: recorder ? new DOMMatrix( + recorder.tStack.current + ) : new DOMMatrix(), + + fill: states.fillColor, + stroke: states.strokeColor, + strokeWeight: this._renderer.states.strokeWeight, + strokeCap: this._renderer.strokeCap() + }; + }; + + // RecordedShape manages the lifecycle of a recorded vector shape session. + // Calling begin() starts ShapeRecorder capture, and end() finalizes the AST data graph. + class RecordedShape { + constructor(pInst) { + this.p5 = pInst; + this.recorder = undefined; + this.data = null; + } + + begin(options = {}) { + this.recorder = new ShapeRecorder(this.p5, { + draw: options ? (options.draw ?? false) : false + }); + this.p5.push(); + this.recorder.start(); + } + + end() { + if (!this.recorder) { + console.warn('end() called without a matching begin().'); + return; + } + this.recorder.stop(); + this.data = this.recorder.getRecord(); + delete this.recorder; + this.p5.pop(); + } + + toSVGElement(visitor) { + if (this.data) { + this.data.toSVGElement(visitor); + } + } + } + + // SVGVisitor implements the Visitor pattern over p5 geometry primitives and ShapeRecorder AST nodes. + // It traverses RecordedShape data graphs to construct valid SVG 2.0 XML DOM elements. + class SVGVisitor extends p5.PrimitiveVisitor { + + constructor(pInst) { + super(); + + this.p5 = pInst; + this.width = pInst.width; + this.height = pInst.height; + + // Initialize root SVG DOM element with the standard namespace + this.svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.svgElement.setAttribute('width', this.width); + this.svgElement.setAttribute('height', this.height); + this.svgElement.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + this.svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + + // For path tracking + this.currentPathElement = null; + } + + _createElement(tagName, attrs = {}) { + const el = document.createElementNS('http://www.w3.org/2000/svg', tagName); + for (const [key, val] of Object.entries(attrs)) { + el.setAttribute(key, val); + } + return el; + } + + _getDefs() { + if (!this.defsElement) { + this.defsElement = this._createElement('defs'); + this.svgElement.insertBefore(this.defsElement, this.svgElement.firstChild); + } + return this.defsElement; + } + + colorToSVG(color) { + if (!color) { + this._currentOpacity = 1; + return 'none'; + } + const [, , , alpha] = color._getRGBA([255, 255, 255, 255]); + + this._currentOpacity = alpha / 255; + + return color.toString('#rrggbb'); + } + + _applyStyle(el) { + const state = this.currentState; + + if (!state) { + return; + } + + this._currentOpacity = 1; + const fill = this.colorToSVG(state.fill); + const fillOpacity = this._currentOpacity; + + this._currentOpacity = 1; + const stroke = this.colorToSVG(state.stroke); + const strokeOpacity = this._currentOpacity; + + el.setAttribute('fill', fill); + el.setAttribute('stroke', stroke); + + if (fillOpacity < 1 && fill !== 'none') { + el.setAttribute('fill-opacity', fillOpacity.toFixed(4)); + } + + if (strokeOpacity < 1 && stroke !== 'none') { + el.setAttribute('stroke-opacity', strokeOpacity.toFixed(4)); + } + + if (state.stroke && state.strokeWeight != null) { + el.setAttribute('stroke-width', state.strokeWeight); + } + + if (state.strokeCap) { + el.setAttribute("stroke-linecap", state.strokeCap); + } + } + + _appendShapeElement(el) { + const m = this.currentState?.transform; + + if ( + m && + !(m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0) + ) { + const g = this._createElement('g'); + g.setAttribute('transform', `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`); + g.appendChild(el); + this.svgElement.appendChild(g); + return; + } + + this.svgElement.appendChild(el); + } + + visitScope(scope) { + for (const child of scope.children) { + child.toSVGElement(this); + } + } + + addBackground(item) { + this._currentOpacity = 1; + const fillStr = this.colorToSVG(item.color); + const opacity = this._currentOpacity; + + const rect = this._createElement('rect', { + x: 0, + y: 0, + width: this.width, + height: this.height, + fill: fillStr + }); + + if (opacity < 1 && fillStr !== 'none') { + rect.setAttribute('fill-opacity', opacity.toFixed(4)); + } + + this.svgElement.appendChild(rect); + } + + clear() { + while (this.svgElement.firstChild) { + this.svgElement.removeChild(this.svgElement.firstChild); + } + } + + // Next is primitive visitor methods for geometry paths, curves, and 2D primitives. + // These methods handle visitor callbacks from p5.PrimitiveVisitor when traversing + // shape geometry graphs (anchors, line segments, bezier curves, splines, arcs, rects, etc.). + + // Path anchor primitive (moves to initial vertex coordinate) + visitAnchor(anchor) { + const vertex = anchor.getEndVertex(); + + if (!this.currentPathElement) { + const pathEl = this._createElement("path", { + d: `M ${vertex.position.x} ${vertex.position.y}` + }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + this.currentPathElement = pathEl; + } else { + const d = this.currentPathElement.getAttribute("d"); + this.currentPathElement.setAttribute( + "d", + `${d} M ${vertex.position.x} ${vertex.position.y}` + ); + } + } + + // Line segment primitive (appends straight line path or closes path segment) + visitLineSegment(lineSegment) { + if (!this.currentPathElement) return; + let d = this.currentPathElement.getAttribute('d') || ''; + if (lineSegment.isClosing) { + d += ' Z'; + } else { + const vertices = lineSegment.vertices; + if (vertices && vertices.length > 0) { + const len = vertices.length; + for (let i = 0; i < len; i++) { + const v = vertices[i]; + const pos = v.position || v; + d += ` L ${pos.x} ${pos.y}`; + } + } else if (typeof lineSegment.getEndVertex === 'function') { + const vertex = lineSegment.getEndVertex(); + if (vertex) { + const pos = vertex.position || vertex; + d += ` L ${pos.x} ${pos.y}`; + } + } + } + this.currentPathElement.setAttribute('d', d); + } + + // Quadratic and cubic Bezier curve primitives (appends Q / C path commands) + visitBezierSegment(bezierSegment) { + if (!this.currentPathElement) return; + let d = this.currentPathElement.getAttribute('d') || ''; + const [v1, v2, v3] = bezierSegment.vertices; + if (bezierSegment.order === 2) { + const p1 = v1?.position || { x: 0, y: 0 }; + const p2 = v2?.position || p1; + d += ` Q ${p1.x} ${p1.y} ${p2.x} ${p2.y}`; + } else if (bezierSegment.order === 3) { + const p1 = v1?.position || { x: 0, y: 0 }; + const p2 = v2?.position || p1; + const p3 = v3?.position || p2; + d += ` C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`; + } + this.currentPathElement.setAttribute('d', d); + } + + // Catmull-Rom spline curve primitives (converts spline control points to cubic Bezier commands) + visitSplineSegment(splineSegment) { + if (!this.currentPathElement) return; + const shape = splineSegment._shape; + let d = this.currentPathElement.getAttribute('d') || ''; + + if ( + splineSegment._splineProperties.ends === this.p5.EXCLUDE && + !splineSegment._comesAfterSegment + ) { + const startVertex = splineSegment._firstInterpolatedVertex; + const startPos = startVertex?.position || { x: 0, y: 0 }; + const sx = startPos.x !== undefined ? startPos.x : (startPos[0] !== undefined ? startPos[0] : (startPos.values ? startPos.values[0] : 0)); + const sy = startPos.y !== undefined ? startPos.y : (startPos[1] !== undefined ? startPos[1] : (startPos.values ? startPos.values[1] : 0)); + d += ` M ${sx} ${sy}`; + } + + const arrayVertices = splineSegment.getControlPoints().map( + v => shape.vertexToArray(v) + ); + const bezierArrays = shape.catmullRomToBezier( + arrayVertices, + splineSegment._splineProperties.tightness + ); + + for (const array of bezierArrays) { + const points = array.flatMap(pt => [pt[0], pt[1]]); + d += ` C ${points[0]} ${points[1]} ${points[2]} ${points[3]} ${points[4]} ${points[5]}`; + } + this.currentPathElement.setAttribute('d', d); + } + + // Arc primitive (renders full circle/ellipse or arc path with pie/chord modes) + visitArcPrimitive(arc) { + const centerX = arc.x + arc.w / 2; + const centerY = arc.y + arc.h / 2; + const radiusX = arc.w / 2; + const radiusY = arc.h / 2; + + const delta = arc.stop - arc.start; + const isFullCircle = Math.abs(delta % (2 * Math.PI)) < 0.00001 && + Math.abs(delta) > 0.00001; + + if (isFullCircle) { + if (radiusX === radiusY) { + const circle = this._createElement('circle', { + cx: centerX, + cy: centerY, + r: radiusX, + }); + this._applyStyle(circle); + this._appendShapeElement(circle); + } else { + const ellipseEl = this._createElement('ellipse', { + cx: centerX, + cy: centerY, + rx: radiusX, + ry: radiusY, + }); + this._applyStyle(ellipseEl); + this._appendShapeElement(ellipseEl); + } + return; + } + + const startX = centerX + radiusX * Math.cos(arc.start); + const startY = centerY + radiusY * Math.sin(arc.start); + const endX = centerX + radiusX * Math.cos(arc.stop); + const endY = centerY + radiusY * Math.sin(arc.stop); + + const largeArcFlag = Math.abs(delta) % (2 * Math.PI) > Math.PI ? 1 : 0; + const sweepFlag = delta > 0 ? 1 : 0; + + const openPath = `M ${startX} ${startY} A ${radiusX} ${radiusY} 0 ${largeArcFlag} ${sweepFlag} ${endX} ${endY}`; + + let dFill = openPath; + let dStroke = openPath; + + const mode = arc.mode ? arc.mode.toLowerCase() : undefined; + if (mode === 'pie') { + dFill = dStroke = `${openPath} L ${centerX} ${centerY} Z`; + } else if (mode === 'chord') { + dFill = dStroke = `${openPath} Z`; + } else if (mode === 'open') { + dFill = dStroke = openPath; + } else { + // default / undefined: fill is pie, stroke is open + dFill = `${openPath} L ${centerX} ${centerY} Z`; + dStroke = openPath; + } + + if (dFill === dStroke) { + const pathEl = this._createElement('path', { d: dFill }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } else { + const state = this.currentState; + const fillStr = this.colorToSVG(state?.fill); + const strokeStr = this.colorToSVG(state?.stroke); + const hasFill = fillStr !== 'none'; + const hasStroke = strokeStr !== 'none' && state?.strokeWeight != null; + + if (hasFill) { + const fillEl = this._createElement('path', { d: dFill }); + this._applyStyle(fillEl); + fillEl.setAttribute('stroke', 'none'); + this._appendShapeElement(fillEl); + } + if (hasStroke) { + const strokeEl = this._createElement('path', { d: dStroke }); + this._applyStyle(strokeEl); + strokeEl.setAttribute('fill', 'none'); + this._appendShapeElement(strokeEl); + } + } + } + + // Ellipse primitive (renders circle or ellipse vector element) + visitEllipsePrimitive(ellipse) { + const cx = ellipse.x + ellipse.w / 2; + const cy = ellipse.y + ellipse.h / 2; + const rx = ellipse.w / 2; + const ry = ellipse.h / 2; + + if (ellipse.w === ellipse.h) { + const circle = this._createElement('circle', { + cx: cx, + cy: cy, + r: rx, + }); + this._applyStyle(circle); + this._appendShapeElement(circle); + } else { + const ellipseEl = this._createElement('ellipse', { + cx: cx, + cy: cy, + rx: rx, + ry: ry, + }); + this._applyStyle(ellipseEl); + this._appendShapeElement(ellipseEl); + } + } + + // Rectangle primitive (supports uniform and individual corner radii) + visitRectPrimitive(rect) { + const x = rect.x; + const y = rect.y; + const w = rect.w; + const h = rect.h; + let tl = rect.tl; + let tr = rect.tr; + let br = rect.br; + let bl = rect.bl; + + const attrs = { + x: x, + y: y, + width: w, + height: h + }; + + if (typeof tl !== 'undefined') { + if (typeof tr === 'undefined') tr = tl; + if (typeof br === 'undefined') br = tr; + if (typeof bl === 'undefined') bl = br; + + if (tl === tr && tl === br && tl === bl) { + attrs.rx = tl; + attrs.ry = tl; + const rectEl = this._createElement('rect', attrs); + this._applyStyle(rectEl); + this._appendShapeElement(rectEl); + } else { + const r_tl = Math.max(0, tl); + const r_tr = Math.max(0, tr); + const r_br = Math.max(0, br); + const r_bl = Math.max(0, bl); + + let d = `M ${x + r_tl} ${y} ` + + `L ${x + w - r_tr} ${y} ` + + `A ${r_tr} ${r_tr} 0 0 1 ${x + w} ${y + r_tr} ` + + `L ${x + w} ${y + h - r_br} ` + + `A ${r_br} ${r_br} 0 0 1 ${x + w - r_br} ${y + h} ` + + `L ${x + r_bl} ${y + h} ` + + `A ${r_bl} ${r_bl} 0 0 1 ${x} ${y + h - r_bl} ` + + `L ${x} ${y + r_tl} ` + + `A ${r_tl} ${r_tl} 0 0 1 ${x + r_tl} ${y} Z`; + + const pathEl = this._createElement('path', { d }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + } else { + const rectEl = this._createElement('rect', attrs); + this._applyStyle(rectEl); + this._appendShapeElement(rectEl); + } + } + + // Point primitive (renders micro-line segment with round stroke-linecap) + visitPoint(point) { + const { x, y } = point.vertices[0].position; + const line = this._createElement('line', { + x1: x, + y1: y, + x2: x + 0.0001, + y2: y + }); + this._applyStyle(line); + line.setAttribute('stroke-linecap', 'round'); + this._appendShapeElement(line); + } + + // Line primitive (renders straight line element) + visitLine(line) { + const { x: x0, y: y0 } = line.vertices[0].position; + const { x: x1, y: y1 } = line.vertices[1].position; + const lineEl = this._createElement('line', { + x1: x0, + y1: y0, + x2: x1, + y2: y1 + }); + this._applyStyle(lineEl); + this._appendShapeElement(lineEl); + } + + // Triangle primitive (renders 3-point polygon element) + visitTriangle(triangle) { + const [v0, v1, v2] = triangle.vertices; + const points = `${v0.position.x},${v0.position.y} ${v1.position.x},${v1.position.y} ${v2.position.x},${v2.position.y}`; + const triangleEl = this._createElement('polygon', { points }); + this._applyStyle(triangleEl); + this._appendShapeElement(triangleEl); + } + + // Quad primitive (renders 4-point polygon element) + visitQuad(quad) { + const [v0, v1, v2, v3] = quad.vertices; + const points = `${v0.position.x},${v0.position.y} ${v1.position.x},${v1.position.y} ${v2.position.x},${v2.position.y} ${v3.position.x},${v3.position.y}`; + const quadEl = this._createElement('polygon', { points }); + this._applyStyle(quadEl); + this._appendShapeElement(quadEl); + } + + // Tessellation primitives + visitTriangleFan(triangleFan) { + if (triangleFan.vertices.length < 3) return; + const [v0, ...rest] = triangleFan.vertices; + let d = ''; + for (let i = 0; i < rest.length - 1; i++) { + const v1 = rest[i]; + const v2 = rest[i + 1]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitTriangleStrip(triangleStrip) { + if (triangleStrip.vertices.length < 3) return; + let d = ''; + for (let i = 0; i < triangleStrip.vertices.length - 2; i++) { + const v0 = triangleStrip.vertices[i]; + const v1 = triangleStrip.vertices[i + 1]; + const v2 = triangleStrip.vertices[i + 2]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitQuadStrip(quadStrip) { + if (quadStrip.vertices.length < 4) return; + let d = ''; + for (let i = 0; i < quadStrip.vertices.length - 3; i += 2) { + const v0 = quadStrip.vertices[i]; + const v1 = quadStrip.vertices[i + 1]; + const v2 = quadStrip.vertices[i + 2]; + const v3 = quadStrip.vertices[i + 3]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v3.position.x} ${v3.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitImage(imageNode) { + const img = imageNode.img; + const [sx, sy, sw, sh, dx, dy, dw, dh] = imageNode.args; + + let dataURL = ''; + if (img) { + if (img.canvas && typeof img.canvas.toDataURL === 'function') { + try { + dataURL = img.canvas.toDataURL(); + } catch (e) {} + } + if (!dataURL && img.elt) { + if (img.elt instanceof HTMLCanvasElement) { + try { + dataURL = img.elt.toDataURL(); + } catch (e) {} + } else if (img.elt instanceof HTMLImageElement) { + if (img.elt.src && img.elt.src.startsWith('data:')) { + dataURL = img.elt.src; + } else { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.elt.naturalWidth || img.width || img.elt.width; + canvas.height = img.elt.naturalHeight || img.height || img.elt.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img.elt, 0, 0); + dataURL = canvas.toDataURL(); + } catch (e) { + dataURL = img.elt.src; + } + } + } + } + if (!dataURL && img instanceof HTMLCanvasElement) { + try { + dataURL = img.toDataURL(); + } catch (e) {} + } + if (!dataURL && img instanceof HTMLImageElement) { + if (img.src && img.src.startsWith('data:')) { + dataURL = img.src; + } else { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + dataURL = canvas.toDataURL(); + } catch (e) { + dataURL = img.src; + } + } + } + if (!dataURL && typeof img === 'string') { + dataURL = img; + } + } + + if (!dataURL) return; + + const imgW = img.width || (img.elt && (img.elt.naturalWidth || img.elt.width)) || 0; + const imgH = img.height || (img.elt && (img.elt.naturalHeight || img.elt.height)) || 0; + + const isCropped = imgW > 0 && imgH > 0 && (sx !== 0 || sy !== 0 || Math.abs(sw - imgW) > 0.1 || Math.abs(sh - imgH) > 0.1); + + let imgEl; + if (isCropped) { + this.clipPathCounter = (this.clipPathCounter || 0) + 1; + const clipId = `clip-p5svg-${this.clipPathCounter}`; + const clipPath = this._createElement('clipPath', { id: clipId }); + const clipRect = this._createElement('rect', { + x: dx, + y: dy, + width: dw, + height: dh + }); + clipPath.appendChild(clipRect); + this._getDefs().appendChild(clipPath); + + const scaleX = dw / sw; + const scaleY = dh / sh; + const fullW = imgW * scaleX; + const fullH = imgH * scaleY; + const imgX = dx - sx * scaleX; + const imgY = dy - sy * scaleY; + + imgEl = this._createElement('image', { + x: imgX, + y: imgY, + width: fullW, + height: fullH, + 'clip-path': `url(#${clipId})`, + preserveAspectRatio: 'none' + }); + } else { + imgEl = this._createElement('image', { + x: dx, + y: dy, + width: dw, + height: dh, + preserveAspectRatio: 'none' + }); + } + + imgEl.setAttribute('href', dataURL); + imgEl.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', dataURL); + + this._appendShapeElement(imgEl); + } + + // ============ END ADDED PRIMITIVES ============ + + buildSVG() { + const serializer = new XMLSerializer(); + return serializer.serializeToString(this.svgElement); + } + } + + // --------------------------------------------------- + // Canvas Replayer + // --------------------------------------------------- + + class CanvasReplay { + constructor(pInst) { + this.p5 = pInst; + } + + replay(record) { + if (!record) return; + if (record instanceof RecordedShape) { + this.replayScope(record.data); + } else { + this.replayScope(record); + } + } + + replayScope(scope) { + for (const child of scope.children) { + switch(child.type) { + case 'scope': + this.replayScope(child); + break; + + case 'shape': + this.replayShape(child); + break; + + case 'background': + this.replayBackground(child); + break; + + case 'clear': + this.replayClear(child); + break; + + case 'image': + this.replayImage(child); + break; + } + } + } + + replayImage(node) { + const p = this.p5; + p.push(); + this.applyState(node.state); + const [sx, sy, sw, sh, dx, dy, dw, dh] = node.args; + p.image(node.img, dx, dy, dw, dh, sx, sy, sw, sh); + p.pop(); + } + + replayShape(shapeNode) { + const p = this.p5; + p.push(); + this.applyState(shapeNode.state); + p._renderer.drawShape(shapeNode.shape); + p.pop(); + } + + replayClear() { + this.p5.clear(); + } + + replayBackground(node) { + const p = this.p5; + + if (!node.color) { + p.clear(); + return; + } + + const [r, g, b, a] = node.color._getRGBA([255, 255, 255, 255]); + p.background(r, g, b, a); + } + + applyState(state) { + const p = this.p5; + if (!state) return; + + if (state.transform) { + const m = state.transform; + p.applyMatrix(m.a, m.b, m.c, m.d, m.e, m.f); + } + + if (state.fill) { + const [r, g, b, a] = state.fill._getRGBA([255, 255, 255, 255]); + p.fill(r, g, b, a); + } else { + p.noFill(); + } + + if (state.stroke) { + const [r, g, b, a] = state.stroke._getRGBA([255, 255, 255, 255]); + p.stroke(r, g, b, a); + } else { + p.noStroke(); + } + + if (state.strokeWeight != null) { + p.strokeWeight(state.strokeWeight); + } + if (state.strokeCap != null) { + p.strokeCap(state.strokeCap); + } + } + } + + + + // --------------------------------------------------- + // API + // --------------------------------------------------- + + function exportRecordedShape(pInst, record, filename = 'drawing.svg') { + const svg = pInst.getSVG(record); + + const blob = new Blob([svg], { + type: 'image/svg+xml' + }); + + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + + // Must append to DOM for browser programmatic download capability + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + URL.revokeObjectURL(url); + } + + // Instantiates a new RecordedShape vector container. + fn.createShape = function () { + return new RecordedShape(this); + }; + + // Helper function that records drawing commands executed inside the provided callback + // into a RecordedShape instance, automatically calling begin() and end(). + fn.buildShape = function (callback, options = {}) { + const shape = this.createShape(); + shape.begin(options); + try { + if (typeof callback === 'function') { + callback(); + } + } finally { + shape.end(); + } + return shape; + }; + + // Generates a valid SVG 2.0 XML string from a RecordedShape instance. + fn.getSVG = function (record) { + const visitor = new SVGVisitor(this); + record.toSVGElement(visitor); + return visitor.buildSVG(); + }; + + const CORNER = 'corner'; + const CENTER = 'center'; + const VIEWBOX = 'viewbox'; + + fn.CORNER = fn.CORNER || CORNER; + fn.CENTER = fn.CENTER || CENTER; + fn.VIEWBOX = fn.VIEWBOX || VIEWBOX; + if (p5) { + p5.CORNER = p5.CORNER || CORNER; + p5.CENTER = p5.CENTER || CENTER; + p5.VIEWBOX = p5.VIEWBOX || VIEWBOX; + } + + function getShapeData(record) { + if (!record) return null; + if (typeof RecordedShape !== 'undefined' && record instanceof RecordedShape) { + return record.data; + } + return record; + } + + function getShapeCoordinateBounds(record) { + const data = getShapeData(record); + if (!data) return null; + + if (data.coordinateBounds) { + return data.coordinateBounds; + } + + const vb = data.viewBox || record?.viewBox; + if ( + vb && + typeof vb.width === 'number' && + typeof vb.height === 'number' && + !isNaN(vb.width) && + !isNaN(vb.height) && + vb.width > 0 && + vb.height > 0 + ) { + return { + x: typeof vb.x === 'number' && !isNaN(vb.x) ? vb.x : 0, + y: typeof vb.y === 'number' && !isNaN(vb.y) ? vb.y : 0, + width: vb.width, + height: vb.height + }; + } + + const w = data.width ?? record?.width; + const h = data.height ?? record?.height; + if (w != null && h != null && !isNaN(w) && !isNaN(h) && w > 0 && h > 0) { + return { + x: 0, + y: 0, + width: w, + height: h + }; + } + + return null; + } + + const ALIGNMENT_REGISTRY = { + corner: (record) => { + const bounds = getShapeCoordinateBounds(record); + if (bounds && typeof bounds.x === 'number' && typeof bounds.y === 'number') { + return { + offsetX: -bounds.x, + offsetY: -bounds.y + }; + } + return { offsetX: 0, offsetY: 0 }; + }, + + center: (record) => { + const bounds = getShapeCoordinateBounds(record); + if (bounds && typeof bounds.width === 'number' && typeof bounds.height === 'number') { + const minX = typeof bounds.x === 'number' ? bounds.x : 0; + const minY = typeof bounds.y === 'number' ? bounds.y : 0; + return { + offsetX: -(minX + bounds.width / 2), + offsetY: -(minY + bounds.height / 2) + }; + } + console.warn( + 'shape(): CENTER alignment requested, but shape record has no valid coordinate bounds metadata.' + ); + return { offsetX: 0, offsetY: 0 }; + }, + + viewbox: () => ({ offsetX: 0, offsetY: 0 }) + }; + + const PLACEMENT_PIPELINE = [ + { + key: 'anchor', + resolve(record, options, x, y) { + if (x === 0 && y === 0) { + return null; + } + return { x, y }; + }, + apply(pInst, params) { + if (typeof pInst.translate === 'function') { + pInst.translate(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(1, 0, 0, 1, params.x, params.y); + } + } + }, + { + key: 'scale', + resolve(record, options, x, y) { + if (!options || options.scale === undefined || options.scale === null) { + return null; + } + const s = options.scale; + let scaleX = 1; + let scaleY = 1; + + if (typeof s === 'number') { + if (!Number.isFinite(s)) { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + scaleX = s; + scaleY = s; + } else if (typeof s === 'object' && s !== null && !Array.isArray(s)) { + if ( + typeof s.x !== 'number' || + !Number.isFinite(s.x) || + typeof s.y !== 'number' || + !Number.isFinite(s.y) + ) { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + scaleX = s.x; + scaleY = s.y; + } else { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + + if (scaleX === 1 && scaleY === 1) { + return null; + } + + return { x: scaleX, y: scaleY }; + }, + apply(pInst, params) { + if (typeof pInst.scale === 'function') { + pInst.scale(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(params.x, 0, 0, params.y, 0, 0); + } + } + }, + { + key: 'align', + resolve(record, options, x, y) { + const alignOption = options && options.align !== undefined ? options.align : CORNER; + const mode = String(alignOption).trim().toLowerCase(); + + const handler = ALIGNMENT_REGISTRY[mode]; + let offsets; + + if (handler) { + offsets = handler(record, options); + } else { + console.warn(`shape(): Unknown alignment mode "${options.align}". Defaulting to CORNER.`); + offsets = ALIGNMENT_REGISTRY.corner(record, options); + } + + const offsetX = offsets?.offsetX || 0; + const offsetY = offsets?.offsetY || 0; + + if (offsetX === 0 && offsetY === 0) { + return null; + } + + return { x: offsetX, y: offsetY }; + }, + apply(pInst, params) { + if (typeof pInst.translate === 'function') { + pInst.translate(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(1, 0, 0, 1, params.x, params.y); + } + } + } + ]; + + function resolveShapePlacement(record, x, y, options = {}) { + const resolved = []; + for (const stage of PLACEMENT_PIPELINE) { + const params = stage.resolve(record, options, x, y); + if (params !== null) { + resolved.push({ stage, params }); + } + } + + return { + resolved, + hasTransform: resolved.length > 0 + }; + } + + function applyShapePlacement(pInst, placement) { + if (!pInst || !placement || !placement.resolved) return; + for (const { stage, params } of placement.resolved) { + stage.apply(pInst, params); + } + } + + fn.shape = function (record, x = 0, y = 0, options = {}) { + const replay = new CanvasReplay(this); + const placement = resolveShapePlacement(record, x, y, options); + + if (placement.hasTransform) { + if (typeof this.push === 'function') this.push(); + applyShapePlacement(this, placement); + replay.replay(record); + if (typeof this.pop === 'function') this.pop(); + } else { + replay.replay(record); + } + }; + + fn.saveSVG = function (arg1, arg2 = 'drawing.svg') { + // Existing API: saveSVG(recordedShape, filename) + if (arg1 instanceof RecordedShape || (arg1 && typeof arg1.toSVGElement === 'function')) { + exportRecordedShape(this, arg1, arg2); + return; + } + + // New API: saveSVG(filename) or saveSVG() + if (typeof arg1 === 'string') { + pendingExport = { + filename: arg1, + p5: this + }; + } else if (typeof arg1 === 'undefined') { + pendingExport = { + filename: arg2, + p5: this + }; + } + }; + +}; + +if (typeof p5 !== 'undefined') { + p5.registerAddon(SVGExportAddon); +} From e8e0eacde28b4c13199aa21bf765780c58a89af5 Mon Sep 17 00:00:00 2001 From: VANSH3104 Date: Wed, 2 Sep 2026 14:18:11 +0530 Subject: [PATCH 4/5] feat(svg): add SVGImportAddon, path command tokenizer, and element converters --- src/shape/svg/svg_import.js | 1538 +++++++++++++++++++++++++++++++++++ 1 file changed, 1538 insertions(+) create mode 100644 src/shape/svg/svg_import.js diff --git a/src/shape/svg/svg_import.js b/src/shape/svg/svg_import.js new file mode 100644 index 0000000000..b0b3fd9193 --- /dev/null +++ b/src/shape/svg/svg_import.js @@ -0,0 +1,1538 @@ +import { ShapeRecorder, ShapeNode, TransformStack } from "./svg_recorder.js"; + +// Map of standard SVG path commands (moveto, lineto, curveto, arcto, closepath) and their expected parameter signatures. +// These definitions are used by the SVG importer to parse path strings into p5 shape drawing operations. +const PATH_COMMANDS = Object.freeze({ + M: { args: ["x", "y"], implicit: "L" }, + m: { args: ["dx", "dy"], implicit: "l" }, + L: { args: ["x", "y"], implicit: "L" }, + l: { args: ["dx", "dy"], implicit: "l" }, + H: { args: ["x"], implicit: "H" }, + h: { args: ["dx"], implicit: "h" }, + V: { args: ["y"], implicit: "V" }, + v: { args: ["dy"], implicit: "v" }, + C: { args: ["x1", "y1", "x2", "y2", "x", "y"], implicit: "C" }, + c: { args: ["dx1", "dy1", "dx2", "dy2", "dx", "dy"], implicit: "c" }, + S: { args: ["x2", "y2", "x", "y"], implicit: "S" }, + s: { args: ["dx2", "dy2", "dx", "dy"], implicit: "s" }, + Q: { args: ["x1", "y1", "x", "y"], implicit: "Q" }, + q: { args: ["dx1", "dy1", "dx", "dy"], implicit: "q" }, + T: { args: ["x", "y"], implicit: "T" }, + t: { args: ["dx", "dy"], implicit: "t" }, + A: { args: ["rx", "ry", "rotation", "largeArc", "sweep", "x", "y"], implicit: "A" }, + a: { args: ["rx", "ry", "rotation", "largeArc", "sweep", "dx", "dy"], implicit: "a" }, + Z: { args: [], implicit: "Z" }, + z: { args: [], implicit: "z" } +}); + +const warnedFeatures = new Set(); +function warnOnce(message) { + if (!warnedFeatures.has(message)) { + warnedFeatures.add(message); + console.warn(message); + } +} + +// TransformResolver parses SVG transform attribute lists (translate, rotate, scale, matrix) +// and multiplies them into the current TransformStack DOMMatrix during SVG element imports. +class TransformResolver { + apply(node, transformStack) { + if (!node.transform?.baseVal) { + return; + } + + const transforms = node.transform.baseVal; + + for (let i = 0; i < transforms.numberOfItems; i++) { + const matrix = transforms.getItem(i).matrix; + + transformStack.current.multiplySelf( + new DOMMatrix([ + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ]) + ); + } + } +} + +// StyleResolver cascades CSS properties, presentation attributes, fill rules, stroke weights, +// opacities, and display/visibility styles down the SVG DOM tree. +class StyleResolver { + resolveNodeStyle(node, parentContext) { + const context = parentContext.clone(); + const styleAttr = node.getAttribute("style"); + const inlineStyle = styleAttr ? this.parseInlineStyle(styleAttr) : null; + + this.resolveColor(context, node, inlineStyle); + this.resolveFill(context, node, inlineStyle); + this.resolveStroke(context, node, inlineStyle, parentContext); + this.resolveOpacity(context, node, inlineStyle, parentContext); + this.resolveDisplayAndVisibility(context, node, inlineStyle, parentContext); + + return context; + } + + resolveColor(context, node, inlineStyle) { + const rawColor = this.getProp(node, inlineStyle, "color"); + if (rawColor !== undefined && rawColor.trim().toLowerCase() !== "currentcolor") { + context.color = rawColor; + } + } + + resolveDisplayAndVisibility(context, node, inlineStyle, parentContext) { + const rawDisplay = this.getProp(node, inlineStyle, "display"); + if (parentContext.display === "none") { + context.display = "none"; + } else if (rawDisplay !== undefined) { + context.display = rawDisplay; + } else { + context.display = "inline"; + } + + const rawVisibility = this.getProp(node, inlineStyle, "visibility"); + if (rawVisibility !== undefined) { + context.visibility = rawVisibility; + } + } + + resolveOpacity(context, node, inlineStyle, parentContext) { + const rawOpacity = this.getProp(node, inlineStyle, "opacity"); + if (rawOpacity !== undefined) { + const val = parseOpacityValue(rawOpacity); + if (!isNaN(val)) { + context.opacity = parentContext.opacity * val; + } + } + const rawFillOpacity = this.getProp(node, inlineStyle, "fill-opacity", "fillOpacity"); + if (rawFillOpacity !== undefined) { + const val = parseOpacityValue(rawFillOpacity); + if (!isNaN(val)) { + context.fillOpacity = val; + } + } + const rawStrokeOpacity = this.getProp(node, inlineStyle, "stroke-opacity", "strokeOpacity"); + if (rawStrokeOpacity !== undefined) { + const val = parseOpacityValue(rawStrokeOpacity); + if (!isNaN(val)) { + context.strokeOpacity = val; + } + } + } + + resolveStroke(context, node, inlineStyle, parentContext) { + const rawStroke = this.getProp(node, inlineStyle, "stroke"); + if (rawStroke !== undefined) { + context.stroke = rawStroke; + } + const rawStrokeWidth = this.getProp(node, inlineStyle, "stroke-width", "strokeWidth"); + if (rawStrokeWidth !== undefined) { + context.strokeWidth = parseLength(rawStrokeWidth, parentContext.strokeWidth); + } + const rawStrokeCap = this.getProp(node, inlineStyle, "stroke-linecap","strokeLinecap"); + + if (rawStrokeCap !== undefined) { + context.strokeCap = rawStrokeCap; + } + } + + resolveFill(context, node, inlineStyle) { + const rawFill = this.getProp(node, inlineStyle, "fill"); + if (rawFill !== undefined) { + context.fill = rawFill; + } + } + + getProp(node, inlineStyle, kebabName, camelName) { + let val; + + if (inlineStyle) { + val = inlineStyle[kebabName]; + if (val !== undefined && val !== "inherit") { + return val; + } + } + + if (this.styleCache) { + const cached = this.styleCache.get(node); + if (cached) { + val = cached[kebabName]; + if (val !== undefined && val !== "inherit" && val !== "") { + return val; + } + } + } + + val = node.getAttribute(kebabName); + if (val !== null && val !== "inherit") { + return val; + } + if (camelName) { + val = node.getAttribute(camelName); + if (val !== null && val !== "inherit") { + return val; + } + } + return undefined; + } + + parseInlineStyle(styleStr) { + const styles = {}; + if (!styleStr) return styles; + const decls = styleStr.split(";"); + for (const decl of decls) { + const colonIndex = decl.indexOf(":"); + if (colonIndex === -1) continue; + const prop = decl.slice(0, colonIndex).trim().toLowerCase(); + const val = decl.slice(colonIndex + 1).trim(); + if (prop && val) { + styles[prop] = val; + } + } + return styles; + } + + preprocess(svgRoot) { + this.styleCache = new WeakMap(); + + const styleEls = svgRoot.querySelectorAll("style"); + const allRules = []; + + for (const styleEl of styleEls) { + // Retrieve stylesheet via native CSSOM + const sheet = styleEl.sheet; + if (!sheet) { + console.warn("SVG Importer Warning: CSS stylesheet could not be parsed via CSSOM (styleEl.sheet is null)."); + continue; + } + + let rulesList; + try { + rulesList = sheet.cssRules; + } catch (e) { + console.warn("SVG Importer Warning: Failed to access cssRules from stylesheet.", e); + continue; + } + + for (let i = 0; i < rulesList.length; i++) { + const rule = rulesList[i]; + + if (rule.type !== CSSRule.STYLE_RULE) { + console.warn(`SVG Importer Warning: Skipping non-style rule type ${rule.type} (${rule.cssText})`); + continue; + } + + const decl = rule.style; + const styles = {}; + for (let j = 0; j < decl.length; j++) { + const prop = decl[j]; + styles[prop] = decl.getPropertyValue(prop).trim(); + } + + if (Object.keys(styles).length > 0) { + const rawSelectors = rule.selectorText; + if (rawSelectors) { + const selectorList = rawSelectors.split(","); + for (const sel of selectorList) { + const selectorText = sel.trim(); + if (selectorText) { + allRules.push({ + selectorText, + styles, + specificity: this.getSpecificity(selectorText) + }); + } + } + } + } + } + } + allRules.sort((a, b) => a.specificity - b.specificity); + + for (const rule of allRules) { + if (!this._isSupportedSelector(rule.selectorText)) continue; + + let matched; + try { + matched = svgRoot.querySelectorAll(rule.selectorText); + } catch (err) { + continue; + } + + for (const el of matched) { + if (!this.styleCache.has(el)) { + this.styleCache.set(el, {}); + } + const cached = this.styleCache.get(el); + for (const [prop, val] of Object.entries(rule.styles)) { + cached[prop] = val; + } + } + } + } + + getSpecificity(selector) { + let a = 0, b = 0, c = 0; + const tokens = selector.split(/[\s>+~]+/); + for (const token of tokens) { + if (!token) continue; + const ids = token.match(/#[a-zA-Z0-9_-]+/g); + if (ids) a += ids.length; + const classes = token.match(/\.[a-zA-Z0-9_-]+/g); + if (classes) b += classes.length; + const attrs = token.match(/\[[^\]]+\]/g); + if (attrs) b += attrs.length; + const cleanToken = token.replace(/#[a-zA-Z0-9_-]+/g, "") + .replace(/\.[a-zA-Z0-9_-]+/g, "") + .replace(/\[[^\]]+\]/g, ""); + if (cleanToken && /^[a-zA-Z]/.test(cleanToken)) { + c += 1; + } + } + return a * 100 + b * 10 + c; + } + + _isSupportedSelector(selectorText) { + return selectorText.split(",").every(part => !part.includes(":")); + } +} + +class RenderContext { + constructor(parent) { + if (parent) { + this.fill = parent.fill; + this.stroke = parent.stroke; + this.strokeWidth = parent.strokeWidth; + this.strokeCap = parent.strokeCap; + this.opacity = parent.opacity; + this.fillOpacity = parent.fillOpacity; + this.strokeOpacity = parent.strokeOpacity; + this.visibility = parent.visibility; + this.display = parent.display === "none" ? "none" : "inline"; + this.color = parent.color; + } else { + this.fill = "rgb(0, 0, 0)"; + this.stroke = "none"; + this.strokeWidth = 1; + this.strokeCap = "butt"; + this.opacity = 1; + this.fillOpacity = 1; + this.strokeOpacity = 1; + this.visibility = "visible"; + this.display = "inline"; + this.color = "rgb(0, 0, 0)"; + //todo future properties like blendMode, etc. + } + } + clone() { + return new RenderContext(this); + } +} + + +// Parses opacity strings (supporting percentages) and clamps them to [0, 1] +function parseOpacityValue(raw) { + if (raw === undefined || raw === null || raw === "") return NaN; + const str = String(raw).trim(); + let val = parseFloat(str); + if (isNaN(val)) return NaN; + if (str.endsWith("%")) { + val = val / 100; + } + return Math.max(0, Math.min(1, val)); +} + +function parseLength(val, defaultValue) { + if (val === undefined || val === null || val === "") return defaultValue; + const str = String(val).trim(); + const num = parseFloat(str); + if (isNaN(num)) return defaultValue; + return num; // Simplified - just return the number +} + +function resolvePairedRadii(rx, ry) { + const hasValidRx = rx !== null && rx !== undefined && !isNaN(rx) && rx >= 0; + const hasValidRy = ry !== null && ry !== undefined && !isNaN(ry) && ry >= 0; + + let resolvedRx = rx; + let resolvedRy = ry; + + if (!hasValidRx && !hasValidRy) { + resolvedRx = 0; + resolvedRy = 0; + } else if (hasValidRx && !hasValidRy) { + resolvedRy = resolvedRx; + } else if (!hasValidRx && hasValidRy) { + resolvedRx = resolvedRy; + } + return { rx: resolvedRx, ry: resolvedRy }; +} + +export function SVGImportAddon(p5, fn, lifecycles) { + class ShapeBuilder { + constructor(pInst, recorder, transformStack) { + this.p5 = pInst; + this.recorder = recorder; + this.transformStack = transformStack; + } + + makeColor(colorStr, opacity, context) { + if (colorStr && colorStr.startsWith("url(")) { + warnOnce("SVG Importer Warning: Gradients and patterns (url(...)) are not supported yet."); + return null; + } + if (!colorStr || colorStr === "none") { + return null; + } + let parsedColor = colorStr.trim(); + if (parsedColor.toLowerCase() === "currentcolor") { + parsedColor = context.color || "rgb(0, 0, 0)"; + if (parsedColor.toLowerCase() === "currentcolor") { + parsedColor = "rgb(0, 0, 0)"; + } + } + try { + // Parse color first + const c = this.p5.color(parsedColor); + // Convert to a standardized RGBA string using documented public API to resolve HSL/HSB to RGB coords + const rgbStr = c.toString('rgba'); + const rgbColor = this.p5.color(rgbStr); + // Set alpha using public API on the RGB-mode color to avoid p5 HSL alpha-scaling bugs + rgbColor.setAlpha(this.p5.alpha(rgbColor) * opacity); + + return rgbColor; + } catch (e) { + warnOnce(`SVG Importer Warning: Failed to parse color: "${colorStr}"`); + return null; + } + } + + captureState(context) { + return { + transform: new DOMMatrix(this.transformStack.current), + fill: this.makeColor(context.fill, context.opacity * context.fillOpacity, context), + stroke: this.makeColor(context.stroke, context.opacity * context.strokeOpacity, context), + strokeWeight: context.strokeWidth, + strokeCap: context.strokeCap, + renderContext: context.clone(), + fillOpacity: context.fillOpacity, + strokeOpacity: context.strokeOpacity, + }; + } + + createShape(builder) { + const shape = new p5.Shape({ + position: new p5.Vector(0, 0) + }); + shape.beginShape(); + builder(shape); + shape.endShape(); + return shape; + } + + addPrimitive(context, builder) { + if (context.visibility === "hidden" || context.visibility === "collapse") { + return; + } + const shape = this.createShape(builder); + const state = this.captureState(context); + this.recorder.addNode( + new ShapeNode(shape, state) + ); + } + + emitShape(shape, context) { + const state = this.captureState(context); + this.recorder.addNode(new ShapeNode(shape, state)); + } + } + + class SVGImporter { + constructor(p5){ + this.p5 = p5; + this.recorder = new ShapeRecorder(p5); + this.tStack = new TransformStack(); + this.renderContextStack = [new RenderContext()]; + this.styleResolver = new StyleResolver(); + this.transformResolver = new TransformResolver(); + this.shapeBuilder = new ShapeBuilder( + p5, + this.recorder, + this.tStack + ); + this.definitions = new Map(); + this.activeRefs = new Set(); + } + + get currentRenderContext() { + return this.renderContextStack[ + this.renderContextStack.length - 1 + ]; + } + import(svg) { + const host = document.createElement("div"); + host.style.position = "absolute"; + host.style.left = "-99999px"; + host.style.visibility = "hidden"; + host.style.pointerEvents = "none"; + + document.body.appendChild(host); + try { + host.appendChild(svg); + this.styleResolver.preprocess(svg); + this.buildIdMap(svg); + this.visit(host.firstChild); + } finally { + host.remove(); + } + const record = this.recorder.getRecord(); + record.sourceSVG = svg.cloneNode(true); + + let viewBox = undefined; + if (svg.viewBox && svg.viewBox.baseVal) { + try { + const vb = svg.viewBox.baseVal; + if ( + typeof vb.x === "number" && + typeof vb.y === "number" && + typeof vb.width === "number" && + typeof vb.height === "number" && + vb.width > 0 && + vb.height > 0 + ) { + viewBox = { + x: vb.x, + y: vb.y, + width: vb.width, + height: vb.height + }; + } + } catch (e) { + // Ignore DOMException + } + } + if (!viewBox && svg.hasAttribute && svg.hasAttribute("viewBox")) { + const rawVb = svg.getAttribute("viewBox").trim(); + const parts = rawVb.split(/[\s,]+/).map((v) => parseFloat(v)); + if (parts.length === 4 && !parts.some((v) => isNaN(v)) && parts[2] > 0 && parts[3] > 0) { + viewBox = { + x: parts[0], + y: parts[1], + width: parts[2], + height: parts[3] + }; + } + } + + let width = undefined; + if (svg.width && svg.width.baseVal) { + try { + const val = svg.width.baseVal.value; + if (typeof val === "number" && !isNaN(val) && val > 0) { + width = val; + } + } catch (e) { + // Ignore + } + } + if (width === undefined && svg.hasAttribute && svg.hasAttribute("width")) { + const parsedW = parseFloat(svg.getAttribute("width")); + if (!isNaN(parsedW)) { + width = parsedW; + } + } + + let height = undefined; + if (svg.height && svg.height.baseVal) { + try { + const val = svg.height.baseVal.value; + if (typeof val === "number" && !isNaN(val) && val > 0) { + height = val; + } + } catch (e) { + // Ignore + } + } + if (height === undefined && svg.hasAttribute && svg.hasAttribute("height")) { + const parsedH = parseFloat(svg.getAttribute("height")); + if (!isNaN(parsedH)) { + height = parsedH; + } + } + + record.width = width; + record.height = height; + record.viewBox = viewBox; + + if (viewBox) { + record.coordinateBounds = { + x: viewBox.x, + y: viewBox.y, + width: viewBox.width, + height: viewBox.height + }; + } else if (width != null && height != null) { + record.coordinateBounds = { + x: 0, + y: 0, + width: width, + height: height + }; + } + + return record; + } + + buildIdMap(node) { + if (node.id && !this.definitions.has(node.id)) { + this.definitions.set(node.id, node); + } + for (const child of node.children) { + this.buildIdMap(child); + } + } + + visit(node) { + if (!node) { + return; + } + const visitor = VISITORS[node.localName]; + if (!visitor) { + return; + } + this.tStack.push(); + this.transformResolver.apply(node, this.tStack); + const parentContext = this.currentRenderContext; + const context = this.styleResolver.resolveNodeStyle(node, parentContext); + this.renderContextStack.push(context); + + if (context.display === "none") { + this.renderContextStack.pop(); + this.tStack.pop(); + return; + } + visitor.call(this, node, context); + + this.renderContextStack.pop(); + this.tStack.pop(); + } + + withRefGuard(refId, fn) { + if (this.activeRefs.has(refId)) { + return; // cycle detected — bail silently + } + this.activeRefs.add(refId); + try { + fn(); + } finally { + this.activeRefs.delete(refId); + } + } + + num(node, attr, fallback = 0) { + if (!node.hasAttribute(attr)) { + return fallback; + } + if (node[attr] && node[attr].baseVal) { + return node[attr].baseVal.value; + } + const val = node.getAttribute(attr); + return parseLength(val, fallback); + } + + visitSVG(node) { + for (const child of node.children) { + this.visit(child); + } + } + + visitGroup(node) { + this.recorder.enterScope(); + + for (const child of node.children) { + this.visit(child); + } + + this.recorder.leaveScope(); + } + + + visitDefs() { + // Definitions are collected during preprocessing. + // Rendering happens when referenced via . + } + + visitUse(node) { + const href = node.getAttribute("href") || node.getAttribute("xlink:href"); + if (!href || !href.startsWith("#")) { + return; + } + + const refId = href.slice(1); + const referenced = this.definitions.get(refId); + if (!referenced) { + return; + } + + this.withRefGuard(refId, () => { + const x = this.num(node, "x"); + const y = this.num(node, "y"); + if (x !== 0 || y !== 0) { + this.tStack.current.translateSelf(x, y); + } + const vb = referenced.viewBox?.baseVal; + if (vb && vb.width && vb.height) { + const w = node.hasAttribute("width") + ? this.num(node, "width") + : (referenced.width?.baseVal?.value || vb.width); + const h = node.hasAttribute("height") + ? this.num(node, "height") + : (referenced.height?.baseVal?.value || vb.height); + + const scale = Math.min(w / vb.width, h / vb.height); // default: xMidYMid meet + this.tStack.current.translateSelf( + (w - vb.width * scale) / 2 - vb.x * scale, + (h - vb.height * scale) / 2 - vb.y * scale + ); + this.tStack.current.scaleSelf(scale, scale); + } + this.visit(referenced); + }); + } + + visitCircle(node, context) { + const r = this.num(node, "r"); + if (r <= 0) return; + + this.shapeBuilder.addPrimitive(context, shape => { + shape.ellipsePrimitive( + this.num(node, "cx") - r, + this.num(node, "cy") - r, + r * 2, + r * 2 + ); + }); + } + + visitEllipse(node, context) { + const rx = this.num(node, "rx", NaN); + const ry = this.num(node, "ry", NaN); + + const { rx: resolvedRx, ry: resolvedRy } = resolvePairedRadii(rx, ry); + + if (resolvedRx <= 0 || resolvedRy <= 0) return; + this.shapeBuilder.addPrimitive(context, shape => { + shape.ellipsePrimitive( + this.num(node, "cx") - resolvedRx, + this.num(node, "cy") - resolvedRy, + resolvedRx * 2, + resolvedRy * 2 + ); + }); + } + + visitLine(node, context) { + this.shapeBuilder.addPrimitive(context, shape => { + shape.line( + this.num(node, "x1"), + this.num(node, "y1"), + this.num(node, "x2"), + this.num(node, "y2") + ); + }); + } + + visitRect(node, context) { + const w = this.num(node, "width"); + const h = this.num(node, "height"); + if (w <= 0 || h <= 0) return; + + const rx = this.num(node, "rx", null); + const ry = this.num(node, "ry", null); + + const { rx: resolvedRx, ry: resolvedRy } = resolvePairedRadii(rx, ry); + + const x = this.num(node, "x"); + const y = this.num(node, "y"); + + let clampedRx = Math.max(0, Math.min(resolvedRx, w / 2)); + let clampedRy = Math.max(0, Math.min(resolvedRy, h / 2)); + if (clampedRx === 0 || clampedRy === 0) { + clampedRx = 0; + clampedRy = 0; + } + + if (clampedRx > 0 && clampedRy > 0 && clampedRx !== clampedRy) { + this.shapeBuilder.addPrimitive(context, shape => { + this.buildRoundedRect(shape, x, y, w, h, clampedRx, clampedRy); + }); + } else { + this.shapeBuilder.addPrimitive(context, shape => { + this.buildSimpleRect(shape, x, y, w, h, clampedRx); + }); + } + } + + visitPolygon(node, context) { + const points = this.getNativePoints(node); + this.shapeBuilder.addPrimitive(context, shape => { + for (const pt of points) { + shape.vertex(new p5.Vector(pt.x, pt.y)); + } + shape.endShape(this.p5.CLOSE); + }); + } + + visitPolyline(node, context) { + const points = this.getNativePoints(node); + this.shapeBuilder.addPrimitive(context, shape => { + for (const pt of points) { + shape.vertex(new p5.Vector(pt.x, pt.y)); + } + }); + } + + visitPath(node, context) { + this.shapeBuilder.addPrimitive(context, shape => { + if (typeof node.getPathData === "function") { + this.buildFromPathData(shape, node.getPathData()); + } else { + const d = node.getAttribute("d") || ""; + this.buildFromLegacyPath(shape, d); + } + }); + } + + emitCubicSegments(shape, segments) { + for (const seg of segments) { + this.emitSingleCubic(shape, seg.cp1, seg.cp2, seg.end); + } + } + + emitSingleCubic(shape, cp1, cp2, end) { + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1.x, cp1.y)); + shape.bezierVertex(new p5.Vector(cp2.x, cp2.y)); + shape.bezierVertex(new p5.Vector(end.x, end.y)); + } + + buildRoundedRect(shape, x, y, w, h, rx, ry) { + const k = 0.5523; + + // Start + shape.vertex(new p5.Vector(x + rx, y)); + + // Top edge + shape.vertex(new p5.Vector(x + w - rx, y)); + + // Top-right corner + this.emitSingleCubic( + shape, + { x: x + w - rx + rx * k, y: y }, + { x: x + w, y: y + ry - ry * k }, + { x: x + w, y: y + ry } + ); + + // Right edge + shape.vertex(new p5.Vector(x + w, y + h - ry)); + + // Bottom-right corner + this.emitSingleCubic( + shape, + { x: x + w, y: y + h - ry + ry * k }, + { x: x + w - rx + rx * k, y: y + h }, + { x: x + w - rx, y: y + h } + ); + + // Bottom edge + shape.vertex(new p5.Vector(x + rx, y + h)); + + // Bottom-left corner + this.emitSingleCubic( + shape, + { x: x + rx - rx * k, y: y + h }, + { x: x, y: y + h - ry + ry * k }, + { x: x, y: y + h - ry } + ); + + // Left edge + shape.vertex(new p5.Vector(x, y + ry)); + + // Top-left corner + this.emitSingleCubic( + shape, + { x: x, y: y + ry - ry * k }, + { x: x + rx - rx * k, y: y }, + { x: x + rx, y: y } + ); + + shape.endShape(this.p5.CLOSE); + } + + buildSimpleRect(shape, x, y, w, h, r) { + if (r > 0) { + shape.rectPrimitive(x, y, w, h, r, r, r, r); + } else { + shape.rectPrimitive(x, y, w, h); + } + } + + parsePointsAttribute(pointsAttr) { + const points = []; + const matches = pointsAttr.match(/-?[\d.]+/g); + if (matches) { + for (let i = 0; i < matches.length - 1; i += 2) { + const x = parseFloat(matches[i]); + const y = parseFloat(matches[i + 1]); + if (!isNaN(x) && !isNaN(y)) { + points.push({ x, y }); + } + } + } + return points; + } + + getNativePoints(node) { + const list = node.points; + if (list && list.numberOfItems > 0) { + const points = []; + for (let i = 0; i < list.numberOfItems; i++) { + const pt = list.getItem(i); + points.push({ x: pt.x, y: pt.y }); + } + return points; + } + + const pointsAttr = node.getAttribute("points"); + return pointsAttr ? this.parsePointsAttribute(pointsAttr) : []; + } + + // --- Legacy fallback parser ------------------------------------------------ + + parsePathData(d) { + const commands = []; + let i = 0; + const len = d.length; + + let currentCommand = ''; + let argIndexForCommand = 0; + let currentCommandObj = null; + let isCurrentCommandObjPushed = false; + + // Helper to skip whitespace and commas + function skipWhitespaceAndCommas() { + while (i < len) { + const char = d[i]; + if (char === ' ' || char === '\t' || char === '\r' || char === '\n' || char === ',') { + i++; + } else { + break; + } + } + } + + const COMMANDS = "MmLlHhVvCcSsQqTtAaZz"; + function isCommandChar(char) { + return COMMANDS.includes(char); + } + + while (i < len) { + skipWhitespaceAndCommas(); + if (i >= len) break; + + const char = d[i]; + + // 1. Check if it's a command + if (isCommandChar(char)) { + currentCommand = char; + argIndexForCommand = 0; + currentCommandObj = { type: char }; + const cmdMeta = PATH_COMMANDS[char]; + if (cmdMeta && cmdMeta.args.length === 0) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } else { + isCurrentCommandObjPushed = false; + } + i++; + continue; + } + + const argName = PATH_COMMANDS[currentCommand]?.args[argIndexForCommand]; + const isFlag = argName === "largeArc" || argName === "sweep"; + + if (isFlag) { + // A flag is just a single character: '0' or '1' + if (char === '0' || char === '1') { + const numVal = Number(char); + if (currentCommandObj) { + if (!isCurrentCommandObjPushed) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } + const argName = PATH_COMMANDS[currentCommand].args[argIndexForCommand]; + currentCommandObj[argName] = numVal; + } + argIndexForCommand++; + if (argIndexForCommand >= 7) { + argIndexForCommand = 0; // Wrap around for repeated arc parameters + } + i++; + } else { + // Invalid flag, abort parsing to avoid infinite loop + warnOnce("SVG Importer Warning: Malformed SVG path data (invalid arc flag)."); + break; + } + } else { + // Parse a general float/number + const slice = d.substring(i); + const numMatch = slice.match(/^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/); + if (numMatch) { + const numStr = numMatch[0]; + const numVal = Number(numStr); + i += numStr.length; + + if (currentCommandObj) { + if (!isCurrentCommandObjPushed) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } + const argName = PATH_COMMANDS[currentCommand].args[argIndexForCommand]; + currentCommandObj[argName] = numVal; + } + + // Update parameter index for the current command + if (currentCommand) { + const cmdMeta = PATH_COMMANDS[currentCommand]; + const totalArgs = cmdMeta ? cmdMeta.args.length : 0; + if (totalArgs > 0) { + argIndexForCommand++; + if (argIndexForCommand >= totalArgs) { + currentCommand = cmdMeta.implicit; + argIndexForCommand = 0; + currentCommandObj = { type: currentCommand }; + isCurrentCommandObjPushed = false; + } + } + } + } else { + // Unrecognized character (skip to prevent infinite loop) + warnOnce("SVG Importer Warning: Malformed SVG path data (unrecognized character)."); + i++; + } + } + } + + + return commands; + } + + arcToBezier(x1, y1, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x2, y2) { + if (x1 === x2 && y1 === y2) { + return []; + } + if (rx === 0 || ry === 0) { + return [{ + cp1: { x: x1, y: y1 }, + cp2: { x: x2, y: y2 }, + end: { x: x2, y: y2 } + }]; + } + + rx = Math.abs(rx); + ry = Math.abs(ry); + + const phi = (xAxisRotation * Math.PI) / 180; + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + + const dx = (x1 - x2) / 2; + const dy = (y1 - y2) / 2; + const x1p = cosPhi * dx + sinPhi * dy; + const y1p = -sinPhi * dx + cosPhi * dy; + + let rxSq = rx * rx; + let rySq = ry * ry; + const x1pSq = x1p * x1p; + const y1pSq = y1p * y1p; + + let radiiCheck = x1pSq / rxSq + y1pSq / rySq; + if (radiiCheck > 1) { + rx *= Math.sqrt(radiiCheck); + ry *= Math.sqrt(radiiCheck); + rxSq = rx * rx; + rySq = ry * ry; + } + + const sign = largeArcFlag === sweepFlag ? -1 : 1; + const sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq); + const coef = sign * Math.sqrt(Math.max(0, sq)); + const cxp = coef * ((rx * y1p) / ry); + const cyp = coef * -((ry * x1p) / rx); + + const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2; + const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2; + + const sx = (x1p - cxp) / rx; + const sy = (y1p - cyp) / ry; + const tx = (-x1p - cxp) / rx; + const ty = (-y1p - cyp) / ry; + + const angleBetween = (ux, uy, vx, vy) => { + const dot = ux * vx + uy * vy; + const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); + let angle = Math.acos(Math.max(-1, Math.min(1, dot / len))); + if (ux * vy - uy * vx < 0) { + angle = -angle; + } + return angle; + }; + + const theta1 = angleBetween(1, 0, sx, sy); + let deltaTheta = angleBetween(sx, sy, tx, ty); + + if (sweepFlag === 0 && deltaTheta > 0) { + deltaTheta -= 2 * Math.PI; + } else if (sweepFlag === 1 && deltaTheta < 0) { + deltaTheta += 2 * Math.PI; + } + + const segments = Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2)); + const bezierSegments = []; + + let tStart = theta1; + const tDiv = deltaTheta / segments; + + for (let i = 0; i < segments; i++) { + const tEnd = tStart + tDiv; + const alpha = Math.sin(tDiv) * (Math.sqrt(4 + 3 * Math.tan(tDiv / 2) * Math.tan(tDiv / 2)) - 1) / 3; + + const cosStart = Math.cos(tStart); + const sinStart = Math.sin(tStart); + const cosEnd = Math.cos(tEnd); + const sinEnd = Math.sin(tEnd); + + const eX1 = cosStart - alpha * sinStart; + const eY1 = sinStart + alpha * cosStart; + const eX2 = cosEnd + alpha * sinEnd; + const eY2 = sinEnd - alpha * cosEnd; + const eX3 = cosEnd; + const eY3 = sinEnd; + + const transformPoint = (x, y) => { + const rxX = rx * x; + const ryY = ry * y; + return { + x: cosPhi * rxX - sinPhi * ryY + cx, + y: sinPhi * rxX + cosPhi * ryY + cy + }; + }; + + const cp1 = transformPoint(eX1, eY1); + const cp2 = transformPoint(eX2, eY2); + const end = transformPoint(eX3, eY3); + + bezierSegments.push({ cp1, cp2, end }); + tStart = tEnd; + } + + return bezierSegments; + } + + // --- Path Geometry Handlers --- + + handlePathM(shape, state, args) { + const { x, y } = args; + state.currentX = x; + state.currentY = y; + state.startX = state.currentX; + state.startY = state.currentY; + if (!state.isFirstContour) { + shape.beginContour(); + } + state.isFirstContour = false; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathm(shape, state, args) { + const { dx, dy } = args; + state.currentX += dx; + state.currentY += dy; + state.startX = state.currentX; + state.startY = state.currentY; + if (!state.isFirstContour) { + shape.beginContour(); + } + state.isFirstContour = false; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathL(shape, state, args) { + const { x, y } = args; + state.currentX = x; + state.currentY = y; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathl(shape, state, args) { + const { dx, dy } = args; + state.currentX += dx; + state.currentY += dy; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathH(shape, state, args) { + const {x} = args; + state.currentX = x; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathh(shape, state, args) { + const {dx} = args; + state.currentX += dx; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathV(shape, state, args) { + const {y} = args; + state.currentY = y; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathv(shape, state, args) { + const {dy} = args; + state.currentY += dy; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathC(shape, state, args) { + const { x1: cp1x, y1: cp1y, x2: cp2x, y2: cp2y, x: endx, y: endy } = args; + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1x, cp1y)); + shape.bezierVertex(new p5.Vector(cp2x, cp2y)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cp2x; + state.lastControlY = cp2y; + state.currentX = endx; + state.currentY = endy; + } + + handlePathc(shape, state, args) { + const { dx1: cp1dx, dy1: cp1dy, dx2: cp2dx, dy2: cp2dy, dx: enddx, dy: enddy } = args; + const absCp1x = cp1dx + state.currentX; + const absCp1y = cp1dy + state.currentY; + const absCp2x = cp2dx + state.currentX; + const absCp2y = cp2dy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(absCp1x, absCp1y)); + shape.bezierVertex(new p5.Vector(absCp2x, absCp2y)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCp2x; + state.lastControlY = absCp2y; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathS(shape, state, args) { + const { x2: cp2x, y2: cp2y, x: endx, y: endy } = args; + let cp1x = state.currentX; + let cp1y = state.currentY; + if (state.lastCommand === 'C' || state.lastCommand === 'c' || state.lastCommand === 'S' || state.lastCommand === 's') { + cp1x = 2 * state.currentX - state.lastControlX; + cp1y = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1x, cp1y)); + shape.bezierVertex(new p5.Vector(cp2x, cp2y)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cp2x; + state.lastControlY = cp2y; + state.currentX = endx; + state.currentY = endy; + } + + handlePaths(shape, state, args) { + const { dx2: cp2dx, dy2: cp2dy, dx: enddx, dy: enddy } = args; + const absCp2x = cp2dx + state.currentX; + const absCp2y = cp2dy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + let cp1dx = state.currentX; + let cp1dy = state.currentY; + if (state.lastCommand === 'C' || state.lastCommand === 'c' || state.lastCommand === 'S' || state.lastCommand === 's') { + cp1dx = 2 * state.currentX - state.lastControlX; + cp1dy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1dx, cp1dy)); + shape.bezierVertex(new p5.Vector(absCp2x, absCp2y)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCp2x; + state.lastControlY = absCp2y; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathQ(shape, state, args) { + const { x1: cpx, y1: cpy, x: endx, y: endy } = args; + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = endx; + state.currentY = endy; + } + + handlePathq(shape, state, args) { + const { dx1: cpdx, dy1: cpdy, dx: enddx, dy: enddy } = args; + const absCpx = cpdx + state.currentX; + const absCpy = cpdy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(absCpx, absCpy)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCpx; + state.lastControlY = absCpy; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathT(shape, state, args) { + const { x: endx, y: endy } = args; + let cpx = state.currentX; + let cpy = state.currentY; + if (state.lastCommand === 'Q' || state.lastCommand === 'q' || state.lastCommand === 'T' || state.lastCommand === 't') { + cpx = 2 * state.currentX - state.lastControlX; + cpy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = endx; + state.currentY = endy; + } + + handlePatht(shape, state, args) { + const { dx: enddx, dy: enddy } = args; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + let cpx = state.currentX; + let cpy = state.currentY; + if (state.lastCommand === 'Q' || state.lastCommand === 'q' || state.lastCommand === 'T' || state.lastCommand === 't') { + cpx = 2 * state.currentX - state.lastControlX; + cpy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathA(shape, state, args) { + const { rx, ry, rotation: xAxisRotation, largeArc: largeArcFlag, sweep: sweepFlag, x: endx, y: endy } = args; + const segments = this.arcToBezier(state.currentX, state.currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, endx, endy); + this.emitCubicSegments(shape, segments); + state.lastControlX = state.currentX = endx; + state.lastControlY = state.currentY = endy; + } + + handlePatha(shape, state, args) { + const { rx, ry, rotation: xAxisRotation, largeArc: largeArcFlag, sweep: sweepFlag, dx: enddx, dy: enddy } = args; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + const segments = this.arcToBezier(state.currentX, state.currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, absEndx, absEndy); + this.emitCubicSegments(shape, segments); + state.lastControlX = state.currentX = absEndx; + state.lastControlY = state.currentY = absEndy; + } + + buildFromCommands(shape, commands) { + const state = { + currentX: 0, + currentY: 0, + lastControlX: 0, + lastControlY: 0, + startX: 0, + startY: 0, + lastCommand: '', + isFirstContour: true + }; + + for (const cmdObj of commands) { + const cmd = cmdObj.type; + + if (cmd === 'Z' || cmd === 'z') { + shape.endContour(this.p5.CLOSE); + state.currentX = state.startX; + state.currentY = state.startY; + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + state.lastCommand = cmd; + continue; + } + + const handler = PATH_HANDLERS[cmd]; + if (handler) { + handler.call(this, shape, state, cmdObj); + } + state.lastCommand = cmd; + } + } + + buildFromLegacyPath(shape, d) { + const commands = this.parsePathData(d); + this.buildFromCommands(shape, commands); + } + + buildFromPathData(shape, pathData) { + const commands = pathData.map(cmd => { + const command = { type: cmd.type }; + const argNames = PATH_COMMANDS[cmd.type].args; + + argNames.forEach((name, i) => { + command[name] = cmd.values[i]; + }); + return command; + }); + this.buildFromCommands(shape, commands); + } + } + + const VISITORS = Object.freeze({ + svg: SVGImporter.prototype.visitSVG, + g: SVGImporter.prototype.visitGroup, + symbol: SVGImporter.prototype.visitGroup, + circle: SVGImporter.prototype.visitCircle, + ellipse: SVGImporter.prototype.visitEllipse, + line: SVGImporter.prototype.visitLine, + rect: SVGImporter.prototype.visitRect, + polygon: SVGImporter.prototype.visitPolygon, + polyline: SVGImporter.prototype.visitPolyline, + path: SVGImporter.prototype.visitPath, + defs: SVGImporter.prototype.visitDefs, + use: SVGImporter.prototype.visitUse, + }); + + const PATH_HANDLERS = Object.freeze({ + M: SVGImporter.prototype.handlePathM, + m: SVGImporter.prototype.handlePathm, + L: SVGImporter.prototype.handlePathL, + l: SVGImporter.prototype.handlePathl, + H: SVGImporter.prototype.handlePathH, + h: SVGImporter.prototype.handlePathh, + V: SVGImporter.prototype.handlePathV, + v: SVGImporter.prototype.handlePathv, + C: SVGImporter.prototype.handlePathC, + c: SVGImporter.prototype.handlePathc, + S: SVGImporter.prototype.handlePathS, + s: SVGImporter.prototype.handlePaths, + Q: SVGImporter.prototype.handlePathQ, + q: SVGImporter.prototype.handlePathq, + T: SVGImporter.prototype.handlePathT, + t: SVGImporter.prototype.handlePatht, + A: SVGImporter.prototype.handlePathA, + a: SVGImporter.prototype.handlePatha, + }); + + // Helper function that parses SVG XML markup or accepts an SVG DOM element, + // importing it into a RecordedShape via SVGImporter. + function createSVGText(pInst, input) { + let svg; + + if (typeof input === "string") { + const parser = new DOMParser(); + const doc = parser.parseFromString(input, "image/svg+xml"); + svg = doc.documentElement; + } else { + svg = input; + } + const importer = new SVGImporter(pInst); + return importer.import(svg); + } + + // Synchronously converts an SVG string or DOM element into a RecordedShape instance. + fn.createSVG = function (input) { + return createSVGText(this, input); + }; + + // Asynchronously loads an external SVG file from a URL path, + // returning a Promise that resolves to a RecordedShape instance. + + + + fn.loadSVG = async function ( + path, + successCallback, + failureCallback + ) { + try { + const req = new Request(path, { + method: 'GET', + mode: 'cors' + }); + let svgText; + if (typeof request === 'function') { + const { data } = await request(req, 'text'); + svgText = data; + } else { + const response = await fetch(req); + if (!response.ok) { + throw new Error(`Failed to load SVG: ${path}`); + } + svgText = await response.text(); + } + const shape = createSVGText(this, svgText); + const cb = () => { + if (successCallback) { + return successCallback(shape); + } + return shape; + }; + return this._internal + ? this._internal(cb) + : cb(); + } catch (err) { + if (typeof p5._friendlyFileLoadError === 'function') { + p5._friendlyFileLoadError(1, path); + } + if (typeof failureCallback === 'function') { + return failureCallback(err); + } else { + throw err; + } + } + }; +} + +if (typeof p5 !== 'undefined') { + p5.registerAddon(SVGImportAddon); +} \ No newline at end of file From ff8c36d6d24d7085fcbf5a9db3007a8220c60777 Mon Sep 17 00:00:00 2001 From: VANSH3104 Date: Wed, 2 Sep 2026 14:19:03 +0530 Subject: [PATCH 5/5] feat(svg): add p5.svg entry point with experimental decorators and register in src/app.js --- src/app.js | 2 ++ src/shape/svg/p5.svg.js | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/shape/svg/p5.svg.js diff --git a/src/app.js b/src/app.js index 77ee64902b..85969ef079 100644 --- a/src/app.js +++ b/src/app.js @@ -59,6 +59,8 @@ import shader from './webgl/p5.Shader'; p5.registerAddon(shader); import strands from './strands/p5.strands'; p5.registerAddon(strands); +import svg from './shape/svg/p5.svg'; +p5.registerAddon(svg); import { waitForDocumentReady, _globalInit } from './core/init'; waitForDocumentReady().then(_globalInit); diff --git a/src/shape/svg/p5.svg.js b/src/shape/svg/p5.svg.js new file mode 100644 index 0000000000..2c4d753039 --- /dev/null +++ b/src/shape/svg/p5.svg.js @@ -0,0 +1,41 @@ +/** + * @module SVG + * @submodule p5.svg + * @for p5 + */ + +import { SVGExportAddon } from './svg_export.js'; +import { SVGImportAddon } from './svg_import.js'; +import { markExperimental } from '../../core/experimental.js'; + +// Initializes the p5.js SVG module by combining export and import functionality. +// Registers public APIs on p5.prototype and marks experimental features with +// warning decorators to inform users about API stability during the 2.x lifecycle. +function svg(p5, fn, lifecycles) { + // Register core export (shape recording, vector output) and import (SVG parser) extensions. + SVGExportAddon(p5, fn, lifecycles); + SVGImportAddon(p5, fn, lifecycles); + + // List of user-facing SVG methods marked as experimental. + // Decorators log friendly error warnings when these methods are invoked in user sketches. + const experimentalMethods = [ + 'createSVG', + 'loadSVG', + 'createShape', + 'buildShape', + 'getSVG', + 'shape', + 'saveSVG' + ]; + + for (const method of experimentalMethods) { + if (fn[method]) { + p5.registerDecorator( + `p5.prototype.${method}`, + markExperimental('p5.svg', p5) + ); + } + } +} + +export default svg; \ No newline at end of file