diff --git a/README.md b/README.md index 306b294..f1e854b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Simple dependency-free module used to generate SVG images of deterministic L-systems. +![Generated SVG tree](https://amphiluke.github.io/l-systems/img/tree.svg) + ## Installation ### In an npm project @@ -35,12 +37,20 @@ If you rather prefer using ES modules in a browser, just choose the “esm” bu ## API & examples -The module exports two methods: +The module exports two pairs of methods. + +1. The methods returning ready-to-render L-system’s SVG code as a string: + * `getSVGCode(lsParams[, svgParams])`; + * `getMultiPathSVGCode(lsParams[, svgParams])`; +2. The methods returning raw data that you may use to construct the SVG code yourself: + * `getSVGData(lsParams)`; + * `getMultiPathSVGData(lsParams)`. + +The “multi-path” methods (`getMultiPathSVGCode` and `getMultiPathSVGData`) differ from the “normal” methods (`getSVGCode` and `getSVGData`) in that they provide the ability for advanced stylisation of _branched_ L-systems. SVG images created using these “multi-path” methods contain several `` elements, each one for a specific branching level, so they can be stylised differently (color, line width, etc.) -* `getSVGCode(lsParams[, svgParams])`: returns ready-to-render L-system’s SVG code as a string; -* `getSVGData(lsParams)`: returns raw data that you may use to construct the SVG code yourself. +All methods expect L-system parameters object as their first argument. These parameters are explained through the comments in the snippet below. Additionally, the methods `getSVGCode` and `getMultiPathSVGCode` may be passed an _optional_ parameter to alter the output SVG settings (refer the comments in the snippet below). -Both methods expect L-system parameters object as their first argument. These parameters are explained through the comments in the snippet below. Additionally, the method `getSVGCode` may be passed an _optional_ parameter to alter the output SVG settings (refer the comments in the snippet below). +### Using “single-path” methods ```javascript let {getSVGCode, getSVGData} = require("lindsvg"); @@ -65,21 +75,71 @@ let svgParams = { width: 600, // Desired SVG element width height: 600, // Desired SVG element height padding: 5, // Additional space to extend the viewBox - pathAttributes: { // Name to value map for the “path” element attributes + pathAttributes: { // Name to value map for the element attributes stroke: "green", - "stroke-width": "2px" + "stroke-width": "2" } }; -// Get ready-to-render L-system’s SVG code as a string +// Get ready-to-render L-system’s SVG code as a string... let svgCode = getSVGCode(lsParams, svgParams); -// Get raw data required for SVG rendering +// ...or get raw data required for manual SVG assemblage let {pathData, minX, minY, width, height} = getSVGData(lsParams); ``` An object returned by `getSVGData` contains [path data](https://www.w3.org/TR/SVG11/paths.html#PathData) needed to draw the L-system, and also the drawing boundaries that are essential for the `viewBox` attribute. +### Using “multi-path” methods + +Using “multi-path” methods (`getMultiPathSVGCode` and `getMultiPathSVGData`) allows you to specify different path attributes for every `` element separately, which may make branched L-systems (like plants) look “more naturally”. + +For example, the image of a tree [demonstrated above](#lindsvg) was generated using the following options: + +```javascript +let {getMultiPathSVGCode, getMultiPathSVGData} = require("lindsvg"); + +// L-system parameters +let lsParams = { + axiom: "FFF+FFFF-FF+FF-[-Y][+Y][Z][+Z]", + rules: { + F: "F", + Y: "FF+F-F-F[FFFZ][+Z]-F-FZ", + Z: "FF-F+F+F[FY][-Y]+F+F++Y" + }, + alpha: 90 * Math.PI / 180, + theta: 10 * Math.PI / 180, + iterations: 7, + step: 5 +}; + +// Output SVG parameters +let svgParams = { + width: 420, + height: 325, + padding: 10, + pathAttributes: { + stroke: ["#514d3a", "#514d3a", "#514d2a", "#55771c", "#55771c", "#44621c", + "rgba(131, 163, 90, 0.5)", "rgba(164, 184, 102, 0.5)", "rgba(192, 200, 97, 0.5)"], + "stroke-width": ["11", "5", "3", "1"], // the rest items are equal to the last one + "stroke-linecap": ["square", "square", "round"], + transform: ["skewY(-35)", ""] + } +}; + +// Get ready-to-render L-system’s SVG code as a string... +let svgCode = getMultiPathSVGCode(lsParams, svgParams); + +// ...or get raw data required for manual SVG assemblage +let {multiPathData, minX, minY, width, height} = getMultiPathSVGData(lsParams); +``` + +If an attribute array contains less elements than the maximum branching depth (e.g. see `stroke-width` in the example above), the missing items are considered equal to the last one. So you don’t need to repeat the same value in the end of the list. + +The property `multiPathData` in the object returned by `getMultiPathSVGData` is a _list_ of path data for every `` element. The list is sorted in the order of increasing branch level (the deeper the branch the higher the index in the array). + +### Error handling + In case of invalid input L-system parameters, the methods throw a custom exception. You may use it to get a detailed explanation of which parameter(s) failed to pass validation, and format the message as you wish. ```javascript @@ -99,6 +159,6 @@ try { } ``` -### Compatibility note +## Compatibility note lindsvg utilizes the ECMAScript 2018 syntax. If you want to use the module in environments that do not support ES 2018, please transpile the sources with babel or whatever for your needs. diff --git a/dist/lindsvg.esm.js b/dist/lindsvg.esm.js index f543efa..e992116 100644 --- a/dist/lindsvg.esm.js +++ b/dist/lindsvg.esm.js @@ -1,5 +1,5 @@ /*! -lindsvg v1.2.1 +lindsvg v1.3.0 https://amphiluke.github.io/l-systems/ (c) 2020 Amphiluke */ @@ -191,10 +191,10 @@ function createTurtle({x, y, step, alpha, theta}) { * Remove all letters which don’t affect the drawing process from the codeword * and split it into “tokens” for the further processing * @param {String} codeword - L-system code - * @return {Array} + * @return {String[]} */ function tokenizeCodeword(codeword) { - return codeword.match(/([FB[\]+-])\1*/g); + return codeword.replace(/[^FB[\]+-]/g, "").match(/([FB[\]+-])\1*/g); } function formatCoordinates(x, y) { @@ -204,7 +204,7 @@ function formatCoordinates(x, y) { /** * Get the value of the d attribute - * @param {Array} tokens - Tokenized codeword + * @param {String[]} tokens - Tokenized codeword * @param {Object} turtle - Turtle object to work with * @return {String} */ @@ -248,6 +248,63 @@ function getPathData(tokens, turtle) { }, "M" + formatCoordinates(turtle.x, turtle.y)); } +/** + * Get the values of the d attribute for each path element + * @param {String[]} tokens - Tokenized codeword + * @param {Object} turtle - Turtle object to work with + * @return {String[]} + */ +function getMultiPathData(tokens, turtle) { + let prevCommand; // used to avoid unnecessary repeating of the commands L and M + let branchLevel = 0; + let multiPathData = tokens.reduce((accumulator, token) => { + let pathData = accumulator[branchLevel] || ""; + let tokenLength = token.length; + switch (token[0]) { + case "F": + turtle.translate(tokenLength); + pathData += (prevCommand === "L" ? " " : "L") + formatCoordinates(turtle.x, turtle.y); + prevCommand = "L"; + break; + case "B": + turtle.translate(tokenLength); + if (prevCommand === "M") { + // As the spec states, “If a moveto is followed by multiple pairs of coordinates, + // the subsequent pairs are treated as implicit lineto commands”. + // This is not what we want, so delete the preceding moveto command + pathData = pathData.slice(0, pathData.lastIndexOf("M")); + } + pathData += "M" + formatCoordinates(turtle.x, turtle.y); + prevCommand = "M"; + break; + case "+": + turtle.rotate(tokenLength); + break; + case "-": + turtle.rotate(-tokenLength); + break; + case "[": + branchLevel += tokenLength; + turtle.pushStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + case "]": + branchLevel -= tokenLength; + turtle.popStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + } + accumulator[branchLevel] = pathData; + return accumulator; + }, ["M" + formatCoordinates(turtle.x, turtle.y)]); + // Some L-systems can produce branching levels which contain no real draw commands (only moves and rotations). + // Such L-systems usually don’t have F commands in their axiom nor they have a production for F (example is + // the Penrose tiling). Having elements with only M commands is meaningless, so filtering them out + return multiPathData.filter(pathData => pathData.includes("L")); +} + /** * Get raw data required for SVG rendering * @param {LSParams} lsParams - L-system parameters @@ -264,16 +321,24 @@ function getSVGData(lsParams) { } /** - * Get ready-to-render L-system’s SVG code + * Get raw data required for rendering of a multi-path SVG * @param {LSParams} lsParams - L-system parameters - * @param {SVGParams} svgParams - Output SVG parameters - * @return {String} + * @return {{multiPathData: String[], minX: Number, minY: Number, width: Number, height: Number}} */ -function getSVGCode(lsParams, svgParams) { - let {pathData, minX, minY, width, height} = getSVGData(lsParams); - let svgConfig = { - width: svgParams.width || width, - height: svgParams.height || height, +function getMultiPathSVGData(lsParams) { + let codeword = generate(lsParams); + let turtle = createTurtle({x: 0, y: 0, ...lsParams}); + let multiPathData = getMultiPathData(tokenizeCodeword(codeword), turtle); + return { + multiPathData, + ...turtle.getDrawingRect() + }; +} + +function makeSVGConfig(svgParams, naturalWidth, naturalHeight) { + return { + width: svgParams.width || naturalWidth, + height: svgParams.height || naturalHeight, padding: svgParams.padding || 0, pathAttributes: { // for backward compatibility with v1.1.0, also check fill and stroke as direct props of svgParams @@ -282,14 +347,62 @@ function getSVGCode(lsParams, svgParams) { ...svgParams.pathAttributes } }; - let {padding} = svgConfig; - let pathAttrStr = Object.entries(svgConfig.pathAttributes).reduce((accumulator, [name, value]) => { +} + +function makeSVGCode({viewBox, width, height, content}) { + return `${content}`; +} + +function makeAttrString(attrs, index) { + return Object.entries(attrs).reduce((accumulator, [name, value]) => { + if (Array.isArray(value)) { + value = value[Math.min(index, value.length - 1)]; + } + if (value === undefined) { + return accumulator; + } value = value.replace(/"/g, """); return `${accumulator} ${name}="${value}"`; }, ""); - return ` - -`; } -export { getSVGCode, getSVGData }; +/** + * Get ready-to-render L-system’s SVG code + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ +function getSVGCode(lsParams, svgParams) { + let {pathData, minX, minY, width: naturalWidth, height: naturalHeight} = getSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let pathAttrStr = makeAttrString(pathAttributes, 0); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content: `` + }); +} + +/** + * Get ready-to-render multi-path SVG code for an L-system + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ +function getMultiPathSVGCode(lsParams, svgParams) { + let {multiPathData, minX, minY, width: naturalWidth, height: naturalHeight} = getMultiPathSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let content = multiPathData.reduce((accumulator, pathData, index) => { + let pathAttrStr = makeAttrString(pathAttributes, index); + return `${accumulator}`; + }, ""); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content + }); +} + +export { getMultiPathSVGCode, getMultiPathSVGData, getSVGCode, getSVGData }; diff --git a/dist/lindsvg.esm.min.js b/dist/lindsvg.esm.min.js index 76f94ec..48ef344 100644 --- a/dist/lindsvg.esm.min.js +++ b/dist/lindsvg.esm.min.js @@ -1,6 +1,6 @@ /*! -lindsvg v1.2.1 +lindsvg v1.3.0 https://amphiluke.github.io/l-systems/ (c) 2020 Amphiluke */ -let t={AXIOM:"Axiom may only contain the following characters: A..Z,+,-,[,]",RULE:"Production rules may only contain the following characters: A..Z,+,-,[,]",LETTER:"Allowed alphabet letters are: A..Z",ALPHA:"The “alpha” parameter must be a finite number",THETA:"The “theta” parameter must be a finite number",STEP:"The “step” parameter must be a positive finite number",COUNT:"The number of iterations must be integer and finite",NUMBER:"A valid finite number expected"},e=/^[A-Z]$/;let a=/^[A-Z+\-[\]]*$/;function i(e,i=t.RULE){return a.test(e)||i}function r(a,r,n){let s=Object.create(null);return Object.entries(a).forEach(([a,h])=>{let l=function(a,i=t.LETTER){return e.test(a)||i}(a,r);!0===l&&(l=i(h,n)),!0!==l&&(s[a]=l)}),!Object.keys(s).length||s}function n(e){let a=Object.create(null);return Object.entries(e).forEach(([e,n])=>{let s=!0;switch(e){case"axiom":s=i(n,t.AXIOM);break;case"rules":s=r(n);break;case"alpha":case"theta":s=function(e,a=t.NUMBER){return Number.isFinite(e)||a}(n,t[e.toUpperCase()]);break;case"step":s=function(e,a=t.STEP){return Number.isFinite(e)&&e>0||a}(n);break;case"iterations":s=function(e,a=t.COUNT){return Number.isInteger(e)&&e>0||a}(n)}!0!==s&&(a[e]=s)}),!Object.keys(a).length||a}class s extends Error{constructor(t){let e=JSON.stringify(t,null,2);super(e),Object.defineProperty(this,"lsErrors",{value:JSON.parse(e)})}toJSON(){return JSON.parse(JSON.stringify(this.lsErrors))}}Object.defineProperty(s.prototype,"name",{configurable:!0,enumerable:!1,writable:!0,value:"LSError"});let h={F:"",B:"","+":"+","-":"-","[":"[","]":"]"},l={alpha:0,theta:0,step:10,iterations:3};let c={translate(t=1){this.x+=t*this.step*Math.cos(this.alpha),this.y+=t*this.step*Math.sin(this.alpha),this.minX=Math.min(this.minX,this.x),this.maxX=Math.max(this.maxX,this.x),this.minY=Math.min(this.minY,this.y),this.maxY=Math.max(this.maxY,this.y)},rotate(t){this.alpha+=t*this.theta},pushStack(t=1){for(;t>0;t--)this.stack.push({x:this.x,y:this.y,alpha:this.alpha})},popStack(t){for(;t>0;t--)({x:this.x,y:this.y,alpha:this.alpha}=this.stack.pop())},getDrawingRect(){let t=Math.floor(this.minX),e=Math.floor(this.minY),a=Math.ceil(this.maxX),i=Math.ceil(this.maxY);return{minX:t,minY:e,maxX:a,maxY:i,width:a-t,height:i-e}}};function o(t,e){return`${+t.toFixed(4)} ${+e.toFixed(4)}`}function u(t){let e=function(t){let e=n(t);if(!0!==e)throw new s(e);let{axiom:a,iterations:i}={...l,...t},r={...h,...t.rules};for(;i>0;i--)a=[...a].reduce((t,e)=>t+(r[e]||""),"");return a}(t),a=function({x:t,y:e,step:a,alpha:i,theta:r}){let n=Object.create(c);return n.stack=[],n.x=n.minX=n.maxX=t,n.y=n.minY=n.maxY=e,n.step=a,n.alpha=-i,n.theta=r,n}({x:0,y:0,...t});return{pathData:function(t,e){let a;return t.reduce((t,i)=>{let r=i.length;switch(i[0]){case"F":e.translate(r),t+=("L"===a?" ":"L")+o(e.x,e.y),a="L";break;case"B":e.translate(r),"M"===a&&(t=t.slice(0,t.lastIndexOf("M"))),t+="M"+o(e.x,e.y),a="M";break;case"+":e.rotate(r);break;case"-":e.rotate(-r);break;case"[":e.pushStack(r);break;case"]":e.popStack(r),t+=`M${o(e.x,e.y)}`,a="M"}return t},"M"+o(e.x,e.y))}(function(t){return t.match(/([FB[\]+-])\1*/g)}(e),a),...a.getDrawingRect()}}function p(t,e){let{pathData:a,minX:i,minY:r,width:n,height:s}=u(t),h={width:e.width||n,height:e.height||s,padding:e.padding||0,pathAttributes:{fill:e.fill||"none",stroke:e.stroke||"#000",...e.pathAttributes}},{padding:l}=h,c=Object.entries(h.pathAttributes).reduce((t,[e,a])=>`${t} ${e}="${a=a.replace(/"/g,""")}"`,"");return`\n \n`}export{p as getSVGCode,u as getSVGData}; +let t={AXIOM:"Axiom may only contain the following characters: A..Z,+,-,[,]",RULE:"Production rules may only contain the following characters: A..Z,+,-,[,]",LETTER:"Allowed alphabet letters are: A..Z",ALPHA:"The “alpha” parameter must be a finite number",THETA:"The “theta” parameter must be a finite number",STEP:"The “step” parameter must be a positive finite number",COUNT:"The number of iterations must be integer and finite",NUMBER:"A valid finite number expected"},e=/^[A-Z]$/;let a=/^[A-Z+\-[\]]*$/;function i(e,i=t.RULE){return a.test(e)||i}function r(a,r,n){let h=Object.create(null);return Object.entries(a).forEach(([a,s])=>{let c=function(a,i=t.LETTER){return e.test(a)||i}(a,r);!0===c&&(c=i(s,n)),!0!==c&&(h[a]=c)}),!Object.keys(h).length||h}function n(e){let a=Object.create(null);return Object.entries(e).forEach(([e,n])=>{let h=!0;switch(e){case"axiom":h=i(n,t.AXIOM);break;case"rules":h=r(n);break;case"alpha":case"theta":h=function(e,a=t.NUMBER){return Number.isFinite(e)||a}(n,t[e.toUpperCase()]);break;case"step":h=function(e,a=t.STEP){return Number.isFinite(e)&&e>0||a}(n);break;case"iterations":h=function(e,a=t.COUNT){return Number.isInteger(e)&&e>0||a}(n)}!0!==h&&(a[e]=h)}),!Object.keys(a).length||a}class h extends Error{constructor(t){let e=JSON.stringify(t,null,2);super(e),Object.defineProperty(this,"lsErrors",{value:JSON.parse(e)})}toJSON(){return JSON.parse(JSON.stringify(this.lsErrors))}}Object.defineProperty(h.prototype,"name",{configurable:!0,enumerable:!1,writable:!0,value:"LSError"});let s={F:"",B:"","+":"+","-":"-","[":"[","]":"]"},c={alpha:0,theta:0,step:10,iterations:3};function l(t){let e=n(t);if(!0!==e)throw new h(e);let{axiom:a,iterations:i}={...c,...t},r={...s,...t.rules};for(;i>0;i--)a=[...a].reduce((t,e)=>t+(r[e]||""),"");return a}let o={translate(t=1){this.x+=t*this.step*Math.cos(this.alpha),this.y+=t*this.step*Math.sin(this.alpha),this.minX=Math.min(this.minX,this.x),this.maxX=Math.max(this.maxX,this.x),this.minY=Math.min(this.minY,this.y),this.maxY=Math.max(this.maxY,this.y)},rotate(t){this.alpha+=t*this.theta},pushStack(t=1){for(;t>0;t--)this.stack.push({x:this.x,y:this.y,alpha:this.alpha})},popStack(t){for(;t>0;t--)({x:this.x,y:this.y,alpha:this.alpha}=this.stack.pop())},getDrawingRect(){let t=Math.floor(this.minX),e=Math.floor(this.minY),a=Math.ceil(this.maxX),i=Math.ceil(this.maxY);return{minX:t,minY:e,maxX:a,maxY:i,width:a-t,height:i-e}}};function u({x:t,y:e,step:a,alpha:i,theta:r}){let n=Object.create(o);return n.stack=[],n.x=n.minX=n.maxX=t,n.y=n.minY=n.maxY=e,n.step=a,n.alpha=-i,n.theta=r,n}function p(t){return t.replace(/[^FB[\]+-]/g,"").match(/([FB[\]+-])\1*/g)}function m(t,e){return`${+t.toFixed(4)} ${+e.toFixed(4)}`}function x(t){let e=l(t),a=u({x:0,y:0,...t});return{pathData:function(t,e){let a;return t.reduce((t,i)=>{let r=i.length;switch(i[0]){case"F":e.translate(r),t+=("L"===a?" ":"L")+m(e.x,e.y),a="L";break;case"B":e.translate(r),"M"===a&&(t=t.slice(0,t.lastIndexOf("M"))),t+="M"+m(e.x,e.y),a="M";break;case"+":e.rotate(r);break;case"-":e.rotate(-r);break;case"[":e.pushStack(r);break;case"]":e.popStack(r),t+=`M${m(e.x,e.y)}`,a="M"}return t},"M"+m(e.x,e.y))}(p(e),a),...a.getDrawingRect()}}function f(t){let e=l(t),a=u({x:0,y:0,...t});return{multiPathData:function(t,e){let a,i=0;return t.reduce((t,r)=>{let n=t[i]||"",h=r.length;switch(r[0]){case"F":e.translate(h),n+=("L"===a?" ":"L")+m(e.x,e.y),a="L";break;case"B":e.translate(h),"M"===a&&(n=n.slice(0,n.lastIndexOf("M"))),n+="M"+m(e.x,e.y),a="M";break;case"+":e.rotate(h);break;case"-":e.rotate(-h);break;case"[":i+=h,e.pushStack(h),n=`${t[i]||""}M${m(e.x,e.y)}`,a="M";break;case"]":i-=h,e.popStack(h),n=`${t[i]||""}M${m(e.x,e.y)}`,a="M"}return t[i]=n,t},["M"+m(e.x,e.y)]).filter(t=>t.includes("L"))}(p(e),a),...a.getDrawingRect()}}function b(t,e,a){return{width:t.width||e,height:t.height||a,padding:t.padding||0,pathAttributes:{fill:t.fill||"none",stroke:t.stroke||"#000",...t.pathAttributes}}}function d({viewBox:t,width:e,height:a,content:i}){return`${i}`}function g(t,e){return Object.entries(t).reduce((t,[a,i])=>(Array.isArray(i)&&(i=i[Math.min(e,i.length-1)]),void 0===i?t:`${t} ${a}="${i=i.replace(/"/g,""")}"`),"")}function y(t,e){let{pathData:a,minX:i,minY:r,width:n,height:h}=x(t),{padding:s,width:c,height:l,pathAttributes:o}=b(e,n,h);return d({viewBox:[i-s,r-s,n+2*s,h+2*s],width:c,height:l,content:``})}function w(t,e){let{multiPathData:a,minX:i,minY:r,width:n,height:h}=f(t),{padding:s,width:c,height:l,pathAttributes:o}=b(e,n,h);return d({viewBox:[i-s,r-s,n+2*s,h+2*s],width:c,height:l,content:a.reduce((t,e,a)=>`${t}`,"")})}export{w as getMultiPathSVGCode,f as getMultiPathSVGData,y as getSVGCode,x as getSVGData}; diff --git a/dist/lindsvg.js b/dist/lindsvg.js index 141be53..94e82f0 100644 --- a/dist/lindsvg.js +++ b/dist/lindsvg.js @@ -1,5 +1,5 @@ /*! -lindsvg v1.2.1 +lindsvg v1.3.0 https://amphiluke.github.io/l-systems/ (c) 2020 Amphiluke */ @@ -197,10 +197,10 @@ https://amphiluke.github.io/l-systems/ * Remove all letters which don’t affect the drawing process from the codeword * and split it into “tokens” for the further processing * @param {String} codeword - L-system code - * @return {Array} + * @return {String[]} */ function tokenizeCodeword(codeword) { - return codeword.match(/([FB[\]+-])\1*/g); + return codeword.replace(/[^FB[\]+-]/g, "").match(/([FB[\]+-])\1*/g); } function formatCoordinates(x, y) { @@ -210,7 +210,7 @@ https://amphiluke.github.io/l-systems/ /** * Get the value of the d attribute - * @param {Array} tokens - Tokenized codeword + * @param {String[]} tokens - Tokenized codeword * @param {Object} turtle - Turtle object to work with * @return {String} */ @@ -254,6 +254,63 @@ https://amphiluke.github.io/l-systems/ }, "M" + formatCoordinates(turtle.x, turtle.y)); } + /** + * Get the values of the d attribute for each path element + * @param {String[]} tokens - Tokenized codeword + * @param {Object} turtle - Turtle object to work with + * @return {String[]} + */ + function getMultiPathData(tokens, turtle) { + let prevCommand; // used to avoid unnecessary repeating of the commands L and M + let branchLevel = 0; + let multiPathData = tokens.reduce((accumulator, token) => { + let pathData = accumulator[branchLevel] || ""; + let tokenLength = token.length; + switch (token[0]) { + case "F": + turtle.translate(tokenLength); + pathData += (prevCommand === "L" ? " " : "L") + formatCoordinates(turtle.x, turtle.y); + prevCommand = "L"; + break; + case "B": + turtle.translate(tokenLength); + if (prevCommand === "M") { + // As the spec states, “If a moveto is followed by multiple pairs of coordinates, + // the subsequent pairs are treated as implicit lineto commands”. + // This is not what we want, so delete the preceding moveto command + pathData = pathData.slice(0, pathData.lastIndexOf("M")); + } + pathData += "M" + formatCoordinates(turtle.x, turtle.y); + prevCommand = "M"; + break; + case "+": + turtle.rotate(tokenLength); + break; + case "-": + turtle.rotate(-tokenLength); + break; + case "[": + branchLevel += tokenLength; + turtle.pushStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + case "]": + branchLevel -= tokenLength; + turtle.popStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + } + accumulator[branchLevel] = pathData; + return accumulator; + }, ["M" + formatCoordinates(turtle.x, turtle.y)]); + // Some L-systems can produce branching levels which contain no real draw commands (only moves and rotations). + // Such L-systems usually don’t have F commands in their axiom nor they have a production for F (example is + // the Penrose tiling). Having elements with only M commands is meaningless, so filtering them out + return multiPathData.filter(pathData => pathData.includes("L")); + } + /** * Get raw data required for SVG rendering * @param {LSParams} lsParams - L-system parameters @@ -270,16 +327,24 @@ https://amphiluke.github.io/l-systems/ } /** - * Get ready-to-render L-system’s SVG code + * Get raw data required for rendering of a multi-path SVG * @param {LSParams} lsParams - L-system parameters - * @param {SVGParams} svgParams - Output SVG parameters - * @return {String} + * @return {{multiPathData: String[], minX: Number, minY: Number, width: Number, height: Number}} */ - function getSVGCode(lsParams, svgParams) { - let {pathData, minX, minY, width, height} = getSVGData(lsParams); - let svgConfig = { - width: svgParams.width || width, - height: svgParams.height || height, + function getMultiPathSVGData(lsParams) { + let codeword = generate(lsParams); + let turtle = createTurtle({x: 0, y: 0, ...lsParams}); + let multiPathData = getMultiPathData(tokenizeCodeword(codeword), turtle); + return { + multiPathData, + ...turtle.getDrawingRect() + }; + } + + function makeSVGConfig(svgParams, naturalWidth, naturalHeight) { + return { + width: svgParams.width || naturalWidth, + height: svgParams.height || naturalHeight, padding: svgParams.padding || 0, pathAttributes: { // for backward compatibility with v1.1.0, also check fill and stroke as direct props of svgParams @@ -288,16 +353,66 @@ https://amphiluke.github.io/l-systems/ ...svgParams.pathAttributes } }; - let {padding} = svgConfig; - let pathAttrStr = Object.entries(svgConfig.pathAttributes).reduce((accumulator, [name, value]) => { + } + + function makeSVGCode({viewBox, width, height, content}) { + return `${content}`; + } + + function makeAttrString(attrs, index) { + return Object.entries(attrs).reduce((accumulator, [name, value]) => { + if (Array.isArray(value)) { + value = value[Math.min(index, value.length - 1)]; + } + if (value === undefined) { + return accumulator; + } value = value.replace(/"/g, """); return `${accumulator} ${name}="${value}"`; }, ""); - return ` - -`; } + /** + * Get ready-to-render L-system’s SVG code + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ + function getSVGCode(lsParams, svgParams) { + let {pathData, minX, minY, width: naturalWidth, height: naturalHeight} = getSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let pathAttrStr = makeAttrString(pathAttributes, 0); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content: `` + }); + } + + /** + * Get ready-to-render multi-path SVG code for an L-system + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ + function getMultiPathSVGCode(lsParams, svgParams) { + let {multiPathData, minX, minY, width: naturalWidth, height: naturalHeight} = getMultiPathSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let content = multiPathData.reduce((accumulator, pathData, index) => { + let pathAttrStr = makeAttrString(pathAttributes, index); + return `${accumulator}`; + }, ""); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content + }); + } + + exports.getMultiPathSVGCode = getMultiPathSVGCode; + exports.getMultiPathSVGData = getMultiPathSVGData; exports.getSVGCode = getSVGCode; exports.getSVGData = getSVGData; diff --git a/dist/lindsvg.min.js b/dist/lindsvg.min.js index 86ba2e2..5d00846 100644 --- a/dist/lindsvg.min.js +++ b/dist/lindsvg.min.js @@ -1,6 +1,6 @@ /*! -lindsvg v1.2.1 +lindsvg v1.3.0 https://amphiluke.github.io/l-systems/ (c) 2020 Amphiluke */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).lindsvg={})}(this,(function(t){"use strict";let e={AXIOM:"Axiom may only contain the following characters: A..Z,+,-,[,]",RULE:"Production rules may only contain the following characters: A..Z,+,-,[,]",LETTER:"Allowed alphabet letters are: A..Z",ALPHA:"The “alpha” parameter must be a finite number",THETA:"The “theta” parameter must be a finite number",STEP:"The “step” parameter must be a positive finite number",COUNT:"The number of iterations must be integer and finite",NUMBER:"A valid finite number expected"},a=/^[A-Z]$/;let i=/^[A-Z+\-[\]]*$/;function r(t,a=e.RULE){return i.test(t)||a}function n(t,i,n){let s=Object.create(null);return Object.entries(t).forEach(([t,h])=>{let o=function(t,i=e.LETTER){return a.test(t)||i}(t,i);!0===o&&(o=r(h,n)),!0!==o&&(s[t]=o)}),!Object.keys(s).length||s}function s(t){let a=Object.create(null);return Object.entries(t).forEach(([t,i])=>{let s=!0;switch(t){case"axiom":s=r(i,e.AXIOM);break;case"rules":s=n(i);break;case"alpha":case"theta":s=function(t,a=e.NUMBER){return Number.isFinite(t)||a}(i,e[t.toUpperCase()]);break;case"step":s=function(t,a=e.STEP){return Number.isFinite(t)&&t>0||a}(i);break;case"iterations":s=function(t,a=e.COUNT){return Number.isInteger(t)&&t>0||a}(i)}!0!==s&&(a[t]=s)}),!Object.keys(a).length||a}class h extends Error{constructor(t){let e=JSON.stringify(t,null,2);super(e),Object.defineProperty(this,"lsErrors",{value:JSON.parse(e)})}toJSON(){return JSON.parse(JSON.stringify(this.lsErrors))}}Object.defineProperty(h.prototype,"name",{configurable:!0,enumerable:!1,writable:!0,value:"LSError"});let o={F:"",B:"","+":"+","-":"-","[":"[","]":"]"},l={alpha:0,theta:0,step:10,iterations:3};let c={translate(t=1){this.x+=t*this.step*Math.cos(this.alpha),this.y+=t*this.step*Math.sin(this.alpha),this.minX=Math.min(this.minX,this.x),this.maxX=Math.max(this.maxX,this.x),this.minY=Math.min(this.minY,this.y),this.maxY=Math.max(this.maxY,this.y)},rotate(t){this.alpha+=t*this.theta},pushStack(t=1){for(;t>0;t--)this.stack.push({x:this.x,y:this.y,alpha:this.alpha})},popStack(t){for(;t>0;t--)({x:this.x,y:this.y,alpha:this.alpha}=this.stack.pop())},getDrawingRect(){let t=Math.floor(this.minX),e=Math.floor(this.minY),a=Math.ceil(this.maxX),i=Math.ceil(this.maxY);return{minX:t,minY:e,maxX:a,maxY:i,width:a-t,height:i-e}}};function u(t,e){return`${+t.toFixed(4)} ${+e.toFixed(4)}`}function p(t){let e=function(t){let e=s(t);if(!0!==e)throw new h(e);let{axiom:a,iterations:i}={...l,...t},r={...o,...t.rules};for(;i>0;i--)a=[...a].reduce((t,e)=>t+(r[e]||""),"");return a}(t),a=function({x:t,y:e,step:a,alpha:i,theta:r}){let n=Object.create(c);return n.stack=[],n.x=n.minX=n.maxX=t,n.y=n.minY=n.maxY=e,n.step=a,n.alpha=-i,n.theta=r,n}({x:0,y:0,...t});return{pathData:function(t,e){let a;return t.reduce((t,i)=>{let r=i.length;switch(i[0]){case"F":e.translate(r),t+=("L"===a?" ":"L")+u(e.x,e.y),a="L";break;case"B":e.translate(r),"M"===a&&(t=t.slice(0,t.lastIndexOf("M"))),t+="M"+u(e.x,e.y),a="M";break;case"+":e.rotate(r);break;case"-":e.rotate(-r);break;case"[":e.pushStack(r);break;case"]":e.popStack(r),t+=`M${u(e.x,e.y)}`,a="M"}return t},"M"+u(e.x,e.y))}(function(t){return t.match(/([FB[\]+-])\1*/g)}(e),a),...a.getDrawingRect()}}t.getSVGCode=function(t,e){let{pathData:a,minX:i,minY:r,width:n,height:s}=p(t),h={width:e.width||n,height:e.height||s,padding:e.padding||0,pathAttributes:{fill:e.fill||"none",stroke:e.stroke||"#000",...e.pathAttributes}},{padding:o}=h,l=Object.entries(h.pathAttributes).reduce((t,[e,a])=>`${t} ${e}="${a=a.replace(/"/g,""")}"`,"");return`\n \n`},t.getSVGData=p,Object.defineProperty(t,"__esModule",{value:!0})})); +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).lindsvg={})}(this,(function(t){"use strict";let e={AXIOM:"Axiom may only contain the following characters: A..Z,+,-,[,]",RULE:"Production rules may only contain the following characters: A..Z,+,-,[,]",LETTER:"Allowed alphabet letters are: A..Z",ALPHA:"The “alpha” parameter must be a finite number",THETA:"The “theta” parameter must be a finite number",STEP:"The “step” parameter must be a positive finite number",COUNT:"The number of iterations must be integer and finite",NUMBER:"A valid finite number expected"},a=/^[A-Z]$/;let i=/^[A-Z+\-[\]]*$/;function r(t,a=e.RULE){return i.test(t)||a}function n(t,i,n){let s=Object.create(null);return Object.entries(t).forEach(([t,h])=>{let o=function(t,i=e.LETTER){return a.test(t)||i}(t,i);!0===o&&(o=r(h,n)),!0!==o&&(s[t]=o)}),!Object.keys(s).length||s}function s(t){let a=Object.create(null);return Object.entries(t).forEach(([t,i])=>{let s=!0;switch(t){case"axiom":s=r(i,e.AXIOM);break;case"rules":s=n(i);break;case"alpha":case"theta":s=function(t,a=e.NUMBER){return Number.isFinite(t)||a}(i,e[t.toUpperCase()]);break;case"step":s=function(t,a=e.STEP){return Number.isFinite(t)&&t>0||a}(i);break;case"iterations":s=function(t,a=e.COUNT){return Number.isInteger(t)&&t>0||a}(i)}!0!==s&&(a[t]=s)}),!Object.keys(a).length||a}class h extends Error{constructor(t){let e=JSON.stringify(t,null,2);super(e),Object.defineProperty(this,"lsErrors",{value:JSON.parse(e)})}toJSON(){return JSON.parse(JSON.stringify(this.lsErrors))}}Object.defineProperty(h.prototype,"name",{configurable:!0,enumerable:!1,writable:!0,value:"LSError"});let o={F:"",B:"","+":"+","-":"-","[":"[","]":"]"},c={alpha:0,theta:0,step:10,iterations:3};function l(t){let e=s(t);if(!0!==e)throw new h(e);let{axiom:a,iterations:i}={...c,...t},r={...o,...t.rules};for(;i>0;i--)a=[...a].reduce((t,e)=>t+(r[e]||""),"");return a}let u={translate(t=1){this.x+=t*this.step*Math.cos(this.alpha),this.y+=t*this.step*Math.sin(this.alpha),this.minX=Math.min(this.minX,this.x),this.maxX=Math.max(this.maxX,this.x),this.minY=Math.min(this.minY,this.y),this.maxY=Math.max(this.maxY,this.y)},rotate(t){this.alpha+=t*this.theta},pushStack(t=1){for(;t>0;t--)this.stack.push({x:this.x,y:this.y,alpha:this.alpha})},popStack(t){for(;t>0;t--)({x:this.x,y:this.y,alpha:this.alpha}=this.stack.pop())},getDrawingRect(){let t=Math.floor(this.minX),e=Math.floor(this.minY),a=Math.ceil(this.maxX),i=Math.ceil(this.maxY);return{minX:t,minY:e,maxX:a,maxY:i,width:a-t,height:i-e}}};function p({x:t,y:e,step:a,alpha:i,theta:r}){let n=Object.create(u);return n.stack=[],n.x=n.minX=n.maxX=t,n.y=n.minY=n.maxY=e,n.step=a,n.alpha=-i,n.theta=r,n}function f(t){return t.replace(/[^FB[\]+-]/g,"").match(/([FB[\]+-])\1*/g)}function m(t,e){return`${+t.toFixed(4)} ${+e.toFixed(4)}`}function d(t){let e=l(t),a=p({x:0,y:0,...t});return{pathData:function(t,e){let a;return t.reduce((t,i)=>{let r=i.length;switch(i[0]){case"F":e.translate(r),t+=("L"===a?" ":"L")+m(e.x,e.y),a="L";break;case"B":e.translate(r),"M"===a&&(t=t.slice(0,t.lastIndexOf("M"))),t+="M"+m(e.x,e.y),a="M";break;case"+":e.rotate(r);break;case"-":e.rotate(-r);break;case"[":e.pushStack(r);break;case"]":e.popStack(r),t+=`M${m(e.x,e.y)}`,a="M"}return t},"M"+m(e.x,e.y))}(f(e),a),...a.getDrawingRect()}}function x(t){let e=l(t),a=p({x:0,y:0,...t});return{multiPathData:function(t,e){let a,i=0;return t.reduce((t,r)=>{let n=t[i]||"",s=r.length;switch(r[0]){case"F":e.translate(s),n+=("L"===a?" ":"L")+m(e.x,e.y),a="L";break;case"B":e.translate(s),"M"===a&&(n=n.slice(0,n.lastIndexOf("M"))),n+="M"+m(e.x,e.y),a="M";break;case"+":e.rotate(s);break;case"-":e.rotate(-s);break;case"[":i+=s,e.pushStack(s),n=`${t[i]||""}M${m(e.x,e.y)}`,a="M";break;case"]":i-=s,e.popStack(s),n=`${t[i]||""}M${m(e.x,e.y)}`,a="M"}return t[i]=n,t},["M"+m(e.x,e.y)]).filter(t=>t.includes("L"))}(f(e),a),...a.getDrawingRect()}}function b(t,e,a){return{width:t.width||e,height:t.height||a,padding:t.padding||0,pathAttributes:{fill:t.fill||"none",stroke:t.stroke||"#000",...t.pathAttributes}}}function g({viewBox:t,width:e,height:a,content:i}){return`${i}`}function y(t,e){return Object.entries(t).reduce((t,[a,i])=>(Array.isArray(i)&&(i=i[Math.min(e,i.length-1)]),void 0===i?t:`${t} ${a}="${i=i.replace(/"/g,""")}"`),"")}t.getMultiPathSVGCode=function(t,e){let{multiPathData:a,minX:i,minY:r,width:n,height:s}=x(t),{padding:h,width:o,height:c,pathAttributes:l}=b(e,n,s);return g({viewBox:[i-h,r-h,n+2*h,s+2*h],width:o,height:c,content:a.reduce((t,e,a)=>`${t}`,"")})},t.getMultiPathSVGData=x,t.getSVGCode=function(t,e){let{pathData:a,minX:i,minY:r,width:n,height:s}=d(t),{padding:h,width:o,height:c,pathAttributes:l}=b(e,n,s);return g({viewBox:[i-h,r-h,n+2*h,s+2*h],width:o,height:c,content:``})},t.getSVGData=d,Object.defineProperty(t,"__esModule",{value:!0})})); diff --git a/package-lock.json b/package-lock.json index fdde8ba..878a5d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "lindsvg", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -24,18 +24,6 @@ "js-tokens": "^4.0.0" } }, - "@types/estree": { - "version": "0.0.42", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.42.tgz", - "integrity": "sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==", - "dev": true - }, - "@types/node": { - "version": "13.7.7", - "resolved": "https://registry.npmjs.org/@types/node/-/node-13.7.7.tgz", - "integrity": "sha512-Uo4chgKbnPNlxQwoFmYIwctkQVkMMmsAoGGU4JKwLuvBefF0pCq4FybNSnfkfRCpC7ZW7kttcC/TrRtAJsvGtg==", - "dev": true - }, "acorn": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.0.tgz", @@ -436,6 +424,13 @@ "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true }, + "fsevents": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.2.tgz", + "integrity": "sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA==", + "dev": true, + "optional": true + }, "functional-red-black-tree": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", @@ -815,20 +810,18 @@ } }, "rollup": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-1.32.0.tgz", - "integrity": "sha512-ab2tF5pdDqm2zuI8j02ceyrJSScl9V2C24FgWQ1v1kTFTu1UrG5H0hpP++mDZlEFyZX4k0chtGEHU2i+pAzBgA==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.0.6.tgz", + "integrity": "sha512-P42IlI6a/bxh52ed8hEXXe44LcHfep2f26OZybMJPN1TTQftibvQEl3CWeOmJrzqGbFxOA000QXDWO9WJaOQpA==", "dev": true, "requires": { - "@types/estree": "*", - "@types/node": "*", - "acorn": "^7.1.0" + "fsevents": "~2.1.2" } }, "rollup-plugin-terser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-5.2.0.tgz", - "integrity": "sha512-jQI+nYhtDBc9HFRBz8iGttQg7li9klmzR62RG2W2nN6hJ/FI2K2ItYQ7kJ7/zn+vs+BP1AEccmVRjRN989I+Nw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-5.3.0.tgz", + "integrity": "sha512-XGMJihTIO3eIBsVGq7jiNYOdDMb3pVxuzY0uhOE/FM4x/u9nQgr3+McsjzqBn3QfHIpNSZmFnpoKAwHBEcsT7g==", "dev": true, "requires": { "@babel/code-frame": "^7.5.5", @@ -1037,9 +1030,9 @@ } }, "terser": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.2.tgz", - "integrity": "sha512-6FUjJdY2i3WZAtYBtnV06OOcOfzl+4hSKYE9wgac8rkLRBToPDDrBB2AcHwQD/OKDxbnvhVy2YgOPWO2SsKWqg==", + "version": "4.6.6", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.6.6.tgz", + "integrity": "sha512-4lYPyeNmstjIIESr/ysHg2vUPRGf2tzF9z2yYwnowXVuVzLEamPN1Gfrz7f8I9uEPuHcbFlW4PLIAsJoxXyJ1g==", "dev": true, "requires": { "commander": "^2.20.0", diff --git a/package.json b/package.json index 8cfe39d..cfb59e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lindsvg", - "version": "1.2.1", + "version": "1.3.0", "description": "Lindenmayer System [Scalable] Vector Graphics", "main": "dist/lindsvg.js", "module": "dist/lindsvg.esm.js", @@ -11,6 +11,7 @@ ], "scripts": { "lint": "eslint src/**/*.mjs", + "test": "node test/node-esm-test.mjs && echo \"Check *.svg files located in the test/svg directory\"", "build": "rollup -c" }, "repository": { @@ -29,8 +30,8 @@ "homepage": "https://amphiluke.github.io/l-systems/", "devDependencies": { "eslint": "^6.8.0", - "rollup": "^1.32.0", - "rollup-plugin-terser": "^5.2.0" + "rollup": "^2.0.6", + "rollup-plugin-terser": "^5.3.0" }, "engines": { "node": ">=8.3.0" diff --git a/src/lindsvg.mjs b/src/lindsvg.mjs index b3e5d79..8624896 100644 --- a/src/lindsvg.mjs +++ b/src/lindsvg.mjs @@ -20,8 +20,8 @@ * @property {Number} [width] - Desired SVG width * @property {Number} [height] - Desired SVG height * @property {Number} [padding=0] - Additional space to extend the viewBox - * @property {Object} [pathAttributes={fill:"none",stroke:"#000"}] - Name to value map for the “path” element attributes + * @property {Object.} [pathAttributes={fill:"none",stroke:"#000"}] - Name to value map for the element attributes */ -export {getSVGData, getSVGCode} from "./svg.mjs"; +export * from "./svg.mjs"; diff --git a/src/svg.mjs b/src/svg.mjs index 0042296..6dc44aa 100644 --- a/src/svg.mjs +++ b/src/svg.mjs @@ -5,10 +5,10 @@ import {createTurtle} from "./turtle.mjs"; * Remove all letters which don’t affect the drawing process from the codeword * and split it into “tokens” for the further processing * @param {String} codeword - L-system code - * @return {Array} + * @return {String[]} */ function tokenizeCodeword(codeword) { - return codeword.match(/([FB[\]+-])\1*/g); + return codeword.replace(/[^FB[\]+-]/g, "").match(/([FB[\]+-])\1*/g); } function formatCoordinates(x, y) { @@ -18,7 +18,7 @@ function formatCoordinates(x, y) { /** * Get the value of the d attribute - * @param {Array} tokens - Tokenized codeword + * @param {String[]} tokens - Tokenized codeword * @param {Object} turtle - Turtle object to work with * @return {String} */ @@ -62,6 +62,63 @@ function getPathData(tokens, turtle) { }, "M" + formatCoordinates(turtle.x, turtle.y)); } +/** + * Get the values of the d attribute for each path element + * @param {String[]} tokens - Tokenized codeword + * @param {Object} turtle - Turtle object to work with + * @return {String[]} + */ +function getMultiPathData(tokens, turtle) { + let prevCommand; // used to avoid unnecessary repeating of the commands L and M + let branchLevel = 0; + let multiPathData = tokens.reduce((accumulator, token) => { + let pathData = accumulator[branchLevel] || ""; + let tokenLength = token.length; + switch (token[0]) { + case "F": + turtle.translate(tokenLength); + pathData += (prevCommand === "L" ? " " : "L") + formatCoordinates(turtle.x, turtle.y); + prevCommand = "L"; + break; + case "B": + turtle.translate(tokenLength); + if (prevCommand === "M") { + // As the spec states, “If a moveto is followed by multiple pairs of coordinates, + // the subsequent pairs are treated as implicit lineto commands”. + // This is not what we want, so delete the preceding moveto command + pathData = pathData.slice(0, pathData.lastIndexOf("M")); + } + pathData += "M" + formatCoordinates(turtle.x, turtle.y); + prevCommand = "M"; + break; + case "+": + turtle.rotate(tokenLength); + break; + case "-": + turtle.rotate(-tokenLength); + break; + case "[": + branchLevel += tokenLength; + turtle.pushStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + case "]": + branchLevel -= tokenLength; + turtle.popStack(tokenLength); + pathData = `${accumulator[branchLevel] || ""}M${formatCoordinates(turtle.x, turtle.y)}`; + prevCommand = "M"; + break; + } + accumulator[branchLevel] = pathData; + return accumulator; + }, ["M" + formatCoordinates(turtle.x, turtle.y)]); + // Some L-systems can produce branching levels which contain no real draw commands (only moves and rotations). + // Such L-systems usually don’t have F commands in their axiom nor they have a production for F (example is + // the Penrose tiling). Having elements with only M commands is meaningless, so filtering them out + return multiPathData.filter(pathData => pathData.includes("L")); +} + /** * Get raw data required for SVG rendering * @param {LSParams} lsParams - L-system parameters @@ -78,16 +135,24 @@ export function getSVGData(lsParams) { } /** - * Get ready-to-render L-system’s SVG code + * Get raw data required for rendering of a multi-path SVG * @param {LSParams} lsParams - L-system parameters - * @param {SVGParams} svgParams - Output SVG parameters - * @return {String} + * @return {{multiPathData: String[], minX: Number, minY: Number, width: Number, height: Number}} */ -export function getSVGCode(lsParams, svgParams) { - let {pathData, minX, minY, width, height} = getSVGData(lsParams); - let svgConfig = { - width: svgParams.width || width, - height: svgParams.height || height, +export function getMultiPathSVGData(lsParams) { + let codeword = generate(lsParams); + let turtle = createTurtle({x: 0, y: 0, ...lsParams}); + let multiPathData = getMultiPathData(tokenizeCodeword(codeword), turtle); + return { + multiPathData, + ...turtle.getDrawingRect() + }; +} + +function makeSVGConfig(svgParams, naturalWidth, naturalHeight) { + return { + width: svgParams.width || naturalWidth, + height: svgParams.height || naturalHeight, padding: svgParams.padding || 0, pathAttributes: { // for backward compatibility with v1.1.0, also check fill and stroke as direct props of svgParams @@ -96,12 +161,60 @@ export function getSVGCode(lsParams, svgParams) { ...svgParams.pathAttributes } }; - let {padding} = svgConfig; - let pathAttrStr = Object.entries(svgConfig.pathAttributes).reduce((accumulator, [name, value]) => { +} + +function makeSVGCode({viewBox, width, height, content}) { + return `${content}`; +} + +function makeAttrString(attrs, index) { + return Object.entries(attrs).reduce((accumulator, [name, value]) => { + if (Array.isArray(value)) { + value = value[Math.min(index, value.length - 1)]; + } + if (value === undefined) { + return accumulator; + } value = value.replace(/"/g, """); return `${accumulator} ${name}="${value}"`; }, ""); - return ` - -`; +} + +/** + * Get ready-to-render L-system’s SVG code + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ +export function getSVGCode(lsParams, svgParams) { + let {pathData, minX, minY, width: naturalWidth, height: naturalHeight} = getSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let pathAttrStr = makeAttrString(pathAttributes, 0); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content: `` + }); +} + +/** + * Get ready-to-render multi-path SVG code for an L-system + * @param {LSParams} lsParams - L-system parameters + * @param {SVGParams} svgParams - Output SVG parameters + * @return {String} + */ +export function getMultiPathSVGCode(lsParams, svgParams) { + let {multiPathData, minX, minY, width: naturalWidth, height: naturalHeight} = getMultiPathSVGData(lsParams); + let {padding, width, height, pathAttributes} = makeSVGConfig(svgParams, naturalWidth, naturalHeight); + let content = multiPathData.reduce((accumulator, pathData, index) => { + let pathAttrStr = makeAttrString(pathAttributes, index); + return `${accumulator}`; + }, ""); + return makeSVGCode({ + viewBox: [minX - padding, minY - padding, naturalWidth + 2 * padding, naturalHeight + 2 * padding], + width, + height, + content + }); } diff --git a/test/browser-esm-test.html b/test/browser-esm-test.html index 20d3908..2896bed 100644 --- a/test/browser-esm-test.html +++ b/test/browser-esm-test.html @@ -9,22 +9,26 @@ position: fixed; left: 0; top: 0; - width: 100vw; + width: 50vw; height: 100vh; } + svg + svg { + left: 50vw; + }