├── .gitignore ├── LICENSE ├── README.md ├── css └── main.css ├── js ├── generator.js ├── main.js └── view.js └── map.html /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | *.iml 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rob Dawson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generative Pattern Maker 2 | 3 | Here's a web-based [generative pattern maker](https://codebox.net/pages/generative-patterns) that produces images like these: 4 | 5 | Generative Pattern 1 Generative Pattern 2 Generative Pattern 3
6 | Generative Pattern 4 Generative Pattern 5 Generative Pattern 6
7 | Generative Pattern 7 Generative Pattern 8 Generative Pattern 9
8 | 9 | You can [use the online version of the tool to make your own patterns](https://codebox.net/html_raw/generative-patterns/index.html). It also works offline, so you can just download the code and open the HTML file in a web browser. 10 | 11 | Click the 'START' button to begin a new pattern. You can pause at any point, or just let the pattern run to completion. Running the 'PENCIL' tool produces a nice shading effect (shown in the images above). If you produce something beautiful you can save a copy using the 'DOWNLOAD' button. 12 | 13 | If you select the 'Continuous' option then each time a pattern is completed a new one is started automatically. 14 | 15 | Each pattern is based on a 'seed' number that can be used to reproduce the same pattern again. The three most recent seeds are shown at the bottom of the page. Clicking a seed number will generate the corresponding pattern again. 16 | 17 | -------------------------------------------------------------------------------- /css/main.css: -------------------------------------------------------------------------------- 1 | html,body { 2 | width: 100%; 3 | height: 100%; 4 | margin: 0; 5 | } 6 | #container { 7 | display: flex; 8 | flex-direction: column; 9 | width: 100%; 10 | height: 100% 11 | } 12 | #canvas { 13 | flex: 1 1 0; 14 | border: 1px solid black; 15 | min-height: 0; 16 | } 17 | #canvas, #controls { 18 | margin: 5px; 19 | border: 1px solid black; 20 | } 21 | #controls { 22 | margin-top: -6px; 23 | padding: 10px; 24 | display: flex; 25 | flex-direction: row; 26 | align-items: center; 27 | } 28 | #controls button { 29 | border: 1px solid black; 30 | padding: 5px 10px; 31 | margin: 0 5px; 32 | cursor: pointer; 33 | border-radius: 3px; 34 | text-transform: uppercase; 35 | width: 100px; 36 | background-color: transparent; 37 | } 38 | #controls button:disabled { 39 | border: 1px solid grey; 40 | cursor: default; 41 | } 42 | .checkboxContainer, #seedList { 43 | display: flex; 44 | flex-direction: row; 45 | margin: 0 0 0 10px; 46 | } 47 | #seedList { 48 | margin: 0 0 0 20px; 49 | } 50 | #recentSeeds { 51 | margin: 0 10px; 52 | padding: 0; 53 | } 54 | #recentSeeds li { 55 | display: inline-block; 56 | margin-right: 5px; 57 | cursor: pointer; 58 | } 59 | #recentSeeds li:hover { 60 | text-decoration: underline; 61 | } 62 | #recentSeeds li:first-child { 63 | font-weight: bold; 64 | } 65 | #checkboxes { 66 | display: flex; 67 | flex-direction: row; 68 | } 69 | @media screen and (max-width:480px) { 70 | #controls { 71 | flex-direction: column; 72 | padding: 0px; 73 | } 74 | #controls button { 75 | width: 100%; 76 | border-radius: 0; 77 | padding:10px; 78 | } 79 | .checkboxContainer, #seedList { 80 | margin: 5px; 81 | } 82 | #checkboxes { 83 | margin: 5px; 84 | } 85 | #seedList { 86 | justify-content: center; 87 | width: 100%; 88 | align-items: center; 89 | margin: -5px 0 10px 0; 90 | } 91 | } -------------------------------------------------------------------------------- /js/generator.js: -------------------------------------------------------------------------------- 1 | const generator = (() => { 2 | "use strict"; 3 | 4 | function buildModel(config, rnd, collisionDetector) { 5 | let activeLineCount = 0, 6 | seeds; 7 | 8 | function buildLine(p0, angle, parent) { 9 | const growthRate = 1; 10 | const line = { 11 | p0, 12 | p1: {...p0}, 13 | angle, 14 | generation: parent ? parent.generation + 1 : 0, 15 | parent, 16 | active: true, 17 | split: false, 18 | expired: false, 19 | steps: 0, 20 | rnd: rnd(), 21 | grow() { 22 | this.p1.x += Math.sin(angle) * growthRate; 23 | this.p1.y += Math.cos(angle) * growthRate; 24 | this.steps++; 25 | this.split = rnd() < config.pBifurcation; 26 | if (rnd() < this.generation * config.expiryThreshold) { 27 | this.expired = true; 28 | } 29 | }, 30 | clip() { 31 | this.p1.x -= Math.sin(angle) * growthRate; 32 | this.p1.y -= Math.cos(angle) * growthRate; 33 | } 34 | }; 35 | activeLineCount++; 36 | line.grow(); 37 | return line; 38 | } 39 | 40 | function buildSeed() { 41 | const angle = rnd(0, Math.PI * 2); 42 | return { 43 | angle, 44 | lines: [buildLine({ 45 | x: rnd(0, canvas.width), 46 | y: rnd(0, canvas.height), 47 | }, angle)], 48 | grow() { 49 | this.lines.filter(l=>l.active).forEach(line => { 50 | line.grow(); 51 | if (line.expired) { 52 | line.active = false; 53 | activeLineCount--; 54 | return; 55 | } 56 | if (collisionDetector.checkForCollisions(line, model.forEachLineUntilTrue)) { 57 | line.clip(); 58 | line.active = false; 59 | activeLineCount--; 60 | return; 61 | } 62 | if (line.split) { 63 | line.split = false; 64 | const newAngle = line.angle + Math.PI/2 * (rnd() < 0.5 ? 1 : -1); 65 | this.lines.push(buildLine({ 66 | x: line.p1.x, 67 | y: line.p1.y, 68 | }, newAngle, line)) 69 | } 70 | }); 71 | } 72 | }; 73 | } 74 | 75 | const model = { 76 | generate() { 77 | seeds = Array(config.seedCount).fill().map(buildSeed); 78 | }, 79 | grow() { 80 | seeds.forEach(s => s.grow()); 81 | }, 82 | forEachLineUntilTrue(fn) { 83 | (seeds || []).some(seed => { 84 | return seed.lines.some(line => { 85 | return fn(line, config); 86 | }) 87 | }) 88 | }, 89 | isActive() { 90 | return activeLineCount > 0; 91 | } 92 | }; 93 | 94 | return model; 95 | } 96 | 97 | function randomFromSeed(seed) { 98 | // https://stackoverflow.com/a/47593316/138256 99 | function mulberry32() { 100 | var t = seed += 0x6D2B79F5; 101 | t = Math.imul(t ^ t >>> 15, t | 1); 102 | t ^= t + Math.imul(t ^ t >>> 7, t | 61); 103 | return ((t ^ t >>> 14) >>> 0) / 4294967296; 104 | } 105 | 106 | return function(a=1, b=0) { 107 | const min = b && a, 108 | max = b || a; 109 | return mulberry32() * (max - min) + min; 110 | } 111 | } 112 | 113 | function buildCollisionDetector(canvas) { 114 | const CLOCKWISE = 1, ANTICLOCKWISE = 2, COLINEAR = 0; 115 | 116 | function lineOffscreen(line) { 117 | return !canvas.isVisible(line.p1.x, line.p1.y); 118 | } 119 | 120 | function orientation(p, q, r) { 121 | const val = ((q.y - p.y) * (r.x - q.x)) - ((q.x - p.x) * (r.y - q.y)); 122 | if (val > 0) { 123 | return CLOCKWISE; 124 | } else if (val < 0) { 125 | return ANTICLOCKWISE; 126 | } 127 | return COLINEAR; 128 | } 129 | 130 | function onSegment(p, q, r){ 131 | return (q.x <= Math.max(p.x, r.x)) && (q.x >= Math.min(p.x, r.x)) && (q.y <= Math.max(p.y, r.y)) && (q.y >= Math.min(p.y, r.y)); 132 | } 133 | 134 | function linesIntersect(l1, l2) { 135 | const o1 = orientation(l1.p0, l1.p1, l2.p0), 136 | o2 = orientation(l1.p0, l1.p1, l2.p1), 137 | o3 = orientation(l2.p0, l2.p1, l1.p0), 138 | o4 = orientation(l2.p0, l2.p1, l1.p1); 139 | 140 | if ((o1 != o2) && (o3 != o4)) { 141 | return true; 142 | } 143 | 144 | if ((o1 === COLINEAR) && onSegment(l1.p0, l2.p0, l1.p1)) { 145 | return true; 146 | } 147 | 148 | if ((o2 === COLINEAR) && onSegment(l1.p0, l2.p1, l1.p1)) { 149 | return true; 150 | } 151 | 152 | if ((o3 === COLINEAR) && onSegment(l2.p0, l1.p0, l2.p1)) { 153 | return true; 154 | } 155 | 156 | if ((o4 === COLINEAR) && onSegment(l2.p0, l1.p1, l2.p1)) { 157 | return true; 158 | } 159 | 160 | return false; 161 | } 162 | 163 | return { 164 | checkForCollisions(line1, forEachLineUntilTrue) { 165 | if (lineOffscreen(line1)) { 166 | return true; 167 | } 168 | 169 | let foundCollision = false; 170 | forEachLineUntilTrue(line2 => { 171 | if (line1 === line2 || line1.parent === line2 || line2.parent === line1) { 172 | return; 173 | } 174 | return foundCollision = linesIntersect(line1, line2); 175 | }); 176 | return foundCollision; 177 | } 178 | }; 179 | } 180 | 181 | function buildRandomConfig(rnd) { 182 | const useGradients = rnd() > 0.3; 183 | 184 | return { 185 | seedCount: Math.round(rnd(1, 10)), 186 | pBifurcation: rnd(0.02, 0.05), 187 | maxRectWidth: rnd(0,100), 188 | rectBaseHue: rnd(360), 189 | rectSaturation: rnd(20,100), 190 | rectHueVariation: rnd(100), 191 | rectAlpha: useGradients ? rnd(0.4,0.8) : rnd(0.1,0.4), 192 | rectLightness: rnd(20,70), 193 | expiryThreshold: rnd(0.001), 194 | lineDarkness: rnd(), 195 | pencilHorizontal: rnd() > 0.5, 196 | useGradients 197 | }; 198 | } 199 | 200 | function createNewRender(seed, onFinished) { 201 | function update() { 202 | model.grow(); 203 | 204 | if (model.isActive()) { 205 | view.canvas.clear(); 206 | model.forEachLineUntilTrue((line, config) => { 207 | view.canvas.drawRect(line, Math.min(config.maxRectWidth, line.steps), `hsla(${(config.rectBaseHue + (line.rnd - 0.5) * config.rectHueVariation) % 360},${config.rectSaturation}%,${config.rectLightness}%,${config.rectAlpha})`, config.useGradients); 208 | }); 209 | model.forEachLineUntilTrue((line, config) => { 210 | const lineColourValue = Math.round(config.lineDarkness * 100), 211 | lineColour = `rgb(${lineColourValue},${lineColourValue},${lineColourValue})`; 212 | view.canvas.drawLine(line, lineColour) 213 | }); 214 | return false; 215 | } 216 | return true; 217 | } 218 | 219 | let model, rnd, config, stopRequested, collisionDetector; 220 | 221 | const render = { 222 | init() { 223 | stopRequested = false; 224 | rnd = randomFromSeed(seed); 225 | config = buildRandomConfig(rnd); 226 | collisionDetector = buildCollisionDetector(view.canvas); 227 | model = buildModel(config, rnd, collisionDetector); 228 | view.canvas.clear(); 229 | model.generate(); 230 | }, 231 | start() { 232 | function doUpdate() { 233 | const isComplete = update(); 234 | 235 | if (stopRequested) { 236 | stopRequested = false; 237 | 238 | } else if (isComplete) { 239 | onFinished(); 240 | 241 | } else { 242 | requestAnimationFrame(doUpdate); 243 | } 244 | } 245 | doUpdate(); 246 | }, 247 | stop() { 248 | stopRequested = true; 249 | }, 250 | applyPencil() { 251 | view.canvas.clear(); 252 | model.forEachLineUntilTrue((line, config) => { 253 | if (!config.pencilHorizontal || Math.sin(line.angle)**2 < rnd()) { 254 | view.canvas.drawWithPencil(line, rnd(10, 100), { 255 | h: (config.rectBaseHue + (line.rnd - 0.5) * config.rectHueVariation) % 360, 256 | s: config.rectSaturation, 257 | l: config.rectLightness 258 | }, rnd); 259 | } 260 | }); 261 | model.forEachLineUntilTrue((line, config) => { 262 | const lineColourValue = Math.round(config.lineDarkness * 100), 263 | lineColour = `rgb(${lineColourValue},${lineColourValue},${lineColourValue})`; 264 | view.canvas.drawLine(line, lineColour) 265 | }); 266 | } 267 | }; 268 | return render; 269 | } 270 | 271 | let render, onFinishedCurrentHandler = () => {}; 272 | 273 | return { 274 | onFinishedCurrent(handler) { 275 | onFinishedCurrentHandler = handler; 276 | }, 277 | startNew(seed=Date.now() & 0xfffff) { 278 | if (render) { 279 | render.stop(); 280 | } 281 | render = createNewRender(seed, onFinishedCurrentHandler); 282 | render.init(); 283 | render.start(); 284 | return seed; 285 | }, 286 | resume() { 287 | render.start(); 288 | }, 289 | pause() { 290 | render.stop(); 291 | }, 292 | applyPencil() { 293 | render.applyPencil(); 294 | } 295 | }; 296 | 297 | })(); 298 | -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | function init() { 2 | "use strict"; 3 | view.init(); 4 | 5 | function startNew(seed) { 6 | const newSeed = generator.startNew(seed); 7 | view.addSeed(newSeed); 8 | } 9 | 10 | view.onStart(startNew); 11 | 12 | view.onResume(() => { 13 | generator.resume(); 14 | }); 15 | 16 | view.onPause(() => { 17 | generator.pause(); 18 | }); 19 | 20 | view.onPencil(() => { 21 | generator.applyPencil(); 22 | }); 23 | 24 | view.onSeedClick(seed => { 25 | startNew(seed); 26 | }); 27 | 28 | generator.onFinishedCurrent(() => { 29 | if (view.isContinuous()) { 30 | startNew(); 31 | } else { 32 | view.setStopped(); 33 | } 34 | }); 35 | } 36 | init(); -------------------------------------------------------------------------------- /js/view.js: -------------------------------------------------------------------------------- 1 | const view = (() => { 2 | "use strict"; 3 | const elPlayPause = document.getElementById('playPause'), 4 | elDownload = document.getElementById('download'), 5 | elContinuous = document.getElementById('continuous'), 6 | elPencil = document.getElementById('pencil'), 7 | elSeeds = document.getElementById('recentSeeds'), 8 | elCanvas = document.getElementById('canvas'), 9 | 10 | NO_OP = () => {}, 11 | MAX_SEEDS = 3, 12 | 13 | STATE_INIT = 1, 14 | STATE_RUNNING = 2, 15 | STATE_PAUSED = 3, 16 | STATE_STOPPED = 4, 17 | 18 | viewModel = {}; 19 | 20 | let onStartHandler, onResumeHandler, onPauseHandler, onSeedClickHandler, onPencilClickHandler; 21 | 22 | elPlayPause.onclick = () => { 23 | let handler, newState; 24 | if (viewModel.state === STATE_INIT || viewModel.state === STATE_STOPPED) { 25 | handler = onStartHandler || NO_OP; 26 | newState = STATE_RUNNING; 27 | 28 | } else if (viewModel.state === STATE_RUNNING) { 29 | handler = onPauseHandler || NO_OP; 30 | newState = STATE_PAUSED; 31 | 32 | } else if (viewModel.state === STATE_PAUSED) { 33 | handler = onResumeHandler || NO_OP; 34 | newState = STATE_RUNNING; 35 | 36 | } else { 37 | console.assert(false, 'Unexpected state: ' + viewModel.state); 38 | } 39 | viewModel.state = newState; 40 | updateFromModel(); 41 | handler(); 42 | }; 43 | 44 | elContinuous.onclick = () => { 45 | viewModel.isContinuous = elContinuous.checked; 46 | }; 47 | 48 | elPencil.onclick = () => { 49 | (onPencilClickHandler || NO_OP)(); 50 | }; 51 | 52 | elSeeds.onclick = e => { 53 | viewModel.state = STATE_RUNNING; 54 | (onSeedClickHandler || NO_OP)(Number(e.target.innerText)); 55 | }; 56 | 57 | elDownload.onclick = () => { 58 | const link = document.createElement('a'); 59 | link.download = `${viewModel.seeds[0]}.png`; 60 | link.href = elCanvas.toDataURL(); 61 | link.click(); 62 | }; 63 | 64 | function updateFromModel() { 65 | if (viewModel.state === STATE_RUNNING) { 66 | elPlayPause.innerText ='Pause'; 67 | } else if (viewModel.state === STATE_PAUSED) { 68 | elPlayPause.innerText = 'Resume'; 69 | } else { 70 | elPlayPause.innerText = 'Start'; 71 | } 72 | 73 | elContinuous.checked = viewModel.isContinuous; 74 | elSeeds.innerHTML = viewModel.seeds.map(seed => `
  • ${seed}
  • `).join(''); 75 | elPencil.disabled = elDownload.disabled = (viewModel.state === STATE_INIT || viewModel.state === STATE_RUNNING); 76 | } 77 | 78 | const canvas = (() => { 79 | const ctx = elCanvas.getContext('2d'); 80 | 81 | function doUpdateDimensions(canvasObj) { 82 | ctx.canvas.width = canvasObj.width = elCanvas.clientWidth; 83 | ctx.canvas.height = canvasObj.height = elCanvas.clientHeight; 84 | } 85 | 86 | let updateDimensions = true; 87 | const canvas = { 88 | clear() { 89 | ctx.fillStyle = "white"; 90 | ctx.fillRect(0, 0, elCanvas.width, elCanvas.height); 91 | if (updateDimensions) { 92 | updateDimensions = false; 93 | doUpdateDimensions(this); 94 | } 95 | }, 96 | drawLine(line, colour) { 97 | ctx.strokeStyle = colour; 98 | ctx.lineWidth=1; 99 | ctx.beginPath(); 100 | ctx.moveTo(line.p0.x, line.p0.y); 101 | ctx.lineTo(line.p1.x, line.p1.y); 102 | ctx.stroke(); 103 | }, 104 | drawRect(line, width, colour, withGradient) { 105 | const xDelta = width * Math.cos(line.angle), 106 | yDelta = width * Math.sin(line.angle); 107 | 108 | if (withGradient) { 109 | const gradient = ctx.createLinearGradient(line.p0.x - xDelta, line.p0.y + yDelta, line.p0.x + xDelta, line.p0.y - yDelta); 110 | gradient.addColorStop(0, 'rgba(255,255,255,0)'); 111 | gradient.addColorStop(0.5, colour); 112 | gradient.addColorStop(1, 'rgba(255,255,255,0)'); 113 | ctx.fillStyle = gradient; 114 | } else { 115 | ctx.fillStyle = colour; 116 | } 117 | ctx.beginPath(); 118 | ctx.moveTo(line.p0.x - xDelta, line.p0.y + yDelta); 119 | ctx.lineTo(line.p1.x - xDelta, line.p1.y + yDelta); 120 | ctx.lineTo(line.p1.x + xDelta, line.p1.y - yDelta); 121 | ctx.lineTo(line.p0.x + xDelta, line.p0.y - yDelta); 122 | ctx.fill(); 123 | }, 124 | drawWithPencil(line, width, colourValues, rnd) { 125 | const ALPHA_FADEOUT_RATE = 4, 126 | ALPHA_RANDOMNESS = 0.1; 127 | let alpha = 0.4; 128 | 129 | for (let d=1; d= 0 && x < this.width && y >= 0 && y < this.height; 140 | } 141 | }; 142 | 143 | window.onresize = () => { 144 | updateDimensions = true; 145 | if (viewModel.state !== STATE_RUNNING) { 146 | const currentImage = elCanvas.toDataURL(); 147 | doUpdateDimensions(canvas); 148 | const img = new Image(); 149 | img.onload = () => { 150 | ctx.drawImage(img, 0, 0); 151 | }; 152 | img.src = currentImage; 153 | } 154 | }; 155 | 156 | return canvas; 157 | })(); 158 | 159 | const viewObj = { 160 | init() { 161 | viewModel.state = STATE_INIT; 162 | viewModel.isContinuous = false; 163 | viewModel.seeds = []; 164 | updateFromModel(); 165 | }, 166 | onStart(handler) { 167 | onStartHandler = handler; 168 | }, 169 | onResume(handler) { 170 | onResumeHandler = handler; 171 | }, 172 | onPause(handler) { 173 | onPauseHandler = handler; 174 | }, 175 | onPencil(handler) { 176 | onPencilClickHandler = handler; 177 | }, 178 | onSeedClick(handler) { 179 | onSeedClickHandler = handler; 180 | }, 181 | addSeed(newSeed) { 182 | const seedIndex = viewModel.seeds.findIndex(s => s === newSeed); 183 | if (seedIndex === -1) { 184 | if (viewModel.seeds.unshift(newSeed) > MAX_SEEDS) { 185 | viewModel.seeds.length = MAX_SEEDS; 186 | } 187 | } else { 188 | viewModel.seeds.splice(seedIndex, 1); 189 | viewModel.seeds.unshift(newSeed); 190 | } 191 | updateFromModel(); 192 | }, 193 | setStopped() { 194 | viewModel.state = STATE_STOPPED; 195 | updateFromModel(); 196 | }, 197 | isContinuous() { 198 | return viewModel.isContinuous; 199 | }, 200 | canvas 201 | }; 202 | 203 | return viewObj; 204 | })(); -------------------------------------------------------------------------------- /map.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generative City Map Maker 5 | 6 | 7 | 8 | 9 |
    10 | 11 |
    12 | 13 | 14 | 15 |
    16 |
    17 | 18 | 19 |
    20 |
    21 |
    22 | 23 |
      24 |
    25 |
    26 |
    27 |
    28 | 29 | 30 | 31 | 32 | --------------------------------------------------------------------------------