├── .eslintrc.js
├── .github
├── FUNDING.yml
└── workflows
│ ├── npmRunDeploy.yml
│ └── npmTest.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .prettierrc.json
├── CNAME
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── NOTICE
├── README.md
├── TODO.md
├── babel.config.json
├── config
└── jest
│ ├── cssTransform.js
│ └── fileTransform.js
├── docker-compose.yml
├── favicon.ico
├── index.html
├── jest.config.js
├── package-lock.json
├── package.json
├── public
└── fonts
│ ├── BubblegumSans-Regular.ttf
│ ├── EBGaramond-Regular.ttf
│ ├── HoltwoodOneSC-Regular.ttf
│ ├── LICENSE
│ ├── Lobster-Regular.ttf
│ ├── Montserrat-Bold.ttf
│ ├── MountainsofChristmas-Regular.ttf
│ ├── NotoEmoji-VariableFont_wght.ttf
│ ├── OpenSans-Regular.ttf
│ ├── Roboto-Black.ttf
│ └── RougeScript-Regular.ttf
├── src
├── common
│ ├── Graph.js
│ ├── Model.js
│ ├── PriorityQueue.js
│ ├── colors.js
│ ├── debugging.js
│ ├── eulerian_trail
│ │ ├── LICENSE
│ │ └── eulerianTrail.js
│ ├── geometry.js
│ ├── hooks
│ │ └── useKeyPress.js
│ ├── lindenmayer.js
│ ├── localStorage.js
│ ├── mocks.js
│ ├── noise.js
│ ├── proximity.js
│ ├── slice.js
│ ├── util.js
│ └── voronoi.js
├── components
│ ├── CheckboxOption.js
│ ├── DropdownOption.js
│ ├── InputOption.js
│ ├── ModelOption.js
│ ├── QuadrantButtonsOption.js
│ ├── SliderOption.js
│ └── ToggleButtonOption.js
├── features
│ ├── app
│ │ ├── About.js
│ │ ├── About.scss
│ │ ├── App.js
│ │ ├── App.scss
│ │ ├── App.test.js
│ │ ├── ErrorFallback.js
│ │ ├── Header.js
│ │ ├── Header.scss
│ │ ├── LICENSE
│ │ ├── Notice.js
│ │ ├── Notice.scss
│ │ ├── Sidebar.js
│ │ ├── appSlice.js
│ │ ├── bootstrap.scss
│ │ ├── happy-holidays.svg
│ │ ├── koch-cube-flowers.svg
│ │ ├── logo.svg
│ │ ├── perlin-rings.svg
│ │ ├── reactGA.js
│ │ ├── rootSlice.js
│ │ └── store.js
│ ├── effects
│ │ ├── CopyEffect.js
│ │ ├── Effect.js
│ │ ├── EffectEditor.js
│ │ ├── EffectLayer.js
│ │ ├── EffectList.js
│ │ ├── EffectManager.js
│ │ ├── FineTuning.js
│ │ ├── Fisheye.js
│ │ ├── Loop.js
│ │ ├── Mask.js
│ │ ├── NewEffect.js
│ │ ├── ProgramCode.js
│ │ ├── Track.js
│ │ ├── Transformer.js
│ │ ├── Voronoi.js
│ │ ├── Warp.js
│ │ ├── effectFactory.js
│ │ ├── effectsSlice.js
│ │ ├── effectsSlice.spec.js
│ │ └── noise
│ │ │ ├── LICENSE
│ │ │ └── Noise.js
│ ├── export
│ │ ├── ExportDownloader.js
│ │ ├── Exporter.js
│ │ ├── GCodeExporter.js
│ │ ├── ScaraGCodeExporter.js
│ │ ├── SvgExporter.js
│ │ ├── ThetaRhoExporter.js
│ │ ├── exporterSlice.js
│ │ └── exporterSlice.spec.js
│ ├── file
│ │ ├── SandifyDownloader.js
│ │ ├── SandifyExporter.js
│ │ ├── SandifyImporter.js
│ │ ├── SandifyUploader.js
│ │ └── fileSlice.js
│ ├── fonts
│ │ └── fontsSlice.js
│ ├── groups
│ │ └── Group.js
│ ├── images
│ │ ├── imagesSlice.js
│ │ └── imagesSlice.spec.js
│ ├── import
│ │ ├── GCodeImporter.js
│ │ ├── ImageUploader.js
│ │ ├── Importer.js
│ │ ├── LayerUploader.js
│ │ ├── ThetaRhoImporter.js
│ │ └── testArcs.gcode
│ ├── layers
│ │ ├── CopyLayer.js
│ │ ├── Layer.js
│ │ ├── LayerEditor.js
│ │ ├── LayerList.js
│ │ ├── LayerManager.js
│ │ ├── LayerManager.scss
│ │ ├── NewLayer.js
│ │ ├── layersSlice.js
│ │ └── layersSlice.spec.js
│ ├── machines
│ │ ├── CopyMachine.js
│ │ ├── Machine.js
│ │ ├── MachineEditor.js
│ │ ├── MachineList.js
│ │ ├── MachineManager.js
│ │ ├── NewMachine.js
│ │ ├── PolarInvertedMachine.js
│ │ ├── PolarMachine.js
│ │ ├── RectInvertedMachine.js
│ │ ├── RectMachine.js
│ │ ├── machineFactory.js
│ │ ├── machinesSlice.js
│ │ ├── machinesSlice.spec.js
│ │ ├── rectMachine.spec.js
│ │ └── util.js
│ ├── preview
│ │ ├── ConnectorPreview.js
│ │ ├── EffectPreview.js
│ │ ├── PreviewHelper.js
│ │ ├── PreviewManager.js
│ │ ├── PreviewManager.scss
│ │ ├── PreviewStats.js
│ │ ├── PreviewWindow.js
│ │ ├── ShapePreview.js
│ │ ├── previewSlice.js
│ │ └── previewSlice.spec.js
│ └── shapes
│ │ ├── Circle.js
│ │ ├── Epicycloid.js
│ │ ├── FancyText.js
│ │ ├── Freeform.js
│ │ ├── Heart.js
│ │ ├── Hypocycloid.js
│ │ ├── LayerImport.js
│ │ ├── NoiseWave.js
│ │ ├── Point.js
│ │ ├── Polygon.js
│ │ ├── Reuleaux.js
│ │ ├── Rose.js
│ │ ├── Shape.js
│ │ ├── Star.js
│ │ ├── Voronoi.js
│ │ ├── Wiper.js
│ │ ├── circle_packer
│ │ ├── Circle.js
│ │ └── CirclePacker.js
│ │ ├── fractal_spirograph
│ │ ├── FractalSpirograph.js
│ │ └── Orbit.js
│ │ ├── image_import
│ │ ├── ImageImport.js
│ │ ├── LICENSE
│ │ ├── helpers.js
│ │ ├── polyspiral.js
│ │ ├── sawtooth.js
│ │ ├── spiral.js
│ │ ├── springs.js
│ │ ├── squiggle.js
│ │ ├── subtypes.js
│ │ └── waves.js
│ │ ├── input_text
│ │ ├── Fonts.js
│ │ ├── InputText.js
│ │ ├── convert_letters.py
│ │ ├── raysol_cursive.js
│ │ ├── raysol_cursive.txt
│ │ ├── raysol_sanserif.js
│ │ └── raysol_sanserif.txt
│ │ ├── lsystem
│ │ ├── LSystem.js
│ │ └── subtypes.js
│ │ ├── shapeFactory.js
│ │ ├── space_filler
│ │ ├── SpaceFiller.js
│ │ └── subtypes.js
│ │ ├── tessellation_twist
│ │ ├── LICENSE
│ │ └── TessellationTwist.js
│ │ └── v1_engineering
│ │ ├── V1Engineering.js
│ │ └── Vicious1Vertices.js
├── index.css
├── index.js
└── setupTests.js
└── vite.config.js
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: jeffeb3 # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: jeffeb3 # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
13 |
--------------------------------------------------------------------------------
/.github/workflows/npmRunDeploy.yml:
--------------------------------------------------------------------------------
1 | # Deploy when there is a new release "Published". This will take each new release and deploy it to
2 | # gh-pages.
3 | #
4 | # Guide:
5 | # https://medium.com/@cmichel/how-to-deploy-a-create-react-app-with-github-actions-5e01f7a7b6b
6 |
7 | name: Deployment
8 |
9 | on:
10 | release:
11 | types: [published]
12 |
13 | jobs:
14 | deploy:
15 |
16 | runs-on: ubuntu-latest
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | - name: Use Node.js
21 | uses: actions/setup-node@v1
22 | with:
23 | node-version: '20.x'
24 | - name: Install Packages
25 | run: npm install
26 | - name: Build page
27 | run: npm run build --if-present
28 | - name: Deploy to gh-pages
29 | uses: peaceiris/actions-gh-pages@v3
30 | with:
31 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
32 | publish_dir: ./build
33 | cname: sandify.org
34 |
--------------------------------------------------------------------------------
/.github/workflows/npmTest.yml:
--------------------------------------------------------------------------------
1 | # Run the npm tests on every push, which automatically tests every PR.
2 | #
3 | # If you want a feature to stay working, then make a test, and other coders will notice when it
4 | # breaks.
5 | # -- Sandify testing strategy.
6 | #
7 | # Guide for this action workflow:
8 | # https://help.github.com/en/actions/language-and-framework-guides/using-nodejs-with-github-actions
9 | name: npm test
10 |
11 | on: [push, pull_request]
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 |
17 | strategy:
18 | matrix:
19 | node-version: [20.x]
20 |
21 | steps:
22 | - uses: actions/checkout@v3
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v3
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 | - run: npm install
28 | - run: npm run build --if-present
29 | - run: npm test
30 | - run: npm run lint-ci
31 | env:
32 | CI: true
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | tmp
4 | .eslintcache
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20.6.1
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | tmp
4 | .eslintcache
5 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleAttributePerLine": true
4 | }
5 |
--------------------------------------------------------------------------------
/CNAME:
--------------------------------------------------------------------------------
1 | sandify.org
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## How to contribute
2 |
3 | Sandify grows from community contributions. We'd love to collaborate with you. Here are some ways we
4 | can improve together:
5 |
6 | ### Share Patterns
7 |
8 | Even though we've spent a lot of time developing sandify, the patterns it can create suprise even
9 | us. If you have used sandify to make a pattern, please share it, and share how it was done. We get
10 | motivated seeing the impacts. Other users get inspired.
11 |
12 | ### Report Issues
13 |
14 | Report bugs, feature requests, errors in the [issue
15 | tracker](https://github.com/jeffeb3/sandify/issues). We can't test every scenario in every release
16 | on every system. Many of the best ideas for features have come from issue requests.
17 |
18 | ### Contribute Code
19 |
20 | Sandify has never been a one person effort. @jeffeb3 owns the project and guides the effort, but
21 | most of the code has been written and rewritten by contributors. It is a pleasure to
22 | work on, and we welcome collaboration.
23 |
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Dockerfile for development of sandify
2 |
3 | FROM node:20.18.1
4 |
5 | RUN npm install -g npm
6 | #RUN npm install -g
7 |
8 | RUN mkdir /srv/app && chown node:node /srv/app
9 |
10 | #COPY . /srv/app
11 |
12 | USER node
13 |
14 | WORKDIR /srv/app
15 |
16 | CMD [ "npm", "start" ]
17 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Jeff Eberl
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 |
23 | ---
24 |
25 | Note: Logo files located in the "src/features/app" directory are not covered under
26 | this license. See LICENSE in that directory for details.
27 |
--------------------------------------------------------------------------------
/NOTICE:
--------------------------------------------------------------------------------
1 | NOTICE
2 |
3 | Sandify incorporates code and font assets from various external sources,
4 | each of may be covered by its own license and copyright notice. Where applicable,
5 | libraries and their licenses are included in the "node_modules"
6 | directory as part of the standard npm installation process. Additional licensing
7 | information can be found, when applicable, in subdirectory LICENSE files and in source
8 | files.
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Sandify
2 |
3 | Sandify turns your cold, empty-hearted, emotionless sand tables into cold, empty-hearted, emotionless sand table robots with enchanting patterns.
4 |
5 | [](https://actions-badge.atrox.dev/jeffeb3/sandify/goto)
6 |
7 | Sandify outputs code you can run on your sand table. The code produced is:
8 | - Continuous, since the ball can't lift to travel to another location.
9 | - Bounded to never exceed machine limits, as the firmware isn't typically smart enough to handle.
10 |
11 | Sandify supports a large number of shape types and effects, so be creative! Input is welcome from the community.
12 |
13 | ## Using Sandify
14 |
15 | - Head over to [sandify.org](https://sandify.org).
16 | - Adjust the machine limits to match your table.
17 | - Add shapes and effects until you create a pleasing pattern.
18 | - Export the code in a format supported by your table.
19 |
20 | ## More info
21 |
22 | - Check out the [wiki](https://github.com/jeffeb3/sandify/wiki) for lots of details on features.
23 | - Read about how Sandify [came into being](https://forum.v1engineering.com/t/does-this-count-as-a-build/6037?u=jeffeb3).
24 |
25 | ## Running from source locally
26 |
27 | - [Install npm and node.js](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm).
28 | - Clone the Sandify repository, and change to that directory.
29 | - `npm install`
30 | - `npm start`
31 | - View in your browser at http://127.0.0.1:3000 or http://localhost:3000.
32 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | ### TODO FOR RELEASE
2 |
3 | - bug: wiper, 90 degrees with noise effect; change wiper size from 4 to 40, hangs browser
4 | - bug: edge-case optimization of pattern with inverted mask is adding a center point within the mask; workaround is to enable "minimize perimeter moves", but this isn't user-friendly obviously
5 |
6 | ### FUTURE CONSIDERATION
7 |
8 | - refactor slider so it's precise like fine tuning; support Shift key as well
9 | - use react-router-dom for routes so browser back button works with tabs
10 | - groups
11 | - selectLayersByGroupId - some kind of compound parent key "[a]-[b]"
12 | - big thunk which just changes layer dimensions
13 | - if it has effects, can render those via selector
14 | - store pattern name and other desired attribution (?) in exported file and display it somewhere (either stats tab or in the preview window)
15 | - show pattern start/end type (e.g., 1-0) if start/end if specified
16 | - new fine tuning setting: when backtracking at end, optionally ignore border if enabled
17 |
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"],
3 | "plugins": [
4 | ["@babel/transform-runtime"]
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/config/jest/cssTransform.js:
--------------------------------------------------------------------------------
1 | // @remove-on-eject-begin
2 | /**
3 | * Copyright (c) 2014-present, Facebook, Inc.
4 | *
5 | * This source code is licensed under the MIT license found in the
6 | * LICENSE file in the root directory of this source tree.
7 | */
8 | // @remove-on-eject-end
9 | 'use strict'
10 |
11 | // This is a custom Jest transformer turning style imports into empty objects.
12 | // http://facebook.github.io/jest/docs/en/webpack.html
13 |
14 | module.exports = {
15 | process() {
16 | return 'module.exports = {}'
17 | },
18 | getCacheKey() {
19 | // The output is always the same.
20 | return 'cssTransform'
21 | },
22 | }
23 |
--------------------------------------------------------------------------------
/config/jest/fileTransform.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const path = require('path')
4 | const camelcase = require('camelcase')
5 |
6 | // This is a custom Jest transformer turning file imports into filenames.
7 | // http://facebook.github.io/jest/docs/en/webpack.html
8 |
9 | module.exports = {
10 | process(src, filename) {
11 | const assetFilename = JSON.stringify(path.basename(filename))
12 |
13 | if (filename.match(/\.svg$/)) {
14 | // Based on how SVGR generates a component name:
15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6
16 | const pascalCaseFilename = camelcase(path.parse(filename).name, {
17 | pascalCase: true,
18 | })
19 | const componentName = `Svg${pascalCaseFilename}`
20 | const code = `const React = require('react')
21 | module.exports = {
22 | __esModule: true,
23 | default: ${assetFilename},
24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) {
25 | return {
26 | $$typeof: Symbol.for('react.element'),
27 | type: 'svg',
28 | ref: ref,
29 | key: null,
30 | props: Object.assign({}, props, {
31 | children: ${assetFilename}
32 | })
33 | }
34 | }),
35 | }`
36 | return {
37 | code
38 | }
39 | }
40 |
41 | return `module.exports = ${assetFilename}`
42 | },
43 | }
44 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 |
2 | # build with:
3 | # docker-compose build sandify
4 | #
5 | # install/update with:
6 | # docker-compose run --entrypoint "npm install" sandify
7 | #
8 | # run with:
9 | # docker-compose run sandify
10 | #
11 | # test with:
12 | # docker-compose run --entrypoint "npm test" sandify
13 | #
14 | version: '3.7'
15 |
16 | services:
17 | sandify:
18 | image: sandify
19 | build: .
20 | container_name: sandify
21 | ports:
22 | - "3000:3000"
23 | volumes:
24 | - .:/srv/app
25 | network_mode: "host"
26 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/favicon.ico
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Sandify
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sandify",
3 | "homepage": "https://sandify.org",
4 | "version": "1.1.1",
5 | "private": true,
6 | "dependencies": {
7 | "@dnd-kit/core": "^6.1.0",
8 | "@dnd-kit/modifiers": "^7.0",
9 | "@dnd-kit/sortable": "^8.0",
10 | "@reduxjs/toolkit": "^2.2.4",
11 | "array-move": "^4.0.0",
12 | "bootstrap": "^5.3.3",
13 | "buffer": "^6.0.3",
14 | "canvas": "^2.11.2",
15 | "color": "^4.2.3",
16 | "convexhull-js": "^1.0.0",
17 | "core-js": "^3.37.0",
18 | "curve-matcher": "^1.1.1",
19 | "d3": "^7.9",
20 | "d3-delaunay": "^6.0.4",
21 | "d3-fisheye": "^2.0.1",
22 | "gcode-toolpath": "^2.2.0",
23 | "geokdbush": "^1.1.0",
24 | "javascript-algorithms": "0.0.5",
25 | "kdbush": "^4.0.2",
26 | "konva": "^9.2.0",
27 | "liang-barsky": "^1.0.4",
28 | "lodash": "^4.17.21",
29 | "lru-cache": "^10.2.2",
30 | "mathjs": "^12.4.2",
31 | "opentype.js": "^1.3.4",
32 | "point-in-polygon": "^1.1.0",
33 | "points-on-path": "^0.2.1",
34 | "poisson-disk-sampling": "^2.3.1",
35 | "rc-slider": "^10.6.2",
36 | "re-reselect": "^5.1.0",
37 | "react": "18.3",
38 | "react-bootstrap": "^2.10.2",
39 | "react-dom": "^18.3.1",
40 | "react-error-boundary": "^4.0.13",
41 | "react-ga4": "^2.1.0",
42 | "react-icons": "^5.2.1",
43 | "react-konva": "^18.2.10",
44 | "react-redux": "^9.1.2",
45 | "react-select": "^5.8.0",
46 | "react-switch": "^7.0.0",
47 | "react-toastify": "^10.0.5",
48 | "react-tooltip": "^5.26.4",
49 | "reduce-reducers": "^1.0.4",
50 | "redux": "^5.0.1",
51 | "redux-mock-store": "^1.5.4",
52 | "reselect": "^5.1.0",
53 | "sass": "^1.77.1",
54 | "seedrandom": "^3.0.5",
55 | "uuid": "^9.0.0",
56 | "victor": "^1.1.0"
57 | },
58 | "devDependencies": {
59 | "@babel/eslint-parser": "^7.24.5",
60 | "@babel/plugin-transform-runtime": "^7.24.3",
61 | "@babel/preset-env": "^7.24.5",
62 | "@babel/preset-react": "^7.24.1",
63 | "@esbuild-plugins/node-globals-polyfill": "^0.2.3",
64 | "@typescript-eslint/eslint-plugin": "^7.8.0",
65 | "@typescript-eslint/parser": "^7.8.0",
66 | "@vitejs/plugin-react": "^4.2.1",
67 | "babel-jest": "^29.6.4",
68 | "eslint": "^8.57.0",
69 | "eslint-config-prettier": "^9.1.0",
70 | "eslint-plugin-jest": "^28.5.0",
71 | "eslint-plugin-prettier": "^5.1.3",
72 | "eslint-plugin-react": "^7.34.1",
73 | "eslint-plugin-react-redux": "^4.1.0",
74 | "gh-pages": "^6.1.0",
75 | "identity-obj-proxy": "^3.0.0",
76 | "jest": "^29.6.4",
77 | "jest-canvas-mock": "^2.3.1",
78 | "jest-environment-jsdom": "^29.6.4",
79 | "prettier": "3.2.5",
80 | "vite": "^4.5.3",
81 | "vite-plugin-node-polyfills": "^0.21.0"
82 | },
83 | "scripts": {
84 | "start": "vite",
85 | "build": "vite build",
86 | "preview": "vite preview",
87 | "deploy": "gh-pages -d build",
88 | "test": "jest",
89 | "lint": "eslint --fix --ext .js --ignore-path .gitignore src",
90 | "lint-ci": "eslint --ext .js --ignore-path .gitignore src"
91 | },
92 | "browserslist": [
93 | ">0.2%",
94 | "not dead",
95 | "not ie <= 11",
96 | "not op_mini all"
97 | ]
98 | }
99 |
--------------------------------------------------------------------------------
/public/fonts/BubblegumSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/BubblegumSans-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/EBGaramond-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/EBGaramond-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/HoltwoodOneSC-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/HoltwoodOneSC-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Lobster-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Lobster-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Montserrat-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Montserrat-Bold.ttf
--------------------------------------------------------------------------------
/public/fonts/MountainsofChristmas-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/MountainsofChristmas-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/NotoEmoji-VariableFont_wght.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/NotoEmoji-VariableFont_wght.ttf
--------------------------------------------------------------------------------
/public/fonts/OpenSans-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/OpenSans-Regular.ttf
--------------------------------------------------------------------------------
/public/fonts/Roboto-Black.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/Roboto-Black.ttf
--------------------------------------------------------------------------------
/public/fonts/RougeScript-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeffeb3/sandify/39f8174a6929cc16c815d1a14c156635bdc88c7a/public/fonts/RougeScript-Regular.ttf
--------------------------------------------------------------------------------
/src/common/Model.js:
--------------------------------------------------------------------------------
1 | import { functionValue } from "@/common/util"
2 |
3 | const options = []
4 |
5 | export default class Model {
6 | constructor(type, state) {
7 | this.type = type
8 | this.state = state
9 |
10 | Object.assign(this, {
11 | usesMachine: false,
12 | usesFonts: false,
13 | dragging: false,
14 | stretch: false,
15 | randomizable: true,
16 | })
17 | }
18 |
19 | // override as needed
20 | canChangeSize(state) {
21 | return true
22 | }
23 |
24 | // override as needed
25 | canChangeAspectRatio(state) {
26 | return this.canChangeSize(state)
27 | }
28 |
29 | // override as needed
30 | canRotate(state) {
31 | return true
32 | }
33 |
34 | // override as needed
35 | canMove(state) {
36 | return true
37 | }
38 |
39 | canTransform(state) {
40 | return (
41 | this.canMove(state) || this.canRotate(state) || this.canChangeSize(state)
42 | )
43 | }
44 |
45 | // override as needed; redux state of a newly created instance
46 | getInitialState() {
47 | return {}
48 | }
49 |
50 | getOptions() {
51 | return options
52 | }
53 |
54 | randomChanges(layer, exclude = []) {
55 | const changes = { id: layer.id }
56 | const options = this.getOptions()
57 |
58 | Object.keys(options).forEach((key) => {
59 | if (!exclude.includes(key)) {
60 | const change = this.randomChange(key, layer, options)
61 |
62 | if (change != null) {
63 | changes[key] = change
64 | }
65 | }
66 | })
67 |
68 | return changes
69 | }
70 |
71 | // given an option key, make a random change based on model options
72 | randomChange(key, layer, options) {
73 | const settings = options[key]
74 | const random = settings.random == null ? 1 : settings.random
75 | const randomize = Math.random() <= random
76 | const type = settings.type
77 |
78 | if (type == "checkbox") {
79 | const defaults = this.getInitialState()
80 | let value = defaults[key]
81 |
82 | if (random && Math.random() <= 0.5) {
83 | value = !value
84 | }
85 |
86 | return value
87 | } else if (type == "togglebutton" || type == "dropdown") {
88 | let choices = functionValue(settings.choices, layer)
89 | const choice = Math.floor(Math.random() * choices.length)
90 |
91 | return choices[choice]
92 | } else if (type == "number" || type == null) {
93 | const defaults = this.getInitialState()
94 | let min = settings.randomMin
95 | if (min == null) {
96 | min = functionValue(settings.min, layer) || 0
97 | }
98 | let max = settings.randomMax || functionValue(settings.max, layer)
99 | if (max == null) {
100 | max = defaults[key] * 3
101 | }
102 | const step = settings.step || 1
103 |
104 | if (randomize) {
105 | const random = Math.random() * (max - min) + min
106 | let precision, value
107 |
108 | if (step >= 1) {
109 | value = Math.round(random)
110 | } else {
111 | precision = Math.round(1 / step)
112 | value = Math.round(random * precision) / precision
113 | }
114 |
115 | return value
116 | } else {
117 | return min
118 | }
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/common/PriorityQueue.js:
--------------------------------------------------------------------------------
1 | export class PriorityQueue {
2 | constructor() {
3 | this.collection = []
4 | }
5 |
6 | enqueue(element) {
7 | if (this.isEmpty()) {
8 | this.collection.push(element)
9 | } else {
10 | let added = false
11 | for (let i = 1; i <= this.collection.length; i++) {
12 | if (element[1] < this.collection[i - 1][1]) {
13 | this.collection.splice(i - 1, 0, element)
14 | added = true
15 | break
16 | }
17 | }
18 |
19 | if (!added) {
20 | this.collection.push(element)
21 | }
22 | }
23 | }
24 |
25 | dequeue() {
26 | let value = this.collection.shift()
27 | return value
28 | }
29 |
30 | isEmpty() {
31 | return this.collection.length === 0
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/common/colors.js:
--------------------------------------------------------------------------------
1 | const colors = {
2 | selectedShapeColor: "rgba(255, 255, 0, 0.7)", // yellow
3 | slidingColor: "#2983BA", // blue
4 | activeEffectColor: "rgba(15, 128, 0, 0.8)", // green
5 | unselectedShapeColor: "rgba(195, 214, 230, 0.4)", // gray
6 | noSelectionColor: "rgba(255, 255, 0, 0.7)", // slightly muted yellow
7 | activeConnectorColor: "#2983BA", // blue
8 | endPointColor: "red",
9 | startPointColor: "rgb(15, 128, 0)", // green
10 | transformerBorderColor: "#fefefe", // almost white
11 | }
12 |
13 | export default colors
14 |
--------------------------------------------------------------------------------
/src/common/debugging.js:
--------------------------------------------------------------------------------
1 | import { compact } from "lodash"
2 |
3 | // set to true to enable console logging
4 | const debug = false
5 |
6 | // set to true to clear console before logging each state change
7 | const debugConsoleClear = false
8 |
9 | // limit which keys are shown, e.g., const keys = ['selectLayerById']
10 | const keys = null
11 |
12 | // keep count of log occurrences by key
13 | const logCounts = {}
14 |
15 | export const log = (key, id) => {
16 | if (debug) {
17 | if (!keys || keys.includes(key)) {
18 | const keyId = compact([id, key]).join("/")
19 | logCounts[keyId] ||= 0
20 | logCounts[keyId]++
21 |
22 | const message = [logCounts[keyId], keyId].join(" - ")
23 | console.log(message)
24 | }
25 | }
26 | }
27 |
28 | export const resetLogCounts = () => {
29 | if (debug) {
30 | for (const key of Object.getOwnPropertyNames(logCounts)) {
31 | delete logCounts[key]
32 | }
33 |
34 | if (debugConsoleClear) {
35 | console.clear()
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/common/eulerian_trail/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Mauricio Poppe
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
13 | all 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
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/common/eulerian_trail/eulerianTrail.js:
--------------------------------------------------------------------------------
1 | // adapted from https://github.com/mauriciopoppe/eulerian-trail/blob/master/lib/eulerian-trail.js
2 | // see LICENSE for license details
3 | // commented out thrown exceptions to return non-optimal eulerian paths
4 | export const eulerianTrail = (options) => {
5 | var g = []
6 | var i
7 | var edgePointer = []
8 | var edgeUsed = []
9 | var trail = []
10 |
11 | var id = {}
12 | var idReverse = []
13 | var idCount = 0
14 |
15 | function getId(x) {
16 | if (!id[x]) {
17 | edgePointer[idCount] = 0
18 | idReverse[idCount] = x
19 | id[x] = idCount++
20 | }
21 | return id[x]
22 | }
23 |
24 | function dfs(v) {
25 | for (; edgePointer[v] < g[v].length; edgePointer[v] += 1) {
26 | var edge = g[v][edgePointer[v]]
27 | var to = edge[0]
28 | var id = edge[1]
29 | if (!edgeUsed[id]) {
30 | edgeUsed[id] = true
31 | dfs(to)
32 | }
33 | }
34 | trail.push(v)
35 | }
36 |
37 | function pushEdge(u, v, id) {
38 | g[u] = g[u] || []
39 | g[v] = g[v] || []
40 | g[u].push([v, id])
41 | }
42 |
43 | var deg = []
44 | var inDeg = [],
45 | outDeg = []
46 |
47 | for (i = 0; i < options.edges.length; i += 1) {
48 | var edge = options.edges[i]
49 | var u = getId(edge[0])
50 | var v = getId(edge[1])
51 |
52 | pushEdge(u, v, i)
53 | if (!options.directed) {
54 | pushEdge(v, u, i)
55 | }
56 |
57 | if (options.directed) {
58 | outDeg[u] = outDeg[u] || 0
59 | inDeg[u] = inDeg[u] || 0
60 | outDeg[v] = outDeg[v] || 0
61 | inDeg[v] = inDeg[v] || 0
62 | outDeg[u] += 1
63 | inDeg[v] += 1
64 | } else {
65 | deg[u] = deg[u] || 0
66 | deg[v] = deg[v] || 0
67 | deg[u] += 1
68 | deg[v] += 1
69 | }
70 | }
71 |
72 | function checkDirected() {
73 | var oddVertex = 0
74 | var start = 0
75 |
76 | for (i = 0; i < idCount; i += 1) {
77 | if (outDeg[i] - inDeg[i] !== 0) {
78 | if (outDeg[i] > inDeg[i]) {
79 | start = i
80 | }
81 | oddVertex += 1
82 | }
83 | }
84 | return { odd: oddVertex, start }
85 | }
86 |
87 | function checkUndirected() {
88 | var oddVertex = 0
89 | var start = 0
90 |
91 | for (i = 0; i < idCount; i += 1) {
92 | if (deg[i] % 2 !== 0) {
93 | start = i
94 | oddVertex += 1
95 | }
96 | }
97 | return { odd: oddVertex, start }
98 | }
99 |
100 | var check = options.directed ? checkDirected() : checkUndirected()
101 | if (check.odd % 2 !== 0 || check.odd > 2) {
102 | // throw Error('the graph does not have an eulerian trail')
103 | }
104 | dfs(check.start)
105 |
106 | if (trail.length !== options.edges.length + 1) {
107 | // throw Error('the graph does not have an eulerian trail')
108 | }
109 |
110 | trail.reverse()
111 |
112 | // id to input
113 | return trail.map(function (id) {
114 | return idReverse[id]
115 | })
116 | }
117 |
--------------------------------------------------------------------------------
/src/common/hooks/useKeyPress.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react"
2 |
3 | const useKeyPress = (targetKey, inputRef) => {
4 | const [keyPressed, setKeyPressed] = useState(false)
5 |
6 | const handleKeyDown = ({ key }) => {
7 | if (key === targetKey) {
8 | setKeyPressed(true)
9 | }
10 | }
11 |
12 | const handleKeyUp = ({ key }) => {
13 | if (key === targetKey) {
14 | setKeyPressed(false)
15 | }
16 | }
17 |
18 | useEffect(() => {
19 | if (inputRef.current) {
20 | window.addEventListener("keydown", handleKeyDown)
21 | window.addEventListener("keyup", handleKeyUp)
22 |
23 | // remove event listeners on cleanup
24 | return () => {
25 | window.removeEventListener("keydown", handleKeyDown)
26 | window.removeEventListener("keyup", handleKeyUp)
27 | }
28 | }
29 | }, [inputRef])
30 |
31 | return keyPressed
32 | }
33 |
34 | export default useKeyPress
35 |
--------------------------------------------------------------------------------
/src/common/localStorage.js:
--------------------------------------------------------------------------------
1 | // if you want to save a multiple temporary states, use these keys. The first time
2 | // you save a new state, change persistSaveKey. Make a change, then change
3 | // persistLoadKey to the same value. These keys are obsolete now
4 | // given that a user can save their pattern to a file.
5 | const persistLoadKey = "state"
6 | const persistSaveKey = "state"
7 |
8 | export const loadState = (key = persistLoadKey) => {
9 | try {
10 | const serializedState = localStorage.getItem(key)
11 | if (serializedState === null) {
12 | return undefined
13 | }
14 | return JSON.parse(serializedState)
15 | } catch (err) {
16 | return undefined
17 | }
18 | }
19 |
20 | export const saveState = (state, key = persistSaveKey) => {
21 | try {
22 | const serializedState = JSON.stringify(state)
23 | localStorage.setItem(key, serializedState)
24 | } catch (err) {
25 | // ignore write errors
26 | console.log(err)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/common/mocks.js:
--------------------------------------------------------------------------------
1 | let counter = 0
2 |
3 | const uniqueId = () => {
4 | counter++
5 | return counter.toString()
6 | }
7 |
8 | export const resetUniqueId = (start = 0) => {
9 | counter = start
10 | }
11 |
12 | jest.mock("uuid", () => ({ v4: () => uniqueId() }))
13 |
--------------------------------------------------------------------------------
/src/common/proximity.js:
--------------------------------------------------------------------------------
1 | import KDBush from "kdbush"
2 |
3 | // O(n); returns the point in arr that is farthest from a given point
4 | export const farthest = (arr, point) => {
5 | return arr.reduce(
6 | (max, x, i, arr) => (x.distance(point) > max.distance(point) ? x : max),
7 | arr[0],
8 | )
9 | }
10 |
11 | // O(n); returns the point in arr that is closest to a given point
12 | export const closest = (arr, point) => {
13 | return arr.reduce(
14 | (max, x, i, arr) => (x.distance(point) < max.distance(point) ? x : max),
15 | arr[0],
16 | )
17 | }
18 |
19 | // O(n log n); returns the point in arr1 that is closest to any point in arr2
20 | export const nearestNeighbor = (arr1, arr2, radius = 1) => {
21 | const index = new KDBush(arr2.length)
22 |
23 | arr2.forEach((point) => index.add(point.x, point.y))
24 | index.finish()
25 |
26 | let minDistance = Infinity
27 | let closestPoint
28 |
29 | arr1.forEach((point) => {
30 | // find the nearest neighbor in arr2 to point
31 | const neighborIds = index.within(point.x, point.y, radius)
32 | const neighbor = arr2[neighborIds[0]]
33 |
34 | if (neighbor) {
35 | // calculate the Euclidean distance between the point and the nearest neighbor
36 | const distance = Math.sqrt(
37 | (point.x - neighbor.x) ** 2 + (point.y - neighbor.y) ** 2,
38 | )
39 |
40 | if (distance < minDistance) {
41 | minDistance = distance
42 | closestPoint = point
43 | }
44 | }
45 | })
46 |
47 | return closestPoint
48 | }
49 |
--------------------------------------------------------------------------------
/src/common/slice.js:
--------------------------------------------------------------------------------
1 | //
2 | // Wrapper methods around slice and adapter actions consistent with how our our application
3 | // uses the Redux store.
4 | //
5 | import { v4 as uuidv4 } from "uuid"
6 |
7 | const selectedIndex = (state) => {
8 | const curr = state.entities[state.selected]
9 | return curr ? state.ids.findIndex((id) => id === curr.id) : -1
10 | }
11 |
12 | // Insert an entity at a specific index, which is not supported by addOne.
13 | export const insertOne = (state, action) => {
14 | const index = state.selected ? selectedIndex(state) + 1 : 0
15 | const entity = { ...action.payload }
16 |
17 | state.ids.splice(index, 0, entity.id)
18 | state.entities[entity.id] = entity
19 | state.current = entity.id
20 |
21 | return entity
22 | }
23 |
24 | export const prepareAfterAdd = (entity) => {
25 | const id = uuidv4()
26 |
27 | // return newly generated id so downstream actions can use it
28 | return { payload: { ...entity, id }, meta: { id } }
29 | }
30 |
31 | export const updateOne = (adapter, state, action) => {
32 | const entity = action.payload
33 |
34 | adapter.updateOne(state, { id: entity.id, changes: entity })
35 | }
36 |
--------------------------------------------------------------------------------
/src/common/util.js:
--------------------------------------------------------------------------------
1 | import { keyBy, compact } from "lodash"
2 |
3 | export const difference = (a, b) => {
4 | // eslint-disable-next-line no-undef
5 | return new Set([
6 | ...[...a].filter((x) => !b.has(x)),
7 | ...[...b].filter((x) => !a.has(x)),
8 | ])
9 | }
10 |
11 | // round a given number n to p number of digits
12 | export const roundP = (n, p) => {
13 | return Math.round((n + Number.EPSILON) * Math.pow(10, p)) / Math.pow(10, p)
14 | }
15 |
16 | // https://stackoverflow.com/a/4652513
17 | export const reduce = (numerator, denominator) => {
18 | let gcd = (a, b) => {
19 | return b ? gcd(b, a % b) : a
20 | }
21 |
22 | gcd = gcd(numerator, denominator)
23 | return [numerator / gcd, denominator / gcd]
24 | }
25 |
26 | // rotates an array count times
27 | // taken from https://stackoverflow.com/questions/1985260/rotate-the-elements-in-an-array-in-javascript#33451102
28 | export const arrayRotate = (arr, count) => {
29 | count -= arr.length * Math.floor(count / arr.length)
30 | arr.push.apply(arr, arr.splice(0, count))
31 | return arr
32 | }
33 |
34 | // Helper function to take a string and make the user download a text file with that text as the
35 | // content. I don't really understand this, but I took it from here, and it seems to work:
36 | // https://stackoverflow.com/a/18197511
37 | export const downloadFile = (
38 | fileName,
39 | text,
40 | fileType = "text/plain;charset=utf-8",
41 | ) => {
42 | let link = document.createElement("a")
43 | link.download = fileName
44 |
45 | let blob = new Blob([text], { type: fileType })
46 |
47 | // Windows Edge fix
48 | if (window.navigator && window.navigator.msSaveOrOpenBlob) {
49 | window.navigator.msSaveOrOpenBlob(blob, fileName)
50 | } else {
51 | link.href = URL.createObjectURL(blob)
52 | if (document.createEvent) {
53 | var event = document.createEvent("MouseEvents")
54 | event.initEvent("click", true, true)
55 | link.dispatchEvent(event)
56 | } else {
57 | link.click()
58 | }
59 | URL.revokeObjectURL(link.href)
60 | }
61 | }
62 |
63 | // returns an ordered list of objects based on a given key
64 | export const orderByKey = (keys, objects, keyName = "id") => {
65 | const objectMap = keyBy(objects, keyName)
66 | return compact(keys.map((key) => objectMap[key]))
67 | }
68 |
69 | // given a delta values from a mouse wheel, returns the equivalent layer delta.
70 | // when shift key is pressed, this is apparently "horizontal" scrolling by the browser;
71 | // for us, it means we'll grow in increments of 1
72 | export const scaleByWheel = (size, deltaX, deltaY) => {
73 | const signX = Math.sign(deltaX)
74 | const signY = Math.sign(deltaY)
75 |
76 | if (deltaX) {
77 | return size + 1 * signX
78 | } else {
79 | const scale = 1 + (Math.log(Math.abs(deltaY)) / 30) * signY
80 | let newSize = Math.max(roundP(size * scale, 0), 1)
81 |
82 | if (newSize === size) {
83 | // if the log scaled value isn't big enough to move the scale
84 | newSize = Math.max(signY + size, 1)
85 | }
86 |
87 | return newSize
88 | }
89 | }
90 |
91 | // convenience method to handle invocation of a function when present
92 | export const functionValue = (val, arg1, arg2) => {
93 | return typeof val === "function" ? val(arg1, arg2) : val
94 | }
95 |
96 | // shared logic via mixins
97 | export const mixin = (targetClass, mixinClass) => {
98 | Object.getOwnPropertyNames(mixinClass.prototype).forEach((name) => {
99 | if (name !== "constructor") {
100 | targetClass.prototype[name] = mixinClass.prototype[name]
101 | }
102 | })
103 | }
104 |
--------------------------------------------------------------------------------
/src/components/CheckboxOption.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Col from "react-bootstrap/Col"
3 | import Row from "react-bootstrap/Row"
4 | import Form from "react-bootstrap/Form"
5 | import S from "react-switch"
6 | const Switch = S.default ? S.default : S // Fix: https://github.com/vitejs/vite/issues/2139
7 |
8 | const CheckboxOption = ({
9 | options,
10 | optionKey,
11 | data,
12 | model,
13 | onChange,
14 | label = true,
15 | }) => {
16 | const option = options[optionKey]
17 | const visible =
18 | option.isVisible === undefined ? true : option.isVisible(model, data)
19 |
20 | const handleChange = (checked) => {
21 | let attrs = {}
22 | attrs[optionKey] = checked
23 |
24 | if (option.onChange !== undefined) {
25 | attrs = option.onChange(model, attrs, data)
26 | }
27 |
28 | onChange(attrs)
29 | }
30 |
31 | return (
32 |
33 |
34 | {label && (
35 |
39 | {option.title}
40 |
41 | )}
42 |
43 |
44 |
48 |
52 |
53 |
54 | )
55 | }
56 | export default CheckboxOption
57 |
--------------------------------------------------------------------------------
/src/components/DropdownOption.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Col from "react-bootstrap/Col"
3 | import Row from "react-bootstrap/Row"
4 | import Form from "react-bootstrap/Form"
5 | import Select from "react-select"
6 |
7 | const DropdownOption = ({
8 | options,
9 | optionKey,
10 | data,
11 | model,
12 | onChange,
13 | index,
14 | }) => {
15 | const option = options[optionKey]
16 | const currentChoice = data[optionKey]
17 |
18 | let choices = option.choices
19 | if (typeof choices === "function") {
20 | choices = choices()
21 | }
22 |
23 | choices = Array.isArray(choices)
24 | ? choices.map((choice) => {
25 | return { value: choice, label: choice }
26 | })
27 | : Object.keys(choices).map((key) => {
28 | return { value: key, label: choices[key] }
29 | })
30 | const currentLabel = (
31 | choices.find((choice) => choice.value == currentChoice) || choices[0]
32 | ).label
33 | const visible =
34 | option.isVisible === undefined ? true : option.isVisible(model, data)
35 |
36 | const handleChange = (choice) => {
37 | const value = choice.value
38 | let attrs = {}
39 | attrs[optionKey] = value
40 |
41 | if (option.onChange !== undefined) {
42 | attrs = option.onChange(model, attrs, data)
43 | }
44 |
45 | onChange(attrs)
46 | }
47 |
48 | return (
49 |
53 |
54 |
58 | {option.title}
59 |
60 |
61 |
62 |
63 | ({ ...base, zIndex: 9999 }) }}
70 | />
71 |
72 |
73 | )
74 | }
75 |
76 | export default DropdownOption
77 |
--------------------------------------------------------------------------------
/src/components/ModelOption.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import InputOption from "@/components/InputOption"
3 | import DropdownOption from "@/components/DropdownOption"
4 | import CheckboxOption from "@/components/CheckboxOption"
5 | import ToggleButtonOption from "@/components/ToggleButtonOption"
6 | import QuadrantButtonsOption from "@/components/QuadrantButtonsOption"
7 | import SliderOption from "@/components/SliderOption"
8 |
9 | const ModelOption = ({
10 | model,
11 | data,
12 | options,
13 | optionKey,
14 | onChange,
15 | label = true,
16 | }) => {
17 | const props = {
18 | model,
19 | data,
20 | options,
21 | optionKey,
22 | onChange,
23 | label,
24 | }
25 |
26 | switch (options[optionKey].type) {
27 | case "dropdown":
28 | return (
29 |
33 | )
34 | case "checkbox":
35 | return (
36 |
40 | )
41 | case "togglebutton":
42 | return (
43 |
47 | )
48 | case "quadrantbuttons":
49 | return (
50 |
54 | )
55 | case "slider":
56 | return (
57 |
61 | )
62 | default:
63 | return (
64 |
68 | )
69 | }
70 | }
71 |
72 | export default React.memo(ModelOption)
73 |
--------------------------------------------------------------------------------
/src/components/QuadrantButtonsOption.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Col from "react-bootstrap/Col"
3 | import Row from "react-bootstrap/Row"
4 | import Form from "react-bootstrap/Form"
5 | import ToggleButton from "react-bootstrap/ToggleButton"
6 | import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"
7 |
8 | const QuadrantButtonsOption = (props) => {
9 | const option = props.options[props.optionKey]
10 | const { data } = props
11 | const value = data[props.optionKey]
12 |
13 | const handleChange = (choices) => {
14 | let attrs = {}
15 | attrs[props.optionKey] = choices[choices.length - 1]
16 | props.onChange(attrs)
17 | }
18 |
19 | return (
20 |
21 |
25 | {option.title}
26 |
27 |
28 |
32 |
33 |
41 |
48 | upper left
49 |
50 |
57 | upper right
58 |
59 |
66 | lower left
67 |
68 |
75 | lower right
76 |
77 |
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | export default QuadrantButtonsOption
85 |
--------------------------------------------------------------------------------
/src/components/ToggleButtonOption.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Col from "react-bootstrap/Col"
3 | import Row from "react-bootstrap/Row"
4 | import Form from "react-bootstrap/Form"
5 | import ToggleButton from "react-bootstrap/ToggleButton"
6 | import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"
7 |
8 | const ToggleButtonOption = (props) => {
9 | const option = props.options[props.optionKey]
10 | const { data } = props
11 | const model = props.model || data
12 | const currentChoice = data[props.optionKey]
13 | const visible =
14 | option.isVisible === undefined ? true : option.isVisible(model, data)
15 |
16 | const handleChange = (choice) => {
17 | let attrs = {}
18 | attrs[props.optionKey] = choice
19 |
20 | if (option.onChange !== undefined) {
21 | attrs = option.onChange(model, attrs, data)
22 | }
23 |
24 | props.onChange(attrs)
25 | }
26 |
27 | return (
28 |
29 |
30 | {option.title}
31 |
32 |
33 |
34 |
43 | {option.choices.map((choice) => {
44 | return (
45 |
51 | {choice}
52 |
53 | )
54 | })}
55 |
56 |
57 |
58 | )
59 | }
60 |
61 | export default ToggleButtonOption
62 |
--------------------------------------------------------------------------------
/src/features/app/About.scss:
--------------------------------------------------------------------------------
1 | .tagline {
2 | font-size: 1.3em;
3 | font-weight: bold;
4 | }
5 |
6 | .pattern-preview {
7 | width: 300px;
8 | padding: 20px;
9 |
10 | @media (max-width: 991px) {
11 | width: 100%;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/src/features/app/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react"
2 | import Tab from "react-bootstrap/Tab"
3 | import { Provider } from "react-redux"
4 | import { ToastContainer } from "react-toastify"
5 | import { ErrorBoundary } from "react-error-boundary"
6 | import PreviewManager from "@/features/preview/PreviewManager"
7 | import Header from "./Header"
8 | import About from "./About"
9 | import Sidebar from "./Sidebar"
10 | import store from "./store"
11 | import ErrorFallback from "./ErrorFallback"
12 | import "./App.scss"
13 |
14 | const App = () => {
15 | const [eventKey, setEventKey] = useState("patterns")
16 |
17 | return (
18 |
19 |
20 |
21 |
27 |
31 |
32 |
36 |
37 |
38 |
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | )
64 | }
65 |
66 | export default App
67 |
--------------------------------------------------------------------------------
/src/features/app/App.scss:
--------------------------------------------------------------------------------
1 | @import 'bootstrap/dist/css/bootstrap.min.css';
2 | @import 'react-toastify/dist/ReactToastify.css';
3 | @import './bootstrap.scss';
4 |
5 | .react-tooltip {
6 | z-index: 9999;
7 | }
8 |
9 | .App {
10 | background-color: #eee;
11 | }
12 |
13 | @media (min-width: 992px) {
14 | .full-page {
15 | height: calc(100vh - 64px);
16 | overflow: auto;
17 |
18 | .tab-content {
19 | height: 100%;
20 | }
21 |
22 | .tab-pane {
23 | height: 100%;
24 | }
25 | }
26 |
27 | .full-page-tab {
28 | height: calc(100vh - 114px);
29 | overflow: auto;
30 | }
31 | }
32 |
33 | @keyframes App-logo-spin {
34 | from { transform: rotate(0deg); }
35 | to { transform: rotate(360deg); }
36 | }
37 |
38 | #sidebar {
39 | width: 550px;
40 | padding-top: 0.5rem;
41 |
42 | @media (max-width: 991px) {
43 | width: 100%;
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/features/app/App.test.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { createRoot } from "react-dom/client"
3 | import App from "./App"
4 | import "core-js"
5 | import Konva from "konva"
6 |
7 | Konva.isBrowser = false
8 |
9 | it("renders without crashing", () => {
10 | const div = document.createElement("div")
11 | const root = createRoot(div)
12 | root.render( )
13 | })
14 |
--------------------------------------------------------------------------------
/src/features/app/ErrorFallback.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import Container from "react-bootstrap/Container"
3 | import Row from "react-bootstrap/Row"
4 | import Col from "react-bootstrap/Col"
5 | import Button from "react-bootstrap/Button"
6 | import Alert from "react-bootstrap/Alert"
7 | import { RiEmotionUnhappyLine } from "react-icons/ri"
8 | import { useDispatch } from "react-redux"
9 |
10 | const ErrorFallback = ({ error }) => {
11 | const dispatch = useDispatch()
12 |
13 | const handleResetPattern = () => {
14 | dispatch({ type: "RESET_PATTERN" })
15 | window.location.reload()
16 | }
17 |
18 | const handleResetAll = () => {
19 | dispatch({ type: "RESET_ALL" })
20 | window.location.reload()
21 | }
22 |
23 | return (
24 |
28 |
29 |
35 |
36 | Sorry! Something went wrong.
37 |
38 | {error.message}
39 |
40 |
44 |
45 |
50 |
55 | Reset pattern
56 |
57 |
58 |
62 |
63 | First try resetting your pattern. If you are getting this
64 | error while trying to open a saved pattern, there is likely
65 | something wrong with the file.
66 |
67 |
68 |
73 |
78 | Reset all
79 |
80 |
81 |
85 |
86 | As a last resort, you can reset all settings. If you think
87 | you've found an issue with Sandify, please let us know on{" "}
88 | GitHub
89 | .
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
100 | export default ErrorFallback
101 |
--------------------------------------------------------------------------------
/src/features/app/Header.scss:
--------------------------------------------------------------------------------
1 | .header {
2 | background-color: #2983BA !important;
3 | color: white;
4 |
5 | .navbar-nav > a {
6 | color: #e1e1e1;
7 | }
8 |
9 | .nav-item.dropdown > a {
10 | color: white;
11 | }
12 |
13 | .navbar-nav .nav-link.active, .navbar-nav .nav-link.show {
14 | color: white;
15 | font-weight: bold;
16 | }
17 |
18 | #file-dropdown {
19 | font-weight: normal !important;
20 | }
21 |
22 | h1 {
23 | font-size: 2rem;
24 | color: white;
25 | }
26 | }
27 |
28 | .app-logo {
29 | height: 1.8rem;
30 | }
31 |
--------------------------------------------------------------------------------
/src/features/app/LICENSE:
--------------------------------------------------------------------------------
1 | Logo License for Sandify
2 |
3 | The logo file(s) located in this directory (e.g., logo.svg) are Copyright (c) 2025
4 | Bob Carmichael.
5 |
6 | All rights reserved.
7 |
8 | These logo assets are not licensed under the main project license. They may not be
9 | modified under any circumstances.
10 |
11 | The logo may be redistributed for non-commercial purposes, provided it is not
12 | altered and not used in a way that suggests endorsement.
13 |
14 | We reserve the right to relicense or trademark this logo in the future.
15 |
--------------------------------------------------------------------------------
/src/features/app/Notice.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import "./Notice.scss"
3 |
4 | const Notice = () => {
5 | return (
6 |
14 | )
15 | }
16 |
17 | export default Notice
18 |
--------------------------------------------------------------------------------
/src/features/app/Notice.scss:
--------------------------------------------------------------------------------
1 | .notice {
2 | background-color: #58abf0;
3 | padding: 0.4rem;
4 | color: black;
5 | text-align: center;
6 |
7 | a {
8 | color: black
9 | }
10 |
11 | p {
12 | font-size: 1.2rem;
13 | margin: 0rem 0 0 0;
14 | }
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/src/features/app/Sidebar.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import Tab from "react-bootstrap/Tab"
3 | import Tabs from "react-bootstrap/Tabs"
4 | import { useDispatch, useSelector } from "react-redux"
5 | import MachineManager from "@/features/machines/MachineManager"
6 | import LayerManager from "@/features/layers/LayerManager"
7 | import PreviewStats from "@/features/preview/PreviewStats"
8 | import { selectSelectedLayer } from "@/features/layers/layersSlice"
9 | import { loadFont, supportedFonts } from "@/features/fonts/fontsSlice"
10 | import { loadImage, selectAllImages } from "@/features/images/imagesSlice"
11 |
12 | const Sidebar = () => {
13 | const dispatch = useDispatch()
14 | const layer = useSelector(selectSelectedLayer)
15 | const images = useSelector(selectAllImages)
16 |
17 | useEffect(() => {
18 | Object.keys(supportedFonts).forEach((url) => dispatch(loadFont(url)))
19 | images.forEach((image) =>
20 | dispatch(loadImage({ imageId: image.id, imageSrc: image.src })),
21 | )
22 | }, [dispatch])
23 |
24 | if (layer) {
25 | return (
26 |
27 |
32 |
33 |
34 |
35 |
40 |
41 |
42 |
43 |
48 |
49 |
50 |
51 | )
52 | } else {
53 | return
54 | }
55 | }
56 |
57 | export default React.memo(Sidebar)
58 |
--------------------------------------------------------------------------------
/src/features/app/appSlice.js:
--------------------------------------------------------------------------------
1 | export const SANDIFY_VERSION = "1.1.1" // Also change the version in package.json.
2 | export const selectState = (state) => state
3 | export const selectAppState = (state) => state.app
4 |
--------------------------------------------------------------------------------
/src/features/app/bootstrap.scss:
--------------------------------------------------------------------------------
1 | /**
2 | Bootstrap 5 overrides
3 | **/
4 | h2 {
5 | font-size: 1.6rem;
6 | }
7 |
8 | h3 {
9 | font-size: 1.2rem;
10 | }
11 |
12 | .navbar {
13 | --bs-navbar-toggler-icon-bg: url("data:image/svg+xml, ") !important;
14 | }
15 |
16 | .navbar-toggler {
17 | border-color: lightgray;
18 | }
19 |
20 | @media (max-width: 991px) {
21 | .navbar-brand {
22 | flex-grow: 1;
23 | }
24 | }
25 |
26 | .accordion-header {
27 | h3 {
28 | margin: 0;
29 | font-size: 1.25rem;
30 | }
31 | }
32 |
33 | .accordion-button {
34 | background-color: #eee !important;
35 | }
36 |
37 | .tab-content {
38 | background-color: white;
39 | }
40 |
41 | .list-group-item.active {
42 | z-index: auto;
43 | background-color: lighten(#2983BA, 5%);
44 | }
45 |
46 | .list-group-item.selected {
47 | background-color: #d2d2d2;
48 | }
49 |
50 | .btn-group {
51 | .btn-light:focus, .btn-light.focus {
52 | background-color: #f8f9fa;
53 | }
54 |
55 | .btn {
56 | box-shadow: none !important;
57 | }
58 | }
59 |
60 | .btn-light {
61 | background-color: white;
62 | border-color: transparent !important;
63 | box-shadow: none !important;
64 |
65 | &:focus-visible {
66 | box-shadow: 0 0 0 0.2rem rgb(216 217 219 / 50%) !important;
67 | border-color: #dae0e5 !important;
68 | }
69 | }
70 |
71 | .btn-secondary {
72 | background-color: #e9ecef;
73 | color: #495057 !important;
74 | border: none;
75 |
76 | &:hover {
77 | background-color: darken(#e9ecef, 5%);
78 | }
79 |
80 | &.focus, &:focus {
81 | background-color: darken(#e9ecef, 5%);
82 | }
83 |
84 | &:not(:disabled):not(.disabled).active,
85 | &:not(:disabled):not(.disabled):active {
86 | background-color: darken(#e9ecef, 5%);
87 | }
88 | }
89 |
90 | .border-secondary {
91 | border-color: #adb5bd !important;
92 | }
93 |
94 | .no-select {
95 | -webkit-user-select: none; /* Safari */
96 | -moz-user-select: none; /* Firefox */
97 | -ms-user-select: none; /* IE10+/Edge */
98 | user-select: none; /* Standard */
99 | }
100 |
--------------------------------------------------------------------------------
/src/features/app/reactGA.js:
--------------------------------------------------------------------------------
1 | import ReactGA from "react-ga4"
2 |
3 | ReactGA.initialize("UA-126702426-1")
4 |
--------------------------------------------------------------------------------
/src/features/app/rootSlice.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from "redux"
2 | import machinesReducer from "@/features/machines/machinesSlice"
3 | import exporterReducer from "@/features/export/exporterSlice"
4 | import previewReducer from "@/features/preview/previewSlice"
5 | import fontsReducer from "@/features/fonts/fontsSlice"
6 | import imagesReducer from "@/features/images/imagesSlice"
7 | import layersReducer from "@/features/layers/layersSlice"
8 | import effectsReducer from "@/features/effects/effectsSlice"
9 | import fileReducer from "@/features/file/fileSlice"
10 | import { saveState } from "@/common/localStorage"
11 |
12 | const combinedReducer = combineReducers({
13 | effects: effectsReducer,
14 | exporter: exporterReducer,
15 | file: fileReducer,
16 | fonts: fontsReducer,
17 | images: imagesReducer,
18 | layers: layersReducer,
19 | machines: machinesReducer,
20 | preview: previewReducer,
21 | })
22 |
23 | const resetPattern = (state, action) => {
24 | const newState = JSON.parse(JSON.stringify(state)) // deep copy
25 |
26 | newState.layers = undefined
27 | newState.effects = undefined
28 | newState.images = undefined
29 | newState.preview.zoom = 1.0
30 | newState.preview.sliderValue = 0.0
31 |
32 | return combinedReducer(newState, action)
33 | }
34 |
35 | const resetAll = (state, action) => {
36 | saveState({}) // explicitly clear state in local storage
37 | return combinedReducer(undefined, action)
38 | }
39 |
40 | const loadPattern = (state, action) => {
41 | const { layers, effects, images } = action.payload
42 | const newState = JSON.parse(JSON.stringify(state)) // deep copy
43 |
44 | newState.layers = layers
45 | newState.effects = effects
46 | newState.images = images
47 |
48 | const id = newState.layers.ids[0]
49 | newState.layers.current = id
50 | newState.layers.selected = id
51 | newState.effects.selected = newState.layers.entities[id].effectIds[0]
52 | newState.preview.sliderValue = 0.0
53 | newState.preview.zoom = 1.0
54 |
55 | return combinedReducer(newState, action)
56 | }
57 |
58 | const rootReducer = (state, action) => {
59 | if (action.type === "RESET_ALL") {
60 | return resetAll(state, action)
61 | } else if (action.type === "RESET_PATTERN") {
62 | return resetPattern(state, action)
63 | } else if (action.type === "LOAD_PATTERN") {
64 | return loadPattern(state, action)
65 | }
66 |
67 | return combinedReducer(state, action)
68 | }
69 |
70 | export default rootReducer
71 |
--------------------------------------------------------------------------------
/src/features/app/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from "@reduxjs/toolkit"
2 | import { loadState, saveState } from "@/common/localStorage"
3 | import { resetLogCounts } from "@/common/debugging"
4 | import SandifyImporter from "@/features/file/SandifyImporter"
5 | import rootReducer from "./rootSlice"
6 |
7 | // by default, state is always persisted in local storage
8 | const usePersistedState = true
9 | const persistState = true
10 |
11 | let persistedState =
12 | typeof jest === "undefined" && usePersistedState
13 | ? loadState() || undefined
14 | : undefined
15 |
16 | // support a URL-based reset as a last resort
17 | const params = new URLSearchParams(window.location.search)
18 | const reset = params.get("reset")
19 |
20 | // reset some values
21 | if (reset === "all") {
22 | persistedState = undefined
23 | } else {
24 | if (persistedState) {
25 | const importer = new SandifyImporter()
26 | try {
27 | // double JSON parsing ensures it's valid JSON before we try to import it
28 | persistedState = importer.import(JSON.stringify(persistedState))
29 | persistedState.fonts.loaded = false
30 | persistedState.images.loaded = false
31 | } catch (err) {
32 | persistedState = undefined
33 | }
34 | }
35 | }
36 |
37 | const store = configureStore({
38 | reducer: rootReducer,
39 | preloadedState: persistedState,
40 | })
41 |
42 | if (persistState) {
43 | store.subscribe(() => {
44 | const state = store.getState()
45 | if (state.fonts.loaded) {
46 | saveState(state)
47 | resetLogCounts()
48 |
49 | if (reset) {
50 | window.location.href = window.location.pathname
51 | }
52 | }
53 | })
54 | }
55 |
56 | export default store
57 |
--------------------------------------------------------------------------------
/src/features/effects/CopyEffect.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react"
2 | import Button from "react-bootstrap/Button"
3 | import Modal from "react-bootstrap/Modal"
4 | import Col from "react-bootstrap/Col"
5 | import Row from "react-bootstrap/Row"
6 | import Form from "react-bootstrap/Form"
7 | import { useDispatch, useSelector } from "react-redux"
8 | import { selectSelectedEffect } from "./effectsSlice"
9 | import { addEffect } from "@/features/layers/layersSlice"
10 |
11 | const CopyEffect = ({ toggleModal, showModal }) => {
12 | const dispatch = useDispatch()
13 | const selectedEffect = useSelector(selectSelectedEffect)
14 | const namedInputRef = useRef(null)
15 | const [copyEffectName, setCopyEffectName] = useState(
16 | selectedEffect?.name || "",
17 | )
18 |
19 | useEffect(() => {
20 | setCopyEffectName(selectedEffect?.name || "")
21 | }, [selectedEffect])
22 |
23 | const handleChangeCopyEffectName = (event) => {
24 | setCopyEffectName(event.target.value)
25 | }
26 |
27 | const handleNameFocus = (event) => {
28 | event.target.select()
29 | }
30 |
31 | const handleCopyEffect = (event) => {
32 | event.preventDefault()
33 | dispatch(
34 | addEffect({
35 | id: selectedEffect.layerId,
36 | effect: {
37 | ...selectedEffect,
38 | name: copyEffectName,
39 | },
40 | }),
41 | )
42 | toggleModal()
43 | }
44 |
45 | const handleInitialFocus = () => {
46 | namedInputRef.current.focus()
47 | }
48 |
49 | return (
50 |
55 |
56 | Copy {selectedEffect?.name || ""}
57 |
58 |
59 |
91 |
92 | )
93 | }
94 |
95 | export default CopyEffect
96 |
--------------------------------------------------------------------------------
/src/features/effects/Effect.js:
--------------------------------------------------------------------------------
1 | import Model from "@/common/Model"
2 |
3 | const effectOptions = []
4 |
5 | export default class Effect extends Model {
6 | constructor(type, state) {
7 | super(type, state)
8 | this.dragPreview = false
9 | }
10 |
11 | // override as needed
12 | canChangeSize(state) {
13 | return false
14 | }
15 |
16 | // override as needed
17 | canRotate(state) {
18 | return false
19 | }
20 |
21 | // override as needed
22 | canMove(state) {
23 | return false
24 | }
25 |
26 | // override as needed; returns an array of Victor vertices that are used to
27 | // render a Konva transformer when the effect is selected
28 | getSelectionVertices(effect) {
29 | return []
30 | }
31 |
32 | // override as needed; returns an array of Victor vertices that are the result
33 | // of applying the effect to the layer
34 | getVertices(effect, layer, vertices) {
35 | return []
36 | }
37 |
38 | getOptions() {
39 | return effectOptions
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/features/effects/EffectLayer.js:
--------------------------------------------------------------------------------
1 | import { getEffect } from "@/features/effects/effectFactory"
2 |
3 | export const effectOptions = {
4 | name: {
5 | title: "Name",
6 | type: "text",
7 | },
8 | x: {
9 | title: "X",
10 | inline: true,
11 | isVisible: (model, state) => {
12 | return model.canMove(state)
13 | },
14 | },
15 | y: {
16 | title: "Y",
17 | inline: true,
18 | isVisible: (model, state) => {
19 | return model.canMove(state)
20 | },
21 | },
22 | width: {
23 | title: "W",
24 | min: 1,
25 | inline: true,
26 | isVisible: (model, state) => {
27 | return model.canChangeSize(state)
28 | },
29 | onChange: (model, changes, state) => {
30 | if (state.maintainAspectRatio) {
31 | changes.height = changes.width
32 | } else {
33 | changes.aspectRatio = changes.width / state.height
34 | }
35 | return changes
36 | },
37 | },
38 | height: {
39 | title: "H",
40 | min: 1,
41 | inline: true,
42 | onChange: (model, changes, state) => {
43 | if (state.maintainAspectRatio) {
44 | changes.width = changes.height
45 | } else {
46 | changes.aspectRatio = state.width / changes.height
47 | }
48 | return changes
49 | },
50 | },
51 | maintainAspectRatio: {
52 | title: "Lock aspect ratio",
53 | type: "checkbox",
54 | },
55 | rotation: {
56 | title: "Rotate (degrees)",
57 | inline: true,
58 | isVisible: (model, state) => {
59 | return model.canRotate(state)
60 | },
61 | },
62 | }
63 |
64 | export default class EffectLayer {
65 | constructor(type) {
66 | this.model = getEffect(type)
67 | }
68 |
69 | getInitialState(layer, layerVertices) {
70 | const state = {
71 | ...this.model.getInitialState(layer, layerVertices),
72 | ...{
73 | type: this.model.type,
74 | visible: true,
75 | name: this.model.label,
76 | },
77 | }
78 |
79 | if (this.model.canChangeSize(state)) {
80 | state.aspectRatio = 1.0
81 | }
82 |
83 | if (this.model.canMove(state) && state.x === undefined) {
84 | state.x = 0
85 | state.y = 0
86 | }
87 |
88 | if (this.model.canRotate(state) && state.rotation === undefined) {
89 | state.rotation = 0
90 | }
91 |
92 | return state
93 | }
94 |
95 | getOptions() {
96 | return effectOptions
97 | }
98 |
99 | getVertices(effect, layer, vertices) {
100 | return this.model.getVertices(effect, layer, vertices)
101 | }
102 |
103 | getSelectionVertices(effect) {
104 | return this.model.getSelectionVertices(effect)
105 | }
106 |
107 | // used to preserve hidden attributes when loading from a file
108 | getHiddenAttrs() {
109 | return ["layerId"]
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/features/effects/Fisheye.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import * as d3Fisheye from "d3-fisheye"
3 | import { circle, subsample } from "@/common/geometry"
4 | import Effect from "./Effect"
5 |
6 | const options = {
7 | fisheyeSubsample: {
8 | title: "Subsample points",
9 | type: "checkbox",
10 | },
11 | fisheyeDistortion: {
12 | title: "Distortion",
13 | min: -2,
14 | max: 40,
15 | step: 1,
16 | },
17 | }
18 |
19 | export default class Fisheye extends Effect {
20 | constructor() {
21 | super("fisheye")
22 | this.label = "Fisheye"
23 | }
24 |
25 | canMove(state) {
26 | return true
27 | }
28 |
29 | canChangeSize(state) {
30 | return true
31 | }
32 |
33 | canChangeAspectRatio(state) {
34 | return false
35 | }
36 |
37 | getInitialState() {
38 | return {
39 | ...super.getInitialState(),
40 | ...{
41 | fisheyeDistortion: 3,
42 | fisheyeSubsample: true,
43 | width: 100,
44 | height: 100,
45 | maintainAspectRatio: true,
46 | },
47 | }
48 | }
49 |
50 | getSelectionVertices(effect) {
51 | return circle(effect.width / 2)
52 | }
53 |
54 | getVertices(effect, layer, vertices) {
55 | if (effect.fisheyeSubsample) {
56 | vertices = subsample(vertices, 2.0)
57 | }
58 |
59 | const radius = effect.width / 2
60 | const fisheye = d3Fisheye
61 | .radial()
62 | .radius(radius)
63 | .distortion(effect.fisheyeDistortion / 2)
64 | fisheye.focus([effect.x, effect.y])
65 |
66 | return vertices.map((vertex) => {
67 | const warped = fisheye([vertex.x, vertex.y])
68 | return new Victor(warped[0], warped[1])
69 | })
70 | }
71 |
72 | getOptions() {
73 | return options
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/features/effects/Mask.js:
--------------------------------------------------------------------------------
1 | import Effect from "./Effect"
2 | import Victor from "victor"
3 | import { rotate, offset, circle } from "@/common/geometry"
4 | import PolarMachine from "@/features/machines/PolarMachine"
5 | import RectMachine from "@/features/machines/RectMachine"
6 | import PolarInvertedMachine from "@/features/machines/PolarInvertedMachine"
7 | import RectInvertedMachine from "@/features/machines/RectInvertedMachine"
8 |
9 | const options = {
10 | maskMachine: {
11 | title: "Mask shape",
12 | type: "togglebutton",
13 | choices: ["rectangle", "circle"],
14 | onChange: (model, changes, state) => {
15 | if (changes.maskMachine) {
16 | if (changes.maskMachine === "circle") {
17 | changes.rotation = 0
18 |
19 | const size = Math.max(state.width, state.height)
20 | changes.height = size
21 | changes.width = size
22 | changes.maintainAspectRatio = true
23 | } else {
24 | changes.maintainAspectRatio = false
25 | }
26 | }
27 |
28 | return changes
29 | },
30 | },
31 | maskMinimizeMoves: {
32 | title: "Minimize perimeter moves",
33 | type: "checkbox",
34 | },
35 | maskInvert: {
36 | title: "Invert",
37 | type: "checkbox",
38 | },
39 | maskBorder: {
40 | title: "Draw border",
41 | type: "checkbox",
42 | },
43 | }
44 |
45 | export default class Mask extends Effect {
46 | constructor() {
47 | super("mask")
48 | this.label = "Mask"
49 | this.randomizable = false
50 | }
51 |
52 | canMove(state) {
53 | return true
54 | }
55 |
56 | canRotate(state) {
57 | return state.maskMachine != "circle"
58 | }
59 |
60 | canChangeAspectRatio(state) {
61 | return state.maskMachine != "circle"
62 | }
63 |
64 | canChangeSize(state) {
65 | return true
66 | }
67 |
68 | getInitialState() {
69 | return {
70 | ...super.getInitialState(),
71 | ...{
72 | width: 100,
73 | height: 100,
74 | maskMinimizeMoves: false,
75 | maskMachine: "rectangle",
76 | maskInvert: false,
77 | maskBorder: false,
78 | },
79 | }
80 | }
81 |
82 | getOptions() {
83 | return options
84 | }
85 |
86 | getSelectionVertices(effect) {
87 | const { width, height } = effect
88 |
89 | if (effect.maskMachine === "circle") {
90 | return circle(width / 2)
91 | } else {
92 | return [
93 | new Victor(-width / 2, height / 2),
94 | new Victor(width / 2, height / 2),
95 | new Victor(width / 2, -height / 2),
96 | new Victor(-width / 2, -height / 2),
97 | new Victor(-width / 2, height / 2),
98 | ]
99 | }
100 | }
101 |
102 | getVertices(effect, layer, vertices) {
103 | vertices = vertices.map((vertex) => {
104 | return rotate(offset(vertex, -effect.x, -effect.y), effect.rotation)
105 | })
106 |
107 | if (!effect.dragging) {
108 | const machineClass =
109 | effect.maskMachine === "circle"
110 | ? effect.maskInvert
111 | ? PolarInvertedMachine
112 | : PolarMachine
113 | : effect.maskInvert
114 | ? RectInvertedMachine
115 | : RectMachine
116 |
117 | const machine = new machineClass({
118 | minX: 0,
119 | maxX: effect.width,
120 | minY: 0,
121 | maxY: effect.height,
122 | minimizeMoves: effect.maskMinimizeMoves,
123 | maxRadius: effect.width / 2,
124 | mask: true,
125 | })
126 | vertices = machine.polish(vertices, { border: effect.maskBorder })
127 | }
128 |
129 | return vertices.map((vertex) => {
130 | return offset(rotate(vertex, -effect.rotation), effect.x, effect.y)
131 | })
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/features/effects/ProgramCode.js:
--------------------------------------------------------------------------------
1 | import Effect from "./Effect"
2 |
3 | const options = {
4 | programCodePre: {
5 | title: "Start code",
6 | type: "textarea",
7 | },
8 | programCodePost: {
9 | title: "End code",
10 | type: "textarea",
11 | },
12 | }
13 |
14 | export default class ProgramCode extends Effect {
15 | constructor() {
16 | super("programCode")
17 | this.label = "Program code"
18 | this.description =
19 | "When exporting the pattern to a file, the provided program code is added before and/or after this layer is rendered."
20 | }
21 |
22 | canChangeSize(state) {
23 | return false
24 | }
25 |
26 | canRotate(state) {
27 | return false
28 | }
29 |
30 | canMove(state) {
31 | return false
32 | }
33 |
34 | getInitialState() {
35 | return {
36 | ...super.getInitialState(),
37 | ...{
38 | programCodePre: "",
39 | programCodePost: "",
40 | },
41 | }
42 | }
43 |
44 | getVertices(effect, layer, vertices) {
45 | return vertices
46 | }
47 |
48 | getOptions() {
49 | return options
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/features/effects/Transformer.js:
--------------------------------------------------------------------------------
1 | import {
2 | resizeVertices,
3 | dimensions,
4 | centerOnOrigin,
5 | findBounds,
6 | } from "@/common/geometry"
7 | import Effect from "./Effect"
8 |
9 | const options = {}
10 |
11 | export default class Transformer extends Effect {
12 | constructor() {
13 | super("transformer")
14 | this.dragPreview = true
15 | this.label = "Move and resize"
16 | this.randomizable = false
17 | }
18 |
19 | canMove(state) {
20 | return true
21 | }
22 |
23 | canRotate(state) {
24 | return true
25 | }
26 |
27 | canChangeSize(state) {
28 | return true
29 | }
30 |
31 | getInitialState(layer, layerVertices) {
32 | if (layerVertices) {
33 | // reverse rotation before calculating bounds
34 | const vertices = [...layerVertices]
35 | vertices.forEach((vertex) => {
36 | vertex.rotateDeg(layer.rotation)
37 | })
38 |
39 | const bounds = findBounds(layerVertices)
40 | const { width, height } = dimensions(vertices)
41 | const offsetX = (bounds[1].x + bounds[0].x) / 2
42 | const offsetY = (bounds[1].y + bounds[0].y) / 2
43 |
44 | return {
45 | ...super.getInitialState(),
46 | ...{
47 | type: "transformer",
48 | width,
49 | height,
50 | x: offsetX - layer.x,
51 | y: offsetY - layer.y,
52 | },
53 | }
54 | } else {
55 | // imported; values will be supplied
56 | return {
57 | ...super.getInitialState(),
58 | ...{
59 | type: "transformer",
60 | width: 0,
61 | height: 0,
62 | x: 0,
63 | y: 0,
64 | },
65 | }
66 | }
67 | }
68 |
69 | getVertices(effect, layer, vertices) {
70 | this.state = effect
71 | this.effect = effect
72 | this.vertices = [...vertices]
73 |
74 | resizeVertices(this.vertices, effect.width, effect.height, true)
75 | centerOnOrigin(this.vertices)
76 | this.transform()
77 |
78 | return vertices
79 | }
80 |
81 | transform() {
82 | const { x, y, rotation } = this.state
83 |
84 | this.vertices.forEach((vertex) => {
85 | vertex.rotateDeg(-rotation)
86 | vertex.addX({ x }).addY({ y })
87 | })
88 | }
89 |
90 | getOptions() {
91 | return options
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/src/features/effects/Voronoi.js:
--------------------------------------------------------------------------------
1 | import { uniqBy } from "lodash"
2 | import seedrandom from "seedrandom"
3 | import noise from "@/common/noise"
4 | import { mixin } from "@/common/util"
5 | import Effect from "./Effect"
6 | import { VoronoiMixin } from "@/common/voronoi"
7 | import {
8 | centerOnOrigin,
9 | dimensions,
10 | shiftToFirstQuadrant,
11 | cloneVertices,
12 | vertexRoundP,
13 | } from "@/common/geometry"
14 |
15 | export const voronoiOptions = {
16 | seed: {
17 | title: "Random seed",
18 | min: 1,
19 | randomMax: 1000,
20 | },
21 | voronoiPolygon: {
22 | title: "Polygon",
23 | type: "togglebutton",
24 | choices: ["voronoi", "delaunay"],
25 | },
26 | }
27 |
28 | export default class Voronoi extends Effect {
29 | constructor() {
30 | super("voronoi")
31 | this.label = "Voronoi"
32 | this.description =
33 | "A Voronoi diagram divides a space into regions based on a set of seed points. Each region contains all the points that are closer to its seed point than to any other seed point."
34 | this.shouldCache = true
35 | }
36 |
37 | canMove(state) {
38 | return false
39 | }
40 |
41 | canRotate(state) {
42 | return false
43 | }
44 |
45 | canChangeSize(state) {
46 | return false
47 | }
48 |
49 | getInitialState() {
50 | return {
51 | ...super.getInitialState(),
52 | ...{
53 | voronoiPolygon: "voronoi",
54 | seed: 1,
55 | },
56 | }
57 | }
58 |
59 | getVertices(effect, layer, vertices) {
60 | const { seed, voronoiPolygon } = effect
61 | const { width, height } = dimensions(vertices)
62 | this.rng = seedrandom(seed)
63 | noise.seed(seed)
64 |
65 | const mappedVertices = uniqBy(
66 | shiftToFirstQuadrant(cloneVertices(vertices)).map((vertex) =>
67 | vertexRoundP(vertex, 2),
68 | ),
69 | (vertex) => vertex.toString(),
70 | )
71 | const points = this.generatePointsFromVertices(mappedVertices)
72 | this.graph = this.buildGraph(points, voronoiPolygon, width, height)
73 |
74 | this.vertices = []
75 | this.visited = {}
76 |
77 | const start = this.getStartNode(this.graph, width, height)
78 | if (start) this.walkNode(start)
79 |
80 | return centerOnOrigin(this.vertices)
81 | }
82 |
83 | getOptions() {
84 | return voronoiOptions
85 | }
86 |
87 | generatePointsFromVertices(vertices) {
88 | return vertices.map((vertex) => [vertex.x, vertex.y])
89 | }
90 | }
91 |
92 | mixin(Voronoi, VoronoiMixin)
93 |
--------------------------------------------------------------------------------
/src/features/effects/effectFactory.js:
--------------------------------------------------------------------------------
1 | import FineTuning from "./FineTuning"
2 | import Fisheye from "./Fisheye"
3 | import Loop from "./Loop"
4 | import Mask from "./Mask"
5 | import Noise from "./noise/Noise"
6 | import ProgramCode from "./ProgramCode"
7 | import Track from "./Track"
8 | import Transformer from "./Transformer"
9 | import Warp from "./Warp"
10 | // import Voronoi from "./Voronoi"
11 |
12 | export const effectFactory = {
13 | loop: Loop,
14 | transformer: Transformer,
15 | fisheye: Fisheye,
16 | fineTuning: FineTuning,
17 | mask: Mask,
18 | programCode: ProgramCode,
19 | noise: Noise,
20 | track: Track,
21 | warp: Warp,
22 | // too slow; disabling until we implement worker-based vertex computation
23 | // voronoi: Voronoi
24 | }
25 |
26 | export const getEffect = (type, ...args) => {
27 | return new effectFactory[type](args)
28 | }
29 |
30 | export const getDefaultEffectType = () => {
31 | return localStorage.getItem("defaultEffect") || "mask"
32 | }
33 |
34 | export const getDefaultEffect = () => {
35 | return getEffect(getDefaultEffectType())
36 | }
37 |
38 | export const getEffectSelectOptions = () => {
39 | const types = Object.keys(effectFactory)
40 |
41 | return types.map((type) => {
42 | return { value: type, label: getEffect(type).label }
43 | })
44 | }
45 |
--------------------------------------------------------------------------------
/src/features/effects/noise/LICENSE:
--------------------------------------------------------------------------------
1 | This project is no longer maintained by the contributor (deleted from GitHub), and was in turn
2 | adapted from an older project, https://github.com/josephg/noisejs.
3 |
4 | MIT License
5 |
6 | Copyright (c) 2018 James Walker
7 |
8 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
9 | associated documentation files (the "Software"), to deal in the Software without restriction,
10 | including without limitation the rights to use, copy, modify, merge, publish, distribute,
11 | sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in all copies or
15 | substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
18 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
20 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/features/effects/noise/Noise.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Effect from "../Effect"
3 | import noise from "@/common/noise"
4 | import { subsample } from "@/common/geometry"
5 |
6 | const options = {
7 | seed: {
8 | title: "Random seed",
9 | min: 1,
10 | randomMax: 1000,
11 | },
12 | noiseMagnification: {
13 | title: "Magnification",
14 | min: 1,
15 | max: 100,
16 | step: 1,
17 | isVisible: (layer, state) => {
18 | return state.noiseApplication !== "Linear"
19 | },
20 | },
21 | noiseAmplitude: {
22 | title: "Amplitude",
23 | min: 0,
24 | max: 20,
25 | step: 1,
26 | },
27 | noiseType: {
28 | title: "Noise type",
29 | type: "togglebutton",
30 | choices: ["Perlin", "Simplex"],
31 | },
32 | noiseApplication: {
33 | title: "Application",
34 | type: "togglebutton",
35 | choices: ["Contour", "Linear"],
36 | },
37 | }
38 |
39 | export default class Noise extends Effect {
40 | constructor() {
41 | super("noise")
42 | this.label = "Noise"
43 | }
44 |
45 | canRotate(state) {
46 | return false
47 | }
48 |
49 | canChangeSize(state) {
50 | return false
51 | }
52 |
53 | getInitialState() {
54 | return {
55 | ...super.getInitialState(),
56 | ...{
57 | seed: 1,
58 | noiseAmplitude: 4,
59 | noiseMagnification: 58,
60 | noiseType: "Simplex",
61 | noiseApplication: "Contour",
62 | subsample: true,
63 | },
64 | }
65 | }
66 |
67 | getVertices(effect, layer, vertices) {
68 | if (effect.noiseAmplitude > 0) {
69 | noise.seed(effect.seed)
70 | vertices = subsample(vertices, 2.0)
71 |
72 | if (effect.noiseApplication === "Linear") {
73 | return this.applyLinearEffect(effect, vertices)
74 | } else {
75 | return this.applyRadialEffect(effect, vertices, this.contour)
76 | }
77 | } else {
78 | return vertices
79 | }
80 | }
81 |
82 | applyLinearEffect(effect, vertices) {
83 | return vertices.map((vertex) => {
84 | const a = this.octaveNoise(
85 | effect.noiseType,
86 | vertex.x,
87 | vertex.y,
88 | 2,
89 | effect.noiseAmplitude,
90 | )
91 | return new Victor(vertex.x + a, vertex.y + a)
92 | })
93 | }
94 |
95 | applyRadialEffect(effect, vertices, contourFn) {
96 | let periodDenominator =
97 | effect.noiseType === "Simplex"
98 | ? 100 + 6 * effect.noiseMagnification
99 | : 100 + effect.noiseMagnification
100 | if (periodDenominator === 0) periodDenominator = 1
101 | const period = 1 / periodDenominator
102 |
103 | return vertices.map((vertex) => {
104 | const v = this.noise(
105 | effect.noiseType,
106 | vertex.x * period,
107 | vertex.y * period,
108 | )
109 | const a = v * Math.PI * 2
110 | return contourFn(a * effect.noiseAmplitude, vertex)
111 | })
112 | }
113 |
114 | noise(noiseType, x, y) {
115 | return noiseType === "Simplex" ? noise.simplex2(x, y) : noise.perlin2(x, y)
116 | }
117 |
118 | octaveNoise(noiseType, x, y, octaves, persistence) {
119 | let total = 0
120 | let frequency = 1
121 | let amplitude = 1
122 |
123 | for (let i = 0; i < octaves; i++) {
124 | total += this.noise(noiseType, x * frequency, y * frequency) * amplitude
125 | amplitude *= persistence
126 | frequency *= 2
127 | }
128 |
129 | return total
130 | }
131 |
132 | contour(a, vertex) {
133 | return new Victor(vertex.x + Math.cos(a) * 5, vertex.y + Math.sin(a) * 5)
134 | }
135 |
136 | getOptions() {
137 | return options
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/features/export/GCodeExporter.js:
--------------------------------------------------------------------------------
1 | import Exporter from "./Exporter"
2 |
3 | export default class GCodeExporter extends Exporter {
4 | constructor(props) {
5 | super(props)
6 | this.fileExtension = ".gcode"
7 | this.label = "Gcode"
8 | this.commentChar = ";"
9 | this.offsetX = this.props.offsetX
10 | this.offsetY = this.props.offsetY
11 | }
12 |
13 | // collects stats for use in PRE and POST blocks
14 | collectStats(vertices) {
15 | return {
16 | minx: Math.min(...vertices.map((v) => v.x)),
17 | miny: Math.min(...vertices.map((v) => v.y)),
18 | maxx: Math.max(...vertices.map((v) => v.x)),
19 | maxy: Math.max(...vertices.map((v) => v.y)),
20 | startx: vertices[0].x,
21 | starty: vertices[0].y,
22 | endx: vertices[vertices.length - 1].x,
23 | endy: vertices[vertices.length - 1].y,
24 | }
25 | }
26 |
27 | // transforms vertices to be compatible with the GCode format
28 | transformVertices(vertices) {
29 | return vertices.map((vertex) => {
30 | return {
31 | ...vertex,
32 | x: vertex.x + this.offsetX,
33 | y: vertex.y + this.offsetY,
34 | }
35 | })
36 | }
37 |
38 | // provides a GCode machine instruction for a given vertex
39 | code(vertex) {
40 | return (
41 | "G1" +
42 | " X" +
43 | vertex.x.toFixed(this.digits) +
44 | " Y" +
45 | vertex.y.toFixed(this.digits)
46 | )
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/features/export/ScaraGCodeExporter.js:
--------------------------------------------------------------------------------
1 | import GCodeExporter from "./GCodeExporter"
2 | import { subsample, toThetaRho, toScaraGcode } from "@/common/geometry"
3 |
4 | export default class ScaraGCodeExporter extends GCodeExporter {
5 | constructor(props) {
6 | super(props)
7 | this.offsetX = 0
8 | this.offsetY = 0
9 | }
10 |
11 | // transforms vertices into a SCARA GCode format
12 | transformVertices(vertices, index, layers) {
13 | let theta, rawTheta
14 |
15 | if (index == 0) {
16 | theta = 0
17 | rawTheta = 0
18 | } else {
19 | // preserve previous theta value
20 | const prevVertices = layers[index - 1].vertices
21 | const last = prevVertices[prevVertices.length - 1]
22 | theta = last.theta
23 | rawTheta = last.rawTheta // already transformed
24 | }
25 |
26 | vertices = toScaraGcode(
27 | toThetaRho(
28 | subsample(vertices, 2.0),
29 | this.props.maxRadius,
30 | parseFloat(this.props.polarRhoMax),
31 | theta,
32 | rawTheta,
33 | ),
34 | parseFloat(this.props.unitsPerCircle),
35 | )
36 |
37 | return super.transformVertices(vertices, index, layers)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/features/export/SvgExporter.js:
--------------------------------------------------------------------------------
1 | import Exporter from "./Exporter"
2 | import { path } from "d3"
3 |
4 | const svgTrim = (svgString) => {
5 | // Based on the svg trim method from msurguy (MIT):
6 | // https://github.com/msurguy/svg-file-downloader/blob/7d3409f60ca58fef90003f24bae2a85fdf324eb3/svg-file-downloader.js#L9
7 | svgString = svgString.replace(
8 | /([+]?\d*\.\d{3,}([eE][+]?\d+)?)/g,
9 | function (x) {
10 | return (+x).toFixed(2)
11 | },
12 | )
13 | return svgString
14 | }
15 |
16 | export default class SvgExporter extends Exporter {
17 | constructor(props) {
18 | super(props)
19 | this.fileExtension = ".svg"
20 | this.label = "SVG"
21 | this.indentLevel = 2
22 | this.props.pre = this.props.post = "" // ignore props
23 | }
24 |
25 | exportCode() {
26 | const vertices = this.layers.map((layer) => layer.vertices).flat()
27 | const centeredVertices = vertices.map((vertex) => {
28 | return {
29 | ...vertex,
30 | x: vertex.x + this.props.width / 2,
31 | y: this.props.height - (vertex.y + this.props.height / 2),
32 | }
33 | })
34 | const svg = path()
35 |
36 | if (centeredVertices.length > 0) {
37 | const firstPoint = centeredVertices[0]
38 | svg.moveTo(firstPoint.x, firstPoint.y)
39 | }
40 |
41 | centeredVertices.forEach((vertex) => svg.lineTo(vertex.x, vertex.y))
42 | this.line(
43 | " pwidth:" +
44 | this.props.width +
45 | ";pheight:" +
46 | this.props.height +
47 | "; ",
48 | )
49 | this.line(" ')
54 | }
55 |
56 | header() {
57 | this.line('')
58 | this.line(
59 | '',
60 | )
61 | this.line(
62 | '',
77 | )
78 |
79 | this.line(" ")
80 | }
81 |
82 | footer() {
83 | this.line(" ")
84 | this.line(" ")
85 | }
86 |
87 | line(content = "", add = true) {
88 | if (add) {
89 | let padding = ""
90 |
91 | if (this.commenting) {
92 | padding = ""
93 |
94 | for (let i = 0; i < this.indentLevel; i++) {
95 | padding += " "
96 | }
97 |
98 | if (content) {
99 | this.lines.push(padding + "")
100 | } else {
101 | this.lines.push("")
102 | }
103 | } else {
104 | this.lines.push(content)
105 | }
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/features/export/ThetaRhoExporter.js:
--------------------------------------------------------------------------------
1 | import Exporter from "./Exporter"
2 | import { subsample, toThetaRho } from "@/common/geometry"
3 |
4 | export default class ThetaRhoExporter extends Exporter {
5 | constructor(props) {
6 | super(props)
7 | this.fileExtension = ".thr"
8 | this.label = "ThetaRho"
9 | this.commentChar = "#"
10 | this.digits = 5
11 | }
12 |
13 | // collects stats for use in PRE and POST blocks
14 | collectStats(vertices) {
15 | return {
16 | mintheta: Math.min(...vertices.map((v) => v.x)),
17 | minrho: Math.min(...vertices.map((v) => v.y)),
18 | maxtheta: Math.max(...vertices.map((v) => v.x)),
19 | maxrho: Math.max(...vertices.map((v) => v.y)),
20 | starttheta: vertices[0].x,
21 | startrho: vertices[0].y,
22 | endtheta: vertices[vertices.length - 1].x,
23 | endrho: vertices[vertices.length - 1].y,
24 | }
25 | }
26 |
27 | // transforms vertices into a theta-rho format
28 | transformVertices(vertices, index, layers) {
29 | // downsample larger lines into smaller ones
30 | const maxLength = 2.0
31 | const subsampledVertices = subsample(vertices, maxLength)
32 |
33 | let theta, rawTheta
34 |
35 | if (index == 0) {
36 | theta = 0
37 | rawTheta = 0
38 | } else {
39 | // preserve previous theta value
40 | const prevVertices = layers[index - 1].vertices
41 | const last = prevVertices[prevVertices.length - 1]
42 | theta = last.theta
43 | rawTheta = last.rawTheta // already transformed
44 | }
45 |
46 | // convert to theta, rho
47 | return toThetaRho(
48 | subsampledVertices,
49 | this.props.maxRadius,
50 | parseFloat(this.props.polarRhoMax),
51 | theta,
52 | rawTheta,
53 | )
54 | }
55 |
56 | // provides a theta-rho machine instruction for a given vertex
57 | code(vertex) {
58 | return (
59 | "" + vertex.x.toFixed(this.digits) + " " + vertex.y.toFixed(this.digits)
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/features/export/exporterSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 | import { createSelector } from "reselect"
3 | import { selectState } from "@/features/app/appSlice"
4 | import { GCODE } from "./Exporter"
5 |
6 | // ------------------------------
7 | // Slice, reducers and atomic actions
8 | // ------------------------------
9 |
10 | const exporterSlice = createSlice({
11 | name: "exporter",
12 | initialState: {
13 | fileName: "sandify",
14 | fileType: GCODE,
15 | pre: "",
16 | post: "",
17 | reverse: false,
18 | polarRhoMax: 1.0,
19 | unitsPerCircle: 6.0,
20 | },
21 | reducers: {
22 | updateExporter(state, action) {
23 | Object.assign(state, action.payload)
24 | },
25 | },
26 | })
27 |
28 | export const { updateExporter } = exporterSlice.actions
29 | export default exporterSlice.reducer
30 |
31 | // ------------------------------
32 | // Selectors
33 | // ------------------------------
34 |
35 | export const selectExporterState = createSelector(
36 | selectState,
37 | (state) => state.exporter,
38 | )
39 |
--------------------------------------------------------------------------------
/src/features/export/exporterSlice.spec.js:
--------------------------------------------------------------------------------
1 | import exporter, { updateExporter } from "./exporterSlice"
2 |
3 | describe("exporter reducer", () => {
4 | it("should handle initial state", () => {
5 | expect(exporter(undefined, {})).toEqual({
6 | fileName: "sandify",
7 | fileType: "gcode",
8 | polarRhoMax: 1,
9 | unitsPerCircle: 6,
10 | pre: "",
11 | post: "",
12 | reverse: false,
13 | })
14 | })
15 |
16 | it("should handle updateExporter", () => {
17 | expect(
18 | exporter({ filename: "" }, updateExporter({ filename: "test_filename" })),
19 | ).toEqual({
20 | filename: "test_filename",
21 | })
22 | })
23 | })
24 |
--------------------------------------------------------------------------------
/src/features/file/SandifyDownloader.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from "react"
2 | import { useSelector, useDispatch } from "react-redux"
3 | import Modal from "react-bootstrap/Modal"
4 | import Button from "react-bootstrap/Button"
5 | import InputOption from "@/components/InputOption"
6 | import { updateFile, selectFileState, fileOptions, download } from "./fileSlice"
7 |
8 | const SandifyDownloader = ({ showModal, toggleModal }) => {
9 | const dispatch = useDispatch()
10 | const fileState = useSelector(selectFileState)
11 | const { fileName } = fileState
12 | const inputRef = useRef()
13 |
14 | const handleChange = (attrs) => {
15 | dispatch(updateFile(attrs))
16 | }
17 |
18 | const handleInitialFocus = () => {
19 | inputRef.current.focus()
20 | }
21 |
22 | const handleDownload = () => {
23 | let name = fileName
24 | if (!fileName.includes(".")) {
25 | name += ".sdf"
26 | }
27 |
28 | dispatch(download(name))
29 | toggleModal()
30 | }
31 |
32 | return (
33 |
38 |
39 | Save pattern as...
40 |
41 |
42 |
43 |
52 |
53 | Downloads a text-based .sdf file that contains no personal or browser
54 | information. You can share this file with others, who can import your
55 | design.
56 |
57 |
58 |
59 |
60 |
65 | Close
66 |
67 |
72 | Download
73 |
74 |
75 |
76 | )
77 | }
78 |
79 | export default React.memo(SandifyDownloader)
80 |
--------------------------------------------------------------------------------
/src/features/file/SandifyExporter.js:
--------------------------------------------------------------------------------
1 | import { SANDIFY_VERSION } from "@/features/app/appSlice"
2 |
3 | export default class SandifyExporter {
4 | export(state) {
5 | const currentMachine = state.machines.entities[state.machines.current]
6 | const json = {
7 | version: SANDIFY_VERSION,
8 | effects: { ...state.effects },
9 | images: { ...state.images },
10 | layers: { ...state.layers },
11 | machine: { ...currentMachine },
12 | }
13 |
14 | delete json.images.loaded
15 | delete json.layers.selected
16 | delete json.layers.current
17 | delete json.effects.selected
18 | delete json.effects.current
19 | delete json.machine.id
20 |
21 | return JSON.stringify(json, null, "\t")
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/features/file/SandifyImporter.js:
--------------------------------------------------------------------------------
1 | import Layer from "@/features/layers/Layer"
2 | import EffectLayer from "@/features/effects/EffectLayer"
3 |
4 | export default class SandifyImporter {
5 | import(stateString) {
6 | const state = JSON.parse(stateString)
7 |
8 | this.checkStructure(state)
9 | this.ensureIntegrity(state, "layers", Layer)
10 | this.ensureIntegrity(state, "effects", EffectLayer)
11 | this.ensureImageIntegrity(state)
12 | this.ensureLayerExists(state)
13 |
14 | return state
15 | }
16 |
17 | checkStructure(state) {
18 | if (
19 | !(
20 | state.layers &&
21 | state.effects &&
22 | state.layers.entities &&
23 | state.layers.ids &&
24 | state.effects.entities &&
25 | state.effects.ids
26 | )
27 | ) {
28 | throw new Error(
29 | "Invalid file format. The JSON structure must contain 'layers' and 'effects', both with 'entities' and 'ids'",
30 | )
31 | }
32 |
33 | if (state.images && !(state.images.entities && state.images.ids)) {
34 | throw new Error(
35 | "Invalid file format. The images JSON structure must contain 'ids' and 'entities'.",
36 | )
37 | }
38 | }
39 |
40 | ensureIntegrity(state, slice, ModelClass) {
41 | // ensure entities only contains data for the provided ids
42 | state[slice].entities = state[slice].ids.reduce((entities, id) => {
43 | const data = state[slice].entities[id]
44 |
45 | if (data) {
46 | const instance = new ModelClass(data.type)
47 | const layer = instance.getInitialState()
48 |
49 | // ensure each entity only contains attributes defined by the layer
50 | Object.keys(layer).forEach((attr) => {
51 | if (data[attr] != undefined) {
52 | layer[attr] = data[attr]
53 | }
54 | })
55 |
56 | instance.getHiddenAttrs().forEach((attr) => {
57 | layer[attr] = data[attr]
58 | })
59 |
60 | entities[id] = {
61 | ...layer,
62 | id,
63 | }
64 | }
65 |
66 | return entities
67 | }, {})
68 |
69 | // ensure only ids with valid entities remain
70 | state[slice].ids = this.ensureValidIds(state, slice)
71 | }
72 |
73 | ensureLayerExists(state) {
74 | if (!state.layers.ids.length > 0) {
75 | throw new Error("Pattern cannot be empty.")
76 | }
77 | }
78 |
79 | ensureImageIntegrity(state) {
80 | const imageState = state.images
81 |
82 | if (!imageState) {
83 | return
84 | }
85 |
86 | imageState.entities = imageState.ids.reduce((entities, id) => {
87 | const data = imageState.entities[id]
88 |
89 | if (data) {
90 | entities[id] = {
91 | id: data.id,
92 | src: data.src,
93 | }
94 | }
95 |
96 | return entities
97 | }, {})
98 |
99 | // ensure only ids with valid entities remain
100 | imageState.ids = this.ensureValidIds(state, "images")
101 | }
102 |
103 | ensureValidIds(state, slice) {
104 | return state[slice].ids.filter((id) => {
105 | return !!state[slice].entities[id]
106 | })
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/features/file/SandifyUploader.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react"
2 | import Form from "react-bootstrap/Form"
3 | import { useDispatch } from "react-redux"
4 | import { toast } from "react-toastify"
5 | import { upsertImportedMachine } from "@/features/machines/machinesSlice"
6 | import SandifyImporter from "./SandifyImporter"
7 |
8 | const SandifyUploader = ({ toggleModal, showModal }) => {
9 | const dispatch = useDispatch()
10 | const inputRef = useRef()
11 |
12 | useEffect(() => {
13 | if (showModal && inputRef.current) {
14 | inputRef.current.click()
15 | }
16 | }, [showModal])
17 |
18 | const handleFileSelected = (event) => {
19 | const file = event.target.files[0]
20 |
21 | if (file) {
22 | const reader = new FileReader()
23 |
24 | reader.onload = (event) => {
25 | var text = reader.result
26 |
27 | try {
28 | const importer = new SandifyImporter()
29 | const newState = importer.import(text)
30 |
31 | dispatch(upsertImportedMachine(newState.machine))
32 | dispatch({ type: "LOAD_PATTERN", payload: newState })
33 | } catch (e) {
34 | toast.error(e.message)
35 | }
36 |
37 | // reset the input so we can load the same file again if needed
38 | event.preventDefault()
39 | inputRef.current.value = null
40 | }
41 |
42 | reader.readAsText(file)
43 | }
44 | }
45 |
46 | return (
47 |
55 | )
56 | }
57 |
58 | export default React.memo(SandifyUploader)
59 |
--------------------------------------------------------------------------------
/src/features/file/fileSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 | import { createSelector } from "reselect"
3 | import { downloadFile } from "@/common/util"
4 | import { selectState } from "@/features/app/appSlice"
5 | import SandifyExporter from "./SandifyExporter"
6 |
7 | export const fileOptions = {
8 | fileName: {
9 | title: "File name",
10 | type: "string",
11 | },
12 | }
13 |
14 | // ------------------------------
15 | // Slice, reducers and atomic actions
16 | // ------------------------------
17 |
18 | const fileSlice = createSlice({
19 | name: "file",
20 | initialState: {
21 | fileName: "sandify",
22 | },
23 | reducers: {
24 | updateFile(state, action) {
25 | Object.assign(state, action.payload)
26 | },
27 | },
28 | })
29 |
30 | export const { updateFile } = fileSlice.actions
31 | export default fileSlice.reducer
32 |
33 | // ------------------------------
34 | // Selectors
35 | // ------------------------------
36 |
37 | export const selectFileState = createSelector(
38 | selectState,
39 | (state) => state.file,
40 | )
41 |
42 | // ------------------------------
43 | // Compound actions (thunks)
44 | // ------------------------------
45 | export const download = (fileName) => {
46 | return (dispatch, getState) => {
47 | const state = getState()
48 | const exporter = new SandifyExporter()
49 | downloadFile(fileName, exporter.export(state), "text/plain;charset=utf-8")
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/features/fonts/fontsSlice.js:
--------------------------------------------------------------------------------
1 | // https://fonts.google.com/attribution; see NOTICE for license details
2 | // Bubblegum, EBGaramond, Holtwood, Lobster, Montserrat, Rouge, NotoEmoji - SIL Open Font License 1.1
3 | // OpenSans, Roboto, Mountains of Christmas - Apache License 2.0
4 |
5 | import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"
6 | import opentype from "opentype.js"
7 |
8 | const globalFonts = {}
9 | export const supportedFonts = {
10 | "fonts/BubblegumSans-Regular.ttf": "Bubblegum Sans",
11 | "fonts/EBGaramond-Regular.ttf": "Garamond",
12 | "fonts/HoltwoodOneSC-Regular.ttf": "Holtwood",
13 | "fonts/Lobster-Regular.ttf": "Lobster",
14 | "fonts/Montserrat-Bold.ttf": "Montserrat",
15 | "fonts/NotoEmoji-VariableFont_wght.ttf": "Noto Emoji",
16 | "fonts/OpenSans-Regular.ttf": "Open Sans",
17 | "fonts/Roboto-Black.ttf": "Roboto",
18 | "fonts/RougeScript-Regular.ttf": "Rouge Script",
19 | "fonts/MountainsofChristmas-Regular.ttf": "Mountains of Christmas",
20 | }
21 |
22 | export const loadFont = createAsyncThunk("fonts/getFont", async (url) => {
23 | const font = await opentype.load(url)
24 | const fontName = supportedFonts[url]
25 |
26 | globalFonts[fontName] = font
27 | return fontName
28 | })
29 |
30 | export const getFont = (name) => {
31 | return globalFonts[name]
32 | }
33 |
34 | let loadCount = 0
35 | export const fontsSlice = createSlice({
36 | name: "fonts",
37 | initialState: {
38 | loaded: false,
39 | },
40 | reducers: {},
41 | extraReducers: (builder) => {
42 | builder.addCase(loadFont.fulfilled, (state, action) => {
43 | loadCount++
44 | state.loaded = loadCount == Object.keys(supportedFonts).length
45 | })
46 | },
47 | })
48 |
49 | export const selectFontsLoaded = (state) => state.fonts.loaded
50 |
51 | export default fontsSlice.reducer
52 |
--------------------------------------------------------------------------------
/src/features/groups/Group.js:
--------------------------------------------------------------------------------
1 | import Model from "@/common/Model"
2 |
3 | export default class Group extends Model {}
4 |
--------------------------------------------------------------------------------
/src/features/images/imagesSlice.js:
--------------------------------------------------------------------------------
1 | import {
2 | createSlice,
3 | createEntityAdapter,
4 | createAsyncThunk,
5 | } from "@reduxjs/toolkit"
6 | import { prepareAfterAdd } from "@/common/slice"
7 |
8 | // ------------------------------
9 | // Slice, reducers and atomic actions
10 | // ------------------------------
11 |
12 | const adapter = createEntityAdapter()
13 |
14 | let loadCount = 0
15 | export const imagesSlice = createSlice({
16 | name: "images",
17 | initialState: {
18 | ...adapter.getInitialState(),
19 | loaded: false,
20 | },
21 | reducers: {
22 | addImage: {
23 | reducer(state, action) {
24 | adapter.addOne(state, action)
25 | },
26 | prepare(image) {
27 | return prepareAfterAdd(image)
28 | },
29 | },
30 | deleteImage: (state, action) => {
31 | adapter.removeOne(state, action)
32 | },
33 | },
34 | extraReducers: (builder) => {
35 | builder.addCase(loadImage.fulfilled, (state, action) => {
36 | loadCount++
37 | state.loaded = loadCount == state.ids.length
38 | })
39 | },
40 | })
41 |
42 | export default imagesSlice.reducer
43 | export const { addImage, deleteImage } = imagesSlice.actions
44 |
45 | // ------------------------------
46 | // Selectors
47 | // ------------------------------
48 |
49 | export const { selectAll: selectAllImages } = adapter.getSelectors(
50 | (state) => state.images,
51 | )
52 |
53 | export const selectImagesLoaded = (state) => state.images.loaded
54 |
55 | // ------------------------------
56 | // Thunks
57 | // ------------------------------
58 |
59 | // returns a sensibly downscaled dimensions for an loaded image (aesthetics and performance)
60 | const downscaledDimensions = (image) => {
61 | let cw = 800
62 | let ch = 600
63 | const w = image.width
64 | const h = image.height
65 |
66 | if (w > cw || h > ch) {
67 | const aspectRatio = w / h
68 |
69 | if (w > h) {
70 | ch = Math.round(cw / aspectRatio)
71 |
72 | if (ch > 600) {
73 | ch = 600
74 | cw = Math.round(ch * aspectRatio)
75 | }
76 | } else {
77 | cw = Math.round(ch * aspectRatio)
78 |
79 | if (cw > 800) {
80 | cw = 800
81 | ch = Math.round(cw / aspectRatio)
82 | }
83 | }
84 | } else {
85 | cw = w
86 | ch = h
87 | }
88 |
89 | return { width: cw, height: ch }
90 | }
91 |
92 | export const loadImage = createAsyncThunk(
93 | "images/getImage",
94 | async ({ imageId, imageSrc }) => {
95 | return new Promise((resolve, reject) => {
96 | const div = document.getElementById("image-importer")
97 | const canvas = document.createElement("canvas")
98 | const image = new Image()
99 |
100 | canvas.setAttribute("id", `${imageId}-canvas`)
101 | div.appendChild(canvas)
102 |
103 | image.onload = (event) => {
104 | const context = canvas.getContext("2d", {
105 | willReadFrequently: true,
106 | })
107 | const { width, height } = downscaledDimensions(image)
108 |
109 | canvas.height = height
110 | canvas.width = width
111 | context.imageSmoothingEnabled = true
112 | context.imageSmoothingQuality = "low"
113 | context.clearRect(0, 0, width, height)
114 | context.drawImage(image, 0, 0, width, height)
115 |
116 | resolve()
117 | }
118 |
119 | image.src = imageSrc
120 | })
121 | },
122 | )
123 |
--------------------------------------------------------------------------------
/src/features/images/imagesSlice.spec.js:
--------------------------------------------------------------------------------
1 | import { resetUniqueId } from "@/common/mocks"
2 | import imagesReducer, { addImage, deleteImage } from "./imagesSlice"
3 |
4 | beforeEach(() => {
5 | resetUniqueId()
6 | })
7 |
8 | // ------------------------------
9 | // Slice, reducers and atomic actions
10 | // ------------------------------
11 |
12 | describe("images reducer", () => {
13 | it("should handle initial state", () => {
14 | expect(imagesReducer(undefined, {})).toEqual({
15 | ids: [],
16 | entities: {},
17 | loaded: false,
18 | })
19 | })
20 |
21 | it("should handle addImage", () => {
22 | expect(
23 | imagesReducer(
24 | {
25 | ids: [],
26 | entities: {},
27 | },
28 | addImage({
29 | name: "foo",
30 | }),
31 | ),
32 | ).toEqual({
33 | ids: ["1"],
34 | entities: {
35 | 1: {
36 | id: "1",
37 | name: "foo",
38 | },
39 | },
40 | })
41 | })
42 |
43 | it("should handle deleteImage", () => {
44 | expect(
45 | imagesReducer(
46 | {
47 | ids: ["1"],
48 | entities: {
49 | 1: {
50 | id: "1",
51 | name: "foo",
52 | },
53 | },
54 | },
55 | deleteImage("1"),
56 | ),
57 | ).toEqual({
58 | entities: {},
59 | ids: [],
60 | })
61 | })
62 | })
63 |
--------------------------------------------------------------------------------
/src/features/import/GCodeImporter.js:
--------------------------------------------------------------------------------
1 | import Importer from "./Importer"
2 | import Toolpath from "gcode-toolpath"
3 |
4 | export default class GCodeImporter extends Importer {
5 | // calls callback, returning an object containing relevant properties
6 | import(callback) {
7 | const vertices = []
8 | const lines = this.text.split("\n")
9 |
10 | // This assumes the line is already trimmed and not empty.
11 | // The parenthesis isn't perfect, since it usually has a match, but I don't think anyone will
12 | // care. I think there are firmwares that do this same kind of hack.
13 | const isComment = (line) => {
14 | return line.indexOf(";") === 0 || line.indexOf("(") === 0
15 | }
16 |
17 | const addVertex = (x, y) => {
18 | vertices.push({ x, y })
19 | }
20 |
21 | // Ignore initial comments
22 | for (let ii = 0; ii < lines.length; ii++) {
23 | let line = lines[ii].trim()
24 | if (line.length === 0 || isComment(line)) {
25 | continue
26 | } else {
27 | break
28 | }
29 | }
30 |
31 | // GCode reader object. More info here:
32 | // https://github.com/cncjs/gcode-toolpath/blob/master/README.md
33 | const toolpath = new Toolpath({
34 | // @param {object} modal The modal object.
35 | // @param {object} v1 A 3D vector of the start point.
36 | // @param {object} v2 A 3D vector of the end point.
37 | addLine: (modal, v1, v2) => {
38 | if (v1.x !== v2.x || v1.y !== v2.y) {
39 | addVertex(v2.x, v2.y)
40 | }
41 | },
42 | // @param {object} modal The modal object.
43 | // @param {object} v1 A 3D vector of the start point.
44 | // @param {object} v2 A 3D vector of the end point.
45 | // @param {object} v0 A 3D vector of the fixed point.
46 | addArcCurve: (modal, v1, v2, v0) => {
47 | if (v1.x !== v2.x || v1.y !== v2.y) {
48 | // We can't use arc, we have to go a specific direction (not the shortest path).
49 | let startTheta = Math.atan2(v1.y - v0.y, v1.x - v0.x)
50 | let endTheta = Math.atan2(v2.y - v0.y, v2.x - v0.x)
51 | let deltaTheta = endTheta - startTheta
52 | const radius = Math.sqrt(
53 | Math.pow(v2.x - v0.x, 2.0) + Math.pow(v2.y - v0.y, 2.0),
54 | )
55 | let direction = 1.0 // Positive, so anticlockwise.
56 |
57 | // Clockwise
58 | if (modal.motion === "G2") {
59 | if (deltaTheta > 0.0) {
60 | endTheta -= 2.0 * Math.PI
61 | deltaTheta -= 2.0 * Math.PI
62 | }
63 | direction = -1.0
64 | } else if (modal.motion === "G3") {
65 | // Anti-clockwise
66 | if (deltaTheta < 0.0) {
67 | endTheta += 2.0 * Math.PI
68 | deltaTheta += 2.0 * Math.PI
69 | }
70 | }
71 |
72 | // What angle do we need to have a resolution of approx. 0.5mm?
73 | const arcResolution = 0.5
74 | const arcLength = Math.abs(deltaTheta) * radius
75 | const thetaStep = (deltaTheta * arcResolution) / arcLength
76 | for (
77 | let theta = startTheta;
78 | direction * theta <= direction * endTheta;
79 | theta += thetaStep
80 | ) {
81 | addVertex(
82 | v0.x + radius * Math.cos(theta),
83 | v0.y + radius * Math.sin(theta),
84 | )
85 | }
86 | // Save the final point, in case our math didn't quite get there.
87 | addVertex(v2.x, v2.y)
88 | }
89 | },
90 | })
91 |
92 | toolpath.loadFromString(this.text, (err, results) => {
93 | callback(this, vertices)
94 | })
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/features/import/ImageUploader.js:
--------------------------------------------------------------------------------
1 | import { toast } from "react-toastify"
2 | import React, { useEffect, useRef } from "react"
3 | import Form from "react-bootstrap/Form"
4 | import { useSelector, useDispatch } from "react-redux"
5 | import { selectCurrentMachine } from "@/features/machines/machinesSlice"
6 | import { addLayerWithImage } from "@/features/layers/layersSlice"
7 |
8 | const ImageUploader = ({ toggleModal, showModal }) => {
9 | const machineState = useSelector(selectCurrentMachine)
10 | const dispatch = useDispatch()
11 | const inputRef = useRef()
12 |
13 | useEffect(() => {
14 | if (showModal && inputRef.current) {
15 | inputRef.current.click()
16 | }
17 | }, [showModal])
18 |
19 | const handleFileSelected = (event) => {
20 | const file = event.target.files[0]
21 | const maxSize = 1024 * 1024 // 1 MB
22 |
23 | if (file) {
24 | if (file.size > maxSize) {
25 | toast.error("This file is too large to import (maximum size 1 MB).")
26 | } else {
27 | const reader = new FileReader()
28 | reader.onload = (event) => {
29 | const image = new Image()
30 |
31 | image.onload = () => {
32 | const layerProps = {
33 | machine: machineState,
34 | width: image.width,
35 | height: image.height,
36 | name: file.name,
37 | }
38 |
39 | dispatch(
40 | addLayerWithImage({
41 | layerProps,
42 | image: {
43 | src: reader.result,
44 | height: image.height,
45 | width: image.width,
46 | },
47 | }),
48 | )
49 | inputRef.current.value = "" // reset to allow more uploads
50 | }
51 |
52 | image.src = reader.result
53 | }
54 |
55 | reader.readAsDataURL(file)
56 | }
57 | }
58 | }
59 |
60 | return (
61 |
75 | )
76 | }
77 |
78 | export default React.memo(ImageUploader)
79 |
--------------------------------------------------------------------------------
/src/features/import/Importer.js:
--------------------------------------------------------------------------------
1 | export default class Importer {
2 | constructor(fileName, text) {
3 | this.fileName = fileName
4 | this.text = text
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/features/import/LayerUploader.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react"
2 | import Form from "react-bootstrap/Form"
3 | import { useSelector, useDispatch } from "react-redux"
4 | import { selectCurrentMachine } from "@/features/machines/machinesSlice"
5 | import ThetaRhoImporter from "@/features/import/ThetaRhoImporter"
6 | import GCodeImporter from "@/features/import/GCodeImporter"
7 | import { addLayer } from "@/features/layers/layersSlice"
8 | import Layer from "@/features/layers/Layer"
9 |
10 | const LayerUploader = ({ toggleModal, showModal }) => {
11 | const machineState = useSelector(selectCurrentMachine)
12 | const dispatch = useDispatch()
13 | const inputRef = useRef()
14 |
15 | useEffect(() => {
16 | if (showModal && inputRef.current) {
17 | inputRef.current.click()
18 | }
19 | }, [showModal])
20 |
21 | const handleFileImported = (importer, vertices) => {
22 | const layer = new Layer("fileImport")
23 | const layerProps = {
24 | machine: machineState,
25 | vertices,
26 | }
27 | const attrs = {
28 | ...layer.getInitialState(layerProps),
29 | fileName: importer.fileName,
30 | name: importer.fileName,
31 | }
32 |
33 | dispatch(addLayer(attrs))
34 | }
35 |
36 | const handleFileSelected = (event) => {
37 | const file = event.target.files[0]
38 |
39 | if (file) {
40 | const reader = new FileReader()
41 |
42 | reader.onload = (event) => {
43 | const text = reader.result
44 |
45 | let importer
46 | if (file.name.toLowerCase().endsWith(".thr")) {
47 | importer = new ThetaRhoImporter(file.name, text)
48 | } else if (
49 | file.name.toLowerCase().endsWith(".gcode") ||
50 | file.name.toLowerCase().endsWith(".nc")
51 | ) {
52 | importer = new GCodeImporter(file.name, text)
53 | }
54 |
55 | importer.import(handleFileImported)
56 | inputRef.current.value = "" // reset to allow more uploads
57 | }
58 |
59 | reader.readAsText(file)
60 | }
61 | }
62 |
63 | return (
64 |
72 | )
73 | }
74 |
75 | export default React.memo(LayerUploader)
76 |
--------------------------------------------------------------------------------
/src/features/import/ThetaRhoImporter.js:
--------------------------------------------------------------------------------
1 | import Importer from "./Importer"
2 |
3 | export default class ThetaRhoImporter extends Importer {
4 | // calls callback, returning an object containing relevant properties
5 | import(callback) {
6 | let hasVertex = false
7 | let lines = this.text.split("\n")
8 | let thetaRhos = []
9 |
10 | for (let ii = 0; ii < lines.length; ii++) {
11 | var line = lines[ii].trim()
12 |
13 | if (line.length === 0 || (line.indexOf("#") === 0 && !hasVertex)) {
14 | // blank lines or comments
15 | continue
16 | }
17 |
18 | if (line.indexOf("#") !== 0) {
19 | hasVertex = true
20 |
21 | // This is a point, let's try to read it.
22 | var pointStrings = line.split(/\s+/)
23 | if (pointStrings.length !== 2) {
24 | continue
25 | }
26 |
27 | thetaRhos.push([
28 | parseFloat(pointStrings[0]),
29 | parseFloat(pointStrings[1]),
30 | ])
31 | }
32 | }
33 |
34 | callback(this, this.convertToXY(thetaRhos))
35 | }
36 |
37 | convertToXY(thetaRhos) {
38 | var vertices = []
39 | var previous = undefined
40 | var max_angle = Math.PI / 64.0
41 | for (let ii = 0; ii < thetaRhos.length; ii++) {
42 | var next = thetaRhos[ii]
43 | if (previous) {
44 | if (Math.abs(next[0] - previous[0]) < max_angle) {
45 | // These sin, cos elements are inverted. I'm not sure why
46 | vertices.push({
47 | x: previous[1] * Math.sin(previous[0]),
48 | y: previous[1] * Math.cos(previous[0]),
49 | })
50 | } else {
51 | // We need to do some interpolating.
52 | let deltaAngle = next[0] - previous[0]
53 | let rhoStep =
54 | (max_angle / Math.abs(deltaAngle)) * (next[1] - previous[1])
55 | var rho = previous[1]
56 | if (deltaAngle > 0.0) {
57 | var emergency_break = 0
58 | for (
59 | let angle = previous[0];
60 | angle < next[0];
61 | angle += max_angle, rho += rhoStep
62 | ) {
63 | vertices.push({
64 | x: rho * Math.sin(angle),
65 | y: rho * Math.cos(angle),
66 | })
67 | if (emergency_break++ > 100000) {
68 | break
69 | }
70 | }
71 | } else {
72 | for (
73 | let angle = previous[0];
74 | angle > next[0];
75 | angle -= max_angle, rho += rhoStep
76 | ) {
77 | vertices.push({
78 | x: rho * Math.sin(angle),
79 | y: rho * Math.cos(angle),
80 | })
81 | if (emergency_break++ > 100000) {
82 | break
83 | }
84 | }
85 | }
86 | }
87 | }
88 | previous = next
89 | }
90 | return vertices
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/features/import/testArcs.gcode:
--------------------------------------------------------------------------------
1 | ; Test code for sandify input
2 |
3 | ; This is still a comment
4 | ( This is a comment too )
5 |
6 | G1 F1000
7 | G1 X-10 Y-10 ; This comment won't be read.
8 | ; Neither will this comment
9 | G1 X-10 Y10
10 | G1 X10 Y10
11 | G1 X10 Y-10
12 | G1 X-10 Y-10
13 | G2 X-10 Y10 I10 J10
14 | G2 X10 Y10 I10 J-10
15 | G2 X10 Y-10 I-10 J-10
16 | G2 X-10 Y-10 I-10 J10
17 | G2 X10 Y10 I20 J0
18 | G2 X-10 Y-10 I-20 J0
19 | G1 X10 Y-10
20 | G3 X-10 Y10 I-20 J0
21 | G3 X10 Y-10 I20 J0
22 | G2 X-10 Y-10 I-10 J10
23 |
--------------------------------------------------------------------------------
/src/features/layers/CopyLayer.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react"
2 | import Col from "react-bootstrap/Col"
3 | import Row from "react-bootstrap/Row"
4 | import Form from "react-bootstrap/Form"
5 | import Button from "react-bootstrap/Button"
6 | import Modal from "react-bootstrap/Modal"
7 | import { useDispatch, useSelector } from "react-redux"
8 | import { copyLayer } from "./layersSlice"
9 | import { selectSelectedLayer } from "./layersSlice"
10 |
11 | const CopyLayer = ({ toggleModal, showModal }) => {
12 | const dispatch = useDispatch()
13 | const selectedLayer = useSelector(selectSelectedLayer)
14 | const namedInputRef = useRef(null)
15 | const [copyLayerName, setCopyLayerName] = useState(selectedLayer.name)
16 |
17 | useEffect(() => {
18 | setCopyLayerName(selectedLayer.name)
19 | }, [selectedLayer])
20 |
21 | const handleChangeCopyLayerName = (event) => {
22 | setCopyLayerName(event.target.value)
23 | }
24 |
25 | const handleNameFocus = (event) => {
26 | event.target.select()
27 | }
28 |
29 | const handleCopyLayer = (event) => {
30 | event.preventDefault()
31 | dispatch(
32 | copyLayer({
33 | id: selectedLayer.id,
34 | name: copyLayerName,
35 | }),
36 | )
37 | toggleModal()
38 | }
39 |
40 | const handleInitialFocus = () => {
41 | namedInputRef.current.focus()
42 | }
43 |
44 | return (
45 |
50 |
51 | Copy {selectedLayer.name}
52 |
53 |
54 |
86 |
87 | )
88 | }
89 |
90 | export default CopyLayer
91 |
--------------------------------------------------------------------------------
/src/features/layers/LayerManager.scss:
--------------------------------------------------------------------------------
1 | .layer-button {
2 | color: #6c757d !important;
3 | > svg { font-size: 20px; }
4 | padding-left: 0.5rem !important;
5 | padding-right: 0.5rem !important;
6 |
7 | .active.list-group-item & {
8 | color: white;
9 | background-color: inherit;
10 | }
11 |
12 | .selected.list-group-item & {
13 | background-color: #d2d2d2;
14 | }
15 | }
16 |
17 | .cursor-move {
18 | cursor: move;
19 | }
20 |
21 | #layers {
22 | .list-group-item {
23 | padding: 0.5rem 0.25rem;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/features/machines/CopyMachine.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react"
2 | import Button from "react-bootstrap/Button"
3 | import Modal from "react-bootstrap/Modal"
4 | import Col from "react-bootstrap/Col"
5 | import Row from "react-bootstrap/Row"
6 | import Form from "react-bootstrap/Form"
7 | import { useDispatch, useSelector } from "react-redux"
8 | import { selectCurrentMachine, addMachine } from "./machinesSlice"
9 |
10 | const CopyMachine = ({ toggleModal, showModal }) => {
11 | const dispatch = useDispatch()
12 | const currentMachine = useSelector(selectCurrentMachine)
13 | const namedInputRef = useRef(null)
14 | const [copyMachineName, setCopyMachineName] = useState(
15 | currentMachine?.name || "",
16 | )
17 |
18 | useEffect(() => {
19 | setCopyMachineName(currentMachine?.name || "")
20 | }, [currentMachine])
21 |
22 | const handleChangeCopyMachineName = (event) => {
23 | setCopyMachineName(event.target.value)
24 | }
25 |
26 | const handleNameFocus = (event) => {
27 | event.target.select()
28 | }
29 |
30 | const handleCopyMachine = (event) => {
31 | event.preventDefault()
32 | dispatch(
33 | addMachine({
34 | ...currentMachine,
35 | name: copyMachineName,
36 | }),
37 | )
38 | toggleModal()
39 | }
40 |
41 | const handleInitialFocus = () => {
42 | namedInputRef.current.focus()
43 | }
44 |
45 | return (
46 |
51 |
52 | Copy {currentMachine?.name || ""}
53 |
54 |
55 |
87 |
88 | )
89 | }
90 |
91 | export default CopyMachine
92 |
--------------------------------------------------------------------------------
/src/features/machines/MachineEditor.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { useDispatch, useSelector } from "react-redux"
3 | import Col from "react-bootstrap/Col"
4 | import Row from "react-bootstrap/Row"
5 | import Select from "react-select"
6 | import ModelOption from "@/components/ModelOption"
7 | import {
8 | updateMachine,
9 | selectCurrentMachine,
10 | changeMachineType,
11 | } from "./machinesSlice"
12 | import { getMachine, getMachineSelectOptions } from "./machineFactory"
13 |
14 | const MachineEditor = () => {
15 | const dispatch = useDispatch()
16 | const machine = useSelector(selectCurrentMachine)
17 | const type = machine?.type || "rectangular" // guard zombie child
18 | const instance = getMachine(type)
19 | const machineOptions = instance.getOptions()
20 | const selectOptions = getMachineSelectOptions()
21 | const selectedOption = {
22 | value: instance.type,
23 | label: instance.label,
24 | }
25 |
26 | const handleChange = (attrs) => {
27 | attrs.id = machine.id
28 | dispatch(updateMachine(attrs))
29 | }
30 |
31 | const handleChangeType = (selected) => {
32 | dispatch(changeMachineType({ id: machine.id, type: selected.value }))
33 | }
34 |
35 | const renderedMachineSelection = (
36 |
37 |
41 | Type
42 |
43 |
44 |
48 | ({ ...base, zIndex: 9999 }) }}
55 | />
56 |
57 |
58 | )
59 |
60 | // this should really be a component, but I could not figure out how to get it
61 | // to not re-render as the value changed; the fallout is that the editor re-renders
62 | // more than it should, but it's not noticeable
63 | const renderOption = ({ optionKey }) => {
64 | return (
65 |
73 | )
74 | }
75 |
76 | const renderedModelOptions = Object.keys(machineOptions)
77 | .filter(
78 | (optionKey) => optionKey !== "name" && optionKey !== "minimizeMoves",
79 | )
80 | .map((optionKey) => renderOption({ options: machineOptions, optionKey }))
81 |
82 | return (
83 |
84 | {renderOption({ optionKey: "name" })}
85 | {renderedMachineSelection}
86 | {renderedModelOptions}
87 | {renderOption({ optionKey: "minimizeMoves" })}
88 |
89 | )
90 | }
91 |
92 | export default React.memo(MachineEditor)
93 |
--------------------------------------------------------------------------------
/src/features/machines/MachineList.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { useDispatch, useSelector } from "react-redux"
3 | import ListGroup from "react-bootstrap/ListGroup"
4 | import {
5 | selectAllMachines,
6 | selectCurrentMachineId,
7 | setCurrentMachine,
8 | } from "./machinesSlice"
9 | import { getMachine } from "./machineFactory"
10 |
11 | const MachineRow = ({ current, numLayers, machine, handleMachineSelected }) => {
12 | const { name, id } = machine
13 | const activeClass = current ? "active" : ""
14 | const machineModel = getMachine(machine)
15 |
16 | return (
17 |
22 |
26 |
27 |
{name}
28 |
{machineModel.label}
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | const MachineList = () => {
36 | const dispatch = useDispatch()
37 | const machines = useSelector(selectAllMachines)
38 | const currentMachineId = useSelector(selectCurrentMachineId)
39 |
40 | const handleMachineSelected = (event) => {
41 | const id = event.target.closest(".list-group-item").id
42 | dispatch(setCurrentMachine(id))
43 | }
44 |
45 | return (
46 |
47 |
51 | {machines.map((machine, index) => (
52 |
60 | ))}
61 |
62 |
63 | )
64 | }
65 |
66 | export default React.memo(MachineList)
67 |
--------------------------------------------------------------------------------
/src/features/machines/MachineManager.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react"
2 | import Button from "react-bootstrap/Button"
3 | import { Tooltip } from "react-tooltip"
4 | import { FaTrash, FaCopy, FaPlusSquare } from "react-icons/fa"
5 | import { useSelector, useDispatch } from "react-redux"
6 | import {
7 | selectNumMachines,
8 | selectCurrentMachineId,
9 | deleteMachine,
10 | } from "@/features/machines/machinesSlice"
11 | import MachineList from "./MachineList"
12 | import MachineEditor from "./MachineEditor"
13 | import CopyMachine from "./CopyMachine"
14 | import NewMachine from "./NewMachine"
15 |
16 | const MachineManager = () => {
17 | const dispatch = useDispatch()
18 | const numMachines = useSelector(selectNumMachines)
19 | const currentMachineId = useSelector(selectCurrentMachineId)
20 |
21 | const canRemove = numMachines > 1
22 | const [showNewMachine, setShowNewMachine] = useState(false)
23 | const [showCopyMachine, setShowCopyMachine] = useState(false)
24 |
25 | const toggleNewMachineModal = () => setShowNewMachine(!showNewMachine)
26 | const toggleCopyMachineModal = () => setShowCopyMachine(!showCopyMachine)
27 | const handleMachineRemoved = (id) => dispatch(deleteMachine(currentMachineId))
28 |
29 | return (
30 |
31 |
35 |
39 |
40 |
41 |
42 |
43 |
51 |
52 |
53 | {canRemove && }
54 | {canRemove && (
55 |
62 |
63 |
64 | )}
65 |
66 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | )
80 | }
81 |
82 | export default React.memo(MachineManager)
83 |
--------------------------------------------------------------------------------
/src/features/machines/NewMachine.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef } from "react"
2 | import { useDispatch } from "react-redux"
3 | import Select from "react-select"
4 | import Col from "react-bootstrap/Col"
5 | import Row from "react-bootstrap/Row"
6 | import Form from "react-bootstrap/Form"
7 | import Button from "react-bootstrap/Button"
8 | import Modal from "react-bootstrap/Modal"
9 | import {
10 | getDefaultMachine,
11 | getMachineSelectOptions,
12 | getMachine,
13 | } from "./machineFactory"
14 | import { addMachine } from "./machinesSlice"
15 |
16 | const defaultMachine = getDefaultMachine()
17 | const customStyles = {
18 | control: (base) => ({
19 | ...base,
20 | height: 55,
21 | minHeight: 55,
22 | }),
23 | }
24 |
25 | const NewMachine = ({ toggleModal, showModal }) => {
26 | const dispatch = useDispatch()
27 | const selectRef = useRef()
28 | const selectOptions = getMachineSelectOptions()
29 | const [type, setType] = useState(defaultMachine.type)
30 | const [name, setName] = useState(defaultMachine.label)
31 |
32 | const selectedMachine = getMachine({ type })
33 | const selectedOption = {
34 | value: selectedMachine.id,
35 | label: selectedMachine.label,
36 | }
37 |
38 | const handleNameFocus = (event) => {
39 | event.target.select()
40 | }
41 |
42 | const handleInitialFocus = () => {
43 | selectRef.current.focus()
44 | }
45 |
46 | const handleChangeNewType = (selected) => {
47 | const machine = getMachine(selected.value)
48 |
49 | setType(selected.value)
50 | setName(machine.label.toLowerCase())
51 | }
52 |
53 | const handleChangeNewName = (event) => {
54 | setName(event.target.value)
55 | }
56 |
57 | const onMachineAdded = (event) => {
58 | const machine = getMachine(type)
59 |
60 | event.preventDefault()
61 | dispatch(
62 | addMachine({
63 | ...machine.getInitialState(),
64 | name,
65 | }),
66 | )
67 | toggleModal()
68 | }
69 |
70 | return (
71 |
76 |
77 | Create new machine
78 |
79 |
80 |
124 |
125 | )
126 | }
127 |
128 | export default NewMachine
129 |
--------------------------------------------------------------------------------
/src/features/machines/PolarInvertedMachine.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import PolarMachine from "./PolarMachine"
3 | import { circle } from "@/common/geometry"
4 | import { nearestNeighbor } from "@/common/proximity"
5 |
6 | // Machine that clips vertices that fall inside the machine limits
7 | export default class PolarInvertedMachine extends PolarMachine {
8 | // Walk the given vertices, clipping as needed along the perimeter
9 | enforceLimits() {
10 | return this.enforceInvertedLimits()
11 | }
12 |
13 | // Finds the nearest vertex that is in the bounds of the circle. This will change the
14 | // shape. i.e. this doesn't care about the line segment, only about the point.
15 | nearestVertex(vertex) {
16 | const size = this.state.maxRadius
17 |
18 | if (vertex.length() < size) {
19 | const scale = size / vertex.length()
20 | return vertex.multiply(new Victor(scale, scale))
21 | } else {
22 | return vertex
23 | }
24 | }
25 |
26 | // Take a given line, and if the line goes out of bounds, returns the vertices
27 | // around the outside edge to follow around without messing up the shape of the vertices.
28 | clipSegment(start, end, log = false) {
29 | const size = this.state.maxRadius
30 | const radStart = start.magnitude()
31 | const radEnd = end.magnitude()
32 |
33 | if (radStart < size && radEnd < size) {
34 | if (log) {
35 | console.log("line is inside limits")
36 | }
37 | return []
38 | }
39 |
40 | const intersections = this.getIntersections(start, end)
41 | if (!intersections.intersection) {
42 | if (log) {
43 | console.log("line is outside limits")
44 | }
45 | return [end]
46 | }
47 |
48 | if (intersections.points[0].on && intersections.points[1].on) {
49 | let point = intersections.points[0].point
50 | let otherPoint = intersections.points[1].point
51 |
52 | if (log) {
53 | console.log("line is outside limits, but intersects within limits")
54 | }
55 | return [...this.tracePerimeter(point, otherPoint), otherPoint, end]
56 | }
57 |
58 | if (radStart <= size) {
59 | const point1 =
60 | intersections.points[0].on &&
61 | Math.abs(intersections.points[0].point - start) > 0.0001
62 | ? intersections.points[0].point
63 | : intersections.points[1].point
64 | if (log) {
65 | console.log("start is inside limits")
66 | }
67 | return [point1, end]
68 | } else {
69 | const point1 = intersections.points[0].on
70 | ? intersections.points[0].point
71 | : intersections.points[1].point
72 | if (log) {
73 | console.log("end is inside limits")
74 | }
75 | return [start, point1]
76 | }
77 | }
78 |
79 | // Returns the vertex if it's outside the bounds of the circle.
80 | inBounds(vertex) {
81 | return vertex.length() < this.state.maxRadius
82 | }
83 |
84 | outlinePerimeter() {
85 | let border = circle(this.state.maxRadius)
86 | const nearestVertex = nearestNeighbor(this.vertices, border)
87 |
88 | if (nearestVertex) {
89 | const nearestIndex = this.vertices.indexOf(nearestVertex)
90 |
91 | // rotate our border to start from the nearest vertex; we're drawing this at a finer
92 | // resolution because otherwise our downsampling can remove vertices.
93 | border = circle(this.state.maxRadius, nearestVertex.angle(), 0, 0, 256)
94 | this.vertices.splice(nearestIndex, 0, ...border)
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/features/machines/RectInvertedMachine.js:
--------------------------------------------------------------------------------
1 | import RectMachine from "./RectMachine"
2 | import Victor from "victor"
3 | import clip from "liang-barsky"
4 | import { cloneVertices } from "@/common/geometry"
5 | import { closest } from "@/common/proximity"
6 |
7 | // Machine that clips vertices that fall inside the machine limits
8 | export default class RectInvertedMachine extends RectMachine {
9 | enforceLimits() {
10 | return this.enforceInvertedLimits()
11 | }
12 |
13 | clipSegment(start, end, log = false) {
14 | const quadrantStart = this.pointLocation(start)
15 | const quadrantEnd = this.pointLocation(end)
16 |
17 | if (quadrantStart === 0b0000 && quadrantEnd === 0b0000) {
18 | if (log) {
19 | console.log("line is inside limits")
20 | }
21 | return []
22 | }
23 |
24 | let a = [start.x, start.y]
25 | let b = [end.x, end.y]
26 | const bounds = [-this.sizeX, -this.sizeY, this.sizeX, this.sizeY]
27 | const clipped = clip(a, b, bounds)
28 |
29 | if (quadrantStart === 0b000) {
30 | if (log) {
31 | console.log("start is inside limits")
32 | }
33 | return [new Victor(b[0], b[1]), end]
34 | }
35 |
36 | if (quadrantEnd === 0b000) {
37 | if (log) {
38 | console.log("end is inside limits")
39 | }
40 | return [start, new Victor(a[0], a[1])]
41 | }
42 |
43 | if (clipped) {
44 | if (log) {
45 | console.log("line is outside limits, but intersects within limits")
46 | }
47 | return [
48 | start,
49 | ...this.tracePerimeter(
50 | new Victor(a[0], a[1]),
51 | new Victor(b[0], b[1]),
52 | true,
53 | ),
54 | end,
55 | ]
56 | } else {
57 | if (log) {
58 | console.log("line is outside limits")
59 | }
60 | return [start, end]
61 | }
62 | }
63 |
64 | // Finds the nearest vertex that is in the bounds. This will change the shape. i.e. this
65 | // doesn't care about the line segment, only about the point.
66 | nearestVertex(vertex) {
67 | if (this.pointLocation(vertex) === 0b0000) {
68 | return this.nearestPerimeterVertex(vertex)
69 | } else {
70 | return vertex
71 | }
72 | }
73 |
74 | outlinePerimeter() {
75 | const borderStart = new Victor(this.corners[0])
76 | const border = cloneVertices(this.corners)
77 | const closestVertex = closest(this.vertices, borderStart)
78 |
79 | if (closestVertex) {
80 | const closestIndex = this.vertices.indexOf(closestVertex)
81 | this.vertices.splice(closestIndex, 0, ...border)
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/features/machines/machineFactory.js:
--------------------------------------------------------------------------------
1 | import RectMachine from "./RectMachine"
2 | import PolarMachine from "./PolarMachine"
3 |
4 | export const machineFactory = {
5 | rectangular: RectMachine,
6 | polar: PolarMachine,
7 | }
8 |
9 | export const getMachine = (state) => {
10 | if (typeof state === "string") {
11 | // "new" machine case
12 | state = { type: state }
13 | }
14 |
15 | return new machineFactory[state.type](state)
16 | }
17 |
18 | export const getDefaultMachineType = () => {
19 | return localStorage.getItem("defaultMachine") || "rectangular"
20 | }
21 |
22 | export const getDefaultMachine = () => {
23 | return getMachine(getDefaultMachineType())
24 | }
25 |
26 | export const getMachineSelectOptions = () => {
27 | const types = Object.keys(machineFactory)
28 |
29 | return types.map((type) => {
30 | return { value: type, label: getMachine(type).label }
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/src/features/machines/util.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import { annotateVertices } from "@/common/geometry"
3 | import { getMachine } from "./machineFactory"
4 |
5 | // looks for vertices with connect = true. When found, adds vertices that connect that vertex
6 | // with the next one in the array along the machine perimeter. Returns a modified array that
7 | // includes connectors.
8 | export const connectMarkedVerticesAlongMachinePerimeter = (
9 | vertices,
10 | machineState,
11 | ) => {
12 | const machine = getMachine(machineState)
13 | const newVertices = []
14 |
15 | for (let i = 0; i < vertices.length; i++) {
16 | const vertex = vertices[i]
17 |
18 | newVertices.push(vertex)
19 | if (vertex.connector) {
20 | vertex.hidden = false
21 | vertex.connect = true
22 |
23 | // connect the next two vertices along the machine perimeter
24 | const next = vertices[i + 1]
25 |
26 | if (next) {
27 | next.connect = true
28 |
29 | const clipped = machine.clipLine(
30 | new Victor(vertex.x - machine.sizeX * 2, vertex.y),
31 | new Victor(vertex.x + machine.sizeX * 2, vertex.y),
32 | )
33 | const clipped2 = machine.clipLine(
34 | new Victor(next.x - machine.sizeX * 2, next.y),
35 | new Victor(next.x + machine.sizeX * 2, next.y),
36 | )
37 |
38 | const connector = annotateVertices(
39 | [
40 | clipped[1],
41 | ...machine.tracePerimeter(clipped[1], clipped2[0]),
42 | clipped2[0],
43 | ],
44 | { connect: true },
45 | )
46 |
47 | newVertices.push(connector)
48 | }
49 | }
50 | }
51 |
52 | return newVertices.flat()
53 | }
54 |
--------------------------------------------------------------------------------
/src/features/preview/PreviewHelper.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import colors from "@/common/colors"
3 |
4 | // translates shape coordinates into pixel coordinates with a centered origin
5 | export default class PreviewHelper {
6 | constructor(props) {
7 | this.props = props
8 | this.width = this.props.layer.width || 0
9 | this.height = this.props.layer.height || 0
10 | }
11 |
12 | toPixels(vertex) {
13 | // y for pixels starts at the top, and goes down
14 | if (vertex) {
15 | return new Victor(vertex.x + this.width / 2, -vertex.y + this.height / 2)
16 | } else {
17 | return new Victor(0, 0)
18 | }
19 | }
20 |
21 | moveTo(context, vertex) {
22 | const px = this.toPixels(vertex)
23 | context.moveTo(px.x, px.y)
24 | }
25 |
26 | lineTo(context, vertex) {
27 | const px = this.toPixels(vertex)
28 | context.lineTo(px.x, px.y)
29 | }
30 |
31 | dot(context, vertex, radius = 4, color = colors.selectedShapeColor) {
32 | const px = this.toPixels(vertex)
33 | context.arc(px.x, px.y, radius, 0, 2 * Math.PI, true)
34 | context.fillStyle = context.strokeStyle
35 | context.fill()
36 | context.lineWidth = 1
37 | context.strokeStyle = color
38 | context.stroke()
39 | }
40 |
41 | drawSliderEndPoint(context) {
42 | const { end } = this.props.bounds
43 |
44 | // Draw a slider path end point if sliding
45 | if (this.props.sliderValue !== 0) {
46 | const offsets = this.props.offsets[this.props.offsetId]
47 |
48 | // If the offset is past the end, then we won't set the slider end
49 | if (offsets && end >= offsets.start && end <= offsets.end) {
50 | const sliderEnd = this.props.vertices[end - offsets.start]
51 |
52 | if (sliderEnd) {
53 | context.beginPath()
54 |
55 | context.strokeStyle = "transparent"
56 | this.dot(context, sliderEnd)
57 | context.stroke()
58 | }
59 | }
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/features/preview/PreviewManager.scss:
--------------------------------------------------------------------------------
1 | #preview-main {
2 | background-color: #333;
3 | }
4 |
5 | .preview-wrapper {
6 | overflow: auto;
7 |
8 | .konvajs-content {
9 | margin: 0 auto;
10 | }
11 |
12 | @media (max-width: 991px) {
13 | height: 400px;
14 | }
15 |
16 | @media (min-width: 992px) {
17 | padding-top: 0.5rem;
18 | padding-bottom: 0.5rem;
19 | height: calc(100vh - 118px);
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/features/preview/PreviewStats.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { useSelector } from "react-redux"
3 | import Col from "react-bootstrap/Col"
4 | import Row from "react-bootstrap/Row"
5 | import { selectVerticesStats } from "@/features/layers/layersSlice"
6 |
7 | const PreviewStats = () => {
8 | const verticesStats = useSelector(selectVerticesStats)
9 |
10 | return (
11 |
12 | Points
13 | {verticesStats.numPoints}
14 | Distance
15 | {verticesStats.distance}
16 |
17 | )
18 | }
19 |
20 | export default React.memo(PreviewStats)
21 |
--------------------------------------------------------------------------------
/src/features/preview/previewSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from "@reduxjs/toolkit"
2 | import { createSelector } from "reselect"
3 | import { selectState } from "@/features/app/appSlice"
4 |
5 | // ------------------------------
6 | // Slice, reducers and atomic actions
7 | // ------------------------------
8 |
9 | const previewSlice = createSlice({
10 | name: "preview",
11 | initialState: {
12 | canvasWidth: 600,
13 | canvasHeight: 600,
14 | sliderValue: 0.0,
15 | zoom: 1.0,
16 | },
17 | reducers: {
18 | updatePreview(state, action) {
19 | Object.assign(state, action.payload)
20 | },
21 | setPreviewSize(state, action) {
22 | state.canvasHeight = action.payload.height
23 | state.canvasWidth = action.payload.width
24 | },
25 | },
26 | })
27 |
28 | export const { updatePreview, setPreviewSize } = previewSlice.actions
29 | export default previewSlice.reducer
30 |
31 | // ------------------------------
32 | // Selectors
33 | // ------------------------------
34 |
35 | export const selectPreviewState = createSelector(
36 | selectState,
37 | (state) => state.preview,
38 | )
39 |
40 | export const selectPreviewSliderValue = createSelector(
41 | selectPreviewState,
42 | (state) => state.sliderValue,
43 | )
44 |
45 | export const selectPreviewZoom = createSelector(
46 | selectPreviewState,
47 | (state) => state.zoom,
48 | )
49 |
--------------------------------------------------------------------------------
/src/features/preview/previewSlice.spec.js:
--------------------------------------------------------------------------------
1 | import preview, { updatePreview, setPreviewSize } from "./previewSlice"
2 |
3 | describe("preview reducer", () => {
4 | it("should handle initial state", () => {
5 | expect(preview(undefined, {})).toEqual({
6 | canvasWidth: 600,
7 | canvasHeight: 600,
8 | sliderValue: 0,
9 | zoom: 1,
10 | })
11 | })
12 |
13 | it("should handle updatePreview", () => {
14 | expect(
15 | preview({ sliderValue: 0 }, updatePreview({ sliderValue: 50 })),
16 | ).toEqual({
17 | sliderValue: 50,
18 | })
19 | })
20 |
21 | it("should handle setPreviewSize", () => {
22 | expect(
23 | preview(
24 | { canvasWidth: 600, canvasHeight: 600 },
25 | setPreviewSize({ width: 800, height: 800 }),
26 | ),
27 | ).toEqual({
28 | canvasWidth: 800,
29 | canvasHeight: 800,
30 | })
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/src/features/shapes/Circle.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | circleLobes: {
6 | title: "Number of lobes",
7 | min: 1,
8 | randomMax: 6,
9 | },
10 | circleDirection: {
11 | title: "Direction",
12 | type: "togglebutton",
13 | choices: ["clockwise", "counterclockwise"],
14 | },
15 | }
16 |
17 | export default class Circle extends Shape {
18 | constructor() {
19 | super("circle")
20 | this.label = "Circle"
21 | }
22 |
23 | getInitialState() {
24 | return {
25 | ...super.getInitialState(),
26 | ...{
27 | circleLobes: 1,
28 | circleDirection: "clockwise",
29 | maintainAspectRatio: true,
30 | },
31 | }
32 | }
33 |
34 | getVertices(state) {
35 | let points = []
36 |
37 | if (state.shape.circleDirection === "counterclockwise") {
38 | for (let i = 128; i >= 0; i--) {
39 | let angle = ((Math.PI * 2.0) / 128.0) * i
40 |
41 | points.push(
42 | new Victor(
43 | Math.cos(angle),
44 | Math.sin(state.shape.circleLobes * angle) / state.shape.circleLobes,
45 | ),
46 | )
47 | }
48 | } else {
49 | for (let i = 0; i <= 128; i++) {
50 | let angle = ((Math.PI * 2.0) / 128.0) * i
51 |
52 | points.push(
53 | new Victor(
54 | Math.cos(angle),
55 | Math.sin(state.shape.circleLobes * angle) / state.shape.circleLobes,
56 | ),
57 | )
58 | }
59 | }
60 |
61 | return points
62 | }
63 |
64 | getOptions() {
65 | return options
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/features/shapes/Epicycloid.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 | import { reduce } from "@/common/util"
4 |
5 | const options = {
6 | epicycloidA: {
7 | title: "Large circle radius",
8 | min: 1,
9 | randomMax: 16,
10 | },
11 | epicycloidB: {
12 | title: "Small circle radius",
13 | min: 1,
14 | randomMax: 16,
15 | random: 0.6,
16 | },
17 | }
18 |
19 | export default class Epicycloid extends Shape {
20 | constructor() {
21 | super("epicycloid")
22 | this.label = "Clover"
23 | this.link = "http://mathworld.wolfram.com/Epicycloid.html"
24 | this.linkText = "Wolfram Mathworld"
25 | this.description =
26 | "The clover shape is an epicycloid. Imagine two circles, with an outer circle rolling around an inner one. The path created by a point on the outer circle as it rolls is called an epicycloid."
27 | }
28 |
29 | getInitialState() {
30 | return {
31 | ...super.getInitialState(),
32 | ...{
33 | epicycloidA: 4,
34 | epicycloidB: 1,
35 | },
36 | }
37 | }
38 |
39 | getVertices(state) {
40 | let points = []
41 | let a = parseInt(state.shape.epicycloidA)
42 | let b = parseInt(state.shape.epicycloidB)
43 | let reduced = reduce(a, b)
44 |
45 | a = reduced[0]
46 | b = reduced[1]
47 |
48 | let rotations = Number.isInteger(a / b) ? 1 : b
49 | let scale = 1 / (a + 2 * b)
50 |
51 | for (let i = 0; i < 128 * rotations; i++) {
52 | let angle = ((Math.PI * 2.0) / 128.0) * i
53 |
54 | points.push(
55 | new Victor(
56 | (a + b) * Math.cos(angle) - b * Math.cos(((a + b) / b) * angle),
57 | (a + b) * Math.sin(angle) - b * Math.sin(((a + b) / b) * angle),
58 | ).multiply({ x: scale, y: scale }),
59 | )
60 | }
61 |
62 | return points
63 | }
64 |
65 | getOptions() {
66 | return options
67 | }
68 |
69 | randomChanges(layer) {
70 | let changes = {}
71 |
72 | while (changes.epicycloidA == changes.epicycloidB) {
73 | changes = super.randomChanges(layer)
74 | }
75 |
76 | return changes
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/features/shapes/Freeform.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | freeformPoints: {
6 | title: "Points",
7 | type: "input",
8 | },
9 | }
10 |
11 | export default class Freeform extends Shape {
12 | constructor() {
13 | super("freeform")
14 | this.startingWidth = 50
15 | this.startingHeight = 50
16 | }
17 |
18 | canChangeAspectRatio(state) {
19 | return false
20 | }
21 |
22 | getInitialState() {
23 | return {
24 | ...super.getInitialState(),
25 | ...{
26 | freeformPoints: "-1,-1;-1,1;1,1",
27 | },
28 | }
29 | }
30 |
31 | getVertices(state) {
32 | return state.shape.freeformPoints.split(";").map((pair) => {
33 | const coordinates = pair.split(",")
34 | return new Victor(coordinates[0], coordinates[1])
35 | })
36 | }
37 |
38 | getOptions() {
39 | return options
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/features/shapes/Heart.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | export default class Heart extends Shape {
5 | constructor() {
6 | super("heart")
7 | this.label = "Heart"
8 | this.description =
9 | "Heart curves can be defined mathematically a number of different ways. Our heart shape is a parametric equation."
10 | this.link = "http://mathworld.wolfram.com/HeartCurve.html"
11 | this.linkText = "Wolfram Mathworld"
12 | this.randomizable = false
13 | }
14 |
15 | getInitialState() {
16 | return {
17 | ...super.getInitialState(),
18 | ...{
19 | // no custom attributes
20 | },
21 | }
22 | }
23 |
24 | // heart equation from: http://mathworld.wolfram.com/HeartCurve.html
25 | getVertices(state) {
26 | const points = []
27 |
28 | for (let i = 0; i < 128; i++) {
29 | let angle = ((Math.PI * 2.0) / 128.0) * i
30 | let scale = 0.9
31 |
32 | points.push(
33 | new Victor(
34 | scale * 1.0 * Math.pow(Math.sin(angle), 3),
35 | scale *
36 | ((13.0 / 16.0) * Math.cos(angle) +
37 | (-5.0 / 16.0) * Math.cos(2.0 * angle) +
38 | (-2.0 / 16.0) * Math.cos(3.0 * angle) +
39 | (-1.0 / 16.0) * Math.cos(4.0 * angle)),
40 | ),
41 | )
42 | }
43 | return points
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/features/shapes/Hypocycloid.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import { reduce } from "@/common/util"
3 | import Shape from "./Shape"
4 |
5 | const options = {
6 | hypocycloidA: {
7 | title: "Large circle radius",
8 | min: 1,
9 | randomMax: 16,
10 | },
11 | hypocycloidB: {
12 | title: "Small circle radius",
13 | min: 1,
14 | randomMax: 16,
15 | random: 0.6,
16 | },
17 | }
18 |
19 | export default class Star extends Shape {
20 | constructor() {
21 | super("hypocycloid")
22 | this.label = "Web"
23 | this.link = "http://mathworld.wolfram.com/Hypocycloid.html"
24 | this.linkText = "Wolfram Mathworld"
25 | this.description =
26 | "The web shape is a hypocycloid. Imagine two circles, with an inner circle rolling around inside an outer one. The path created by a point on the inner circle as it rolls is called a hypocycloid."
27 | }
28 |
29 | getInitialState() {
30 | return {
31 | ...super.getInitialState(),
32 | ...{
33 | hypocycloidA: 6,
34 | hypocycloidB: 1,
35 | },
36 | }
37 | }
38 |
39 | getVertices(state) {
40 | let points = []
41 | let a = parseInt(state.shape.hypocycloidA)
42 | let b = parseInt(state.shape.hypocycloidB)
43 | let reduced = reduce(a, b)
44 | a = reduced[0]
45 | b = reduced[1]
46 | let rotations = Number.isInteger(a / b) ? 1 : b
47 | let scale = b < a ? 1 / a : 1 / (2 * (b - a / 2))
48 |
49 | for (let i = 0; i < 128 * rotations; i++) {
50 | let angle = ((Math.PI * 2.0) / 128.0) * i
51 | points.push(
52 | new Victor(
53 | (a - b) * Math.cos(angle) + b * Math.cos(((a - b) / b) * angle),
54 | (a - b) * Math.sin(angle) - b * Math.sin(((a - b) / b) * angle),
55 | ).multiply({ x: scale, y: scale }),
56 | )
57 | }
58 |
59 | return points
60 | }
61 |
62 | getOptions() {
63 | return options
64 | }
65 |
66 | randomChanges(layer) {
67 | let changes = {}
68 |
69 | while (
70 | changes.hypocycloidA == changes.hypocycloidB ||
71 | changes.hypocycloidA / changes.hypocycloidB == 2
72 | ) {
73 | changes = super.randomChanges(layer)
74 | }
75 |
76 | return changes
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/features/shapes/LayerImport.js:
--------------------------------------------------------------------------------
1 | import { resizeVertices, dimensions, cloneVertices } from "@/common/geometry"
2 | import { getMachine } from "@/features/machines/machineFactory"
3 | import Shape from "./Shape"
4 |
5 | const options = {
6 | fileName: {
7 | title: "Source file",
8 | type: "inputText",
9 | plainText: "true",
10 | },
11 | }
12 |
13 | export default class LayerImport extends Shape {
14 | constructor() {
15 | super("fileImport")
16 | this.label = "Import"
17 | this.usesMachine = true
18 | this.selectGroup = "import"
19 | this.randomizable = false
20 | }
21 |
22 | getInitialState(props) {
23 | return {
24 | ...super.getInitialState(),
25 | ...{
26 | vertices: [],
27 | maintainAspectRatio: true,
28 | fileName: "",
29 | },
30 | ...(props === undefined
31 | ? {}
32 | : {
33 | fileName: props.fileName,
34 | vertices: props.vertices,
35 | }),
36 | }
37 | }
38 |
39 | initialDimensions(props) {
40 | if (!props) {
41 | // undefined during import integrity checks
42 | return {
43 | width: 0,
44 | height: 0,
45 | aspectRatio: 1,
46 | }
47 | }
48 |
49 | const vertices = cloneVertices(props.vertices)
50 | const machine = getMachine(props.machine)
51 |
52 | // default to 80% of machine size
53 | resizeVertices(vertices, machine.width * 0.8, machine.height * 0.8)
54 |
55 | return dimensions(vertices)
56 | }
57 |
58 | getVertices(state) {
59 | return cloneVertices(state.shape.vertices)
60 | }
61 |
62 | getOptions() {
63 | return options
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/features/shapes/Point.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | export default class Point extends Shape {
5 | constructor() {
6 | super("point")
7 | this.label = "Point"
8 | this.startingWidth = 1
9 | this.startingHeight = 1
10 | this.shouldCache = false
11 | this.autosize = false
12 | this.randomizable = false
13 | }
14 |
15 | canChangeSize(state) {
16 | return false
17 | }
18 |
19 | canRotate(state) {
20 | return false
21 | }
22 |
23 | getInitialState() {
24 | return {
25 | ...super.getInitialState(),
26 | ...{
27 | // no custom attributes
28 | },
29 | }
30 | }
31 |
32 | getVertices(state) {
33 | return [new Victor(0.0, 0.0)]
34 | }
35 |
36 | finalizeVertices(vertices, state, options) {
37 | if (!options.bounds) {
38 | return vertices
39 | }
40 |
41 | // used to calculate bounds of the shape to "hit" purposes in the preview
42 | const point = vertices[0]
43 |
44 | return [
45 | new Victor(-20.0, -20.0).add(point),
46 | new Victor(20.0, 20.0).add(point),
47 | ]
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/features/shapes/Polygon.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | polygonSides: {
6 | title: "Number of sides",
7 | min: 3,
8 | randomMax: 12,
9 | },
10 | roundCorners: {
11 | title: "Round corners",
12 | type: "checkbox",
13 | },
14 | roundFraction: {
15 | title: "Round fraction",
16 | min: 0.05,
17 | max: 0.5,
18 | step: 0.025,
19 | isVisible: (layer, state) => {
20 | return state.roundCorners
21 | },
22 | },
23 | }
24 |
25 | export default class Polygon extends Shape {
26 | constructor() {
27 | super("polygon")
28 | this.label = "Polygon"
29 | }
30 |
31 | getInitialState() {
32 | return {
33 | ...super.getInitialState(),
34 | ...{
35 | type: "polygon",
36 | polygonSides: 4,
37 | roundCorners: false,
38 | roundFraction: 0.25,
39 | maintainAspectRatio: true,
40 | },
41 | }
42 | }
43 |
44 | getOptions() {
45 | return options
46 | }
47 |
48 | getVertices(state) {
49 | // beta is the fraction to have rounded.
50 | const beta = state.shape.roundFraction
51 |
52 | // alpha is the fration to have straight.
53 | const alpha = 1.0 - beta
54 |
55 | let points = []
56 |
57 | for (let i = 0; i <= state.shape.polygonSides; i++) {
58 | const angle = ((Math.PI * 2.0) / state.shape.polygonSides) * (0.5 + i)
59 |
60 | if (state.shape.roundCorners && beta !== 0.0) {
61 | // angles that make up the arc.
62 | const sides = state.shape.polygonSides
63 | const angleStart = ((Math.PI * 2.0) / sides) * i
64 | const angleEnd = ((Math.PI * 2.0) / sides) * (i + 1)
65 | const angleResolution = 0.1
66 |
67 | if (points.length > 0) {
68 | // Start with a line. We use a bunch of points for this, so they get stretch about evenly
69 | // as the curves do.
70 | const numberOfLinePoints =
71 | (angleEnd - angleStart) / angleResolution / beta
72 |
73 | points = points.concat(
74 | this.getLineVertices(
75 | points[points.length - 1],
76 | new Victor(
77 | alpha * Math.cos(angle) + beta * Math.cos(angleStart),
78 | alpha * Math.sin(angle) + beta * Math.sin(angleStart),
79 | ),
80 | numberOfLinePoints,
81 | ),
82 | )
83 | }
84 | if (i !== sides) {
85 | // Create the arc.
86 | for (
87 | let arcAngle = angleStart + angleResolution;
88 | arcAngle <= angleEnd;
89 | arcAngle += angleResolution
90 | ) {
91 | points.push(
92 | new Victor(
93 | alpha * Math.cos(angle) + beta * Math.cos(arcAngle),
94 | alpha * Math.sin(angle) + beta * Math.sin(arcAngle),
95 | ),
96 | )
97 | }
98 | } else {
99 | points.push(new Victor(points[0].x, points[0].y))
100 | }
101 | } else {
102 | // Not rounded corners.
103 | points.push(new Victor(Math.cos(angle), Math.sin(angle)))
104 | }
105 | }
106 |
107 | return points
108 | }
109 |
110 | // Returns a list of points from (start, end] along the line.
111 | getLineVertices(startPoint, endPoint, numberOfPoints) {
112 | const resolution = 1.0 / numberOfPoints
113 | let points = []
114 |
115 | for (let d = resolution; d <= 1.0; d += resolution) {
116 | points.push(
117 | new Victor(
118 | startPoint.x + (endPoint.x - startPoint.x) * d,
119 | startPoint.y + (endPoint.y - startPoint.y) * d,
120 | ),
121 | )
122 | }
123 | return points
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/src/features/shapes/Reuleaux.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | reuleauxSides: {
6 | title: "Number of sides",
7 | step: 1,
8 | min: 2,
9 | randomMax: 8,
10 | },
11 | }
12 |
13 | export default class Reuleaux extends Shape {
14 | constructor() {
15 | super("reuleaux")
16 | this.label = "Reuleaux"
17 | this.description =
18 | "A reuleaux polygon is a curve of constant width made up of circular arcs of constant radius. It's named after Frances Reuleaux, a 19th century German engineer who used Reuleaux triangles in his designs."
19 | this.link = "https://en.wikipedia.org/wiki/Reuleaux_polygon"
20 | this.linkText = "Wikipedia"
21 | }
22 |
23 | getInitialState() {
24 | return {
25 | ...super.getInitialState(),
26 | ...{
27 | reuleauxSides: 3,
28 | },
29 | }
30 | }
31 |
32 | getVertices(state) {
33 | let points = []
34 |
35 | // Construct an equilateral triangle
36 | let corners = []
37 |
38 | // Initial location at PI/2
39 | let angle = Math.PI / 2.0
40 |
41 | // How much of the circle in one side?
42 | let coverageAngle = Math.PI / state.shape.reuleauxSides
43 | let halfCoverageAngle = 0.5 * coverageAngle
44 |
45 | for (let c = 0; c < state.shape.reuleauxSides; c++) {
46 | let startAngle = angle + Math.PI - halfCoverageAngle
47 |
48 | corners.push([new Victor(Math.cos(angle), Math.sin(angle)), startAngle])
49 | angle += (2.0 * Math.PI) / state.shape.reuleauxSides
50 | }
51 |
52 | let length = 0.5 / Math.cos(Math.PI / 2.0 / state.shape.reuleauxSides)
53 | const scale = 1.7
54 |
55 | for (let corn = 0; corn < corners.length; corn++) {
56 | for (let i = 0; i < 128; i++) {
57 | let angle = coverageAngle * (i / 128.0) + corners[corn][1]
58 |
59 | points.push(
60 | new Victor(
61 | scale * (length * corners[corn][0].x + Math.cos(angle)),
62 | scale * (length * corners[corn][0].y + Math.sin(angle)),
63 | ),
64 | )
65 | }
66 | }
67 |
68 | return points
69 | }
70 |
71 | getOptions() {
72 | return options
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/features/shapes/Rose.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | roseN: {
6 | title: "Numerator",
7 | step: 1,
8 | min: 1,
9 | randomMax: 16,
10 | },
11 | roseD: {
12 | title: "Denominator",
13 | step: 1,
14 | min: 1,
15 | randomMax: 16,
16 | },
17 | }
18 |
19 | export default class Rose extends Shape {
20 | constructor() {
21 | super("rose")
22 | this.label = "Rose"
23 | this.link = "https://mathworld.wolfram.com/RoseCurve.html"
24 | this.linkText = "Wolfram Mathworld"
25 | this.description =
26 | "A rose curve is a curve which has the shape of a petalled flower."
27 | }
28 |
29 | getInitialState() {
30 | return {
31 | ...super.getInitialState(),
32 | ...{
33 | roseN: 3,
34 | roseD: 2,
35 | transformMethod: "intact",
36 | },
37 | }
38 | }
39 |
40 | getVertices(state) {
41 | let points = []
42 | let a = 2
43 | let n = parseInt(state.shape.roseN)
44 | let d = parseInt(state.shape.roseD)
45 | let p = (n * d) % 2 === 0 ? 2 : 1
46 | let thetaClose = d * p * 32 * n
47 | let resolution = 64 * n
48 |
49 | for (let i = 0; i < thetaClose + 1; i++) {
50 | let theta = ((Math.PI * 2.0) / resolution) * i
51 | let r = 0.5 * a * Math.sin((n / d) * theta)
52 |
53 | points.push(new Victor(r * Math.cos(theta), r * Math.sin(theta)))
54 | }
55 |
56 | return points
57 | }
58 |
59 | getOptions() {
60 | return options
61 | }
62 |
63 | randomChanges(layer) {
64 | let changes = {}
65 |
66 | while (changes.roseN == changes.roseD) {
67 | changes = super.randomChanges(layer)
68 | }
69 |
70 | return changes
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/features/shapes/Shape.js:
--------------------------------------------------------------------------------
1 | import { resizeVertices, dimensions, cloneVertices } from "@/common/geometry"
2 | import { pick } from "lodash"
3 | import { LRUCache } from "lru-cache"
4 | import Model from "@/common/Model"
5 |
6 | const cache = new LRUCache({
7 | length: (n, key) => {
8 | return n.length
9 | },
10 | max: 500000,
11 | })
12 |
13 | export default class Shape extends Model {
14 | constructor(type) {
15 | super(type)
16 | this.cache = []
17 |
18 | Object.assign(this, {
19 | selectGroup: "Shapes",
20 | shouldCache: true,
21 | autosize: true,
22 | startingWidth: 100,
23 | startingHeight: 100,
24 | maintainAspectRatio: false,
25 | })
26 | }
27 |
28 | // calculates the initial dimensions of the model
29 | initialDimensions(props) {
30 | const { width, height } = this.recalculateDimensions(
31 | props,
32 | this.startingWidth,
33 | this.startingHeight,
34 | )
35 | return {
36 | width,
37 | height,
38 | aspectRatio: width / height,
39 | }
40 | }
41 |
42 | recalculateDimensions(props, width, height) {
43 | if (this.autosize) {
44 | const vertices = this.initialVertices(props)
45 |
46 | resizeVertices(vertices, width, height, false)
47 |
48 | return dimensions(vertices)
49 | } else {
50 | return {
51 | width,
52 | height,
53 | }
54 | }
55 | }
56 |
57 | // returns an array of vertices used to calculate the initial width and height of a model
58 | initialVertices(props) {
59 | return this.getVertices({
60 | shape: this.getInitialState(props),
61 | creating: true,
62 | })
63 | }
64 |
65 | getCacheKey(state) {
66 | // include only model values in key
67 | const cacheState = { ...state }
68 | cacheState.shape = pick(cacheState.shape, [
69 | ...Object.keys(this.getOptions()),
70 | "imageId",
71 | "id",
72 | ])
73 | cacheState.type = state.shape.type
74 | cacheState.dragging = state.shape.dragging
75 |
76 | return JSON.stringify(cacheState)
77 | }
78 |
79 | // override as needed; returns an array of Victor vertices that render
80 | // the shape with the specific options
81 | getVertices(state) {
82 | return []
83 | }
84 |
85 | getCachedVertices(state) {
86 | if (this.shouldCache) {
87 | const key = this.getCacheKey(state)
88 | let vertices = cache.get(key)
89 |
90 | if (!vertices) {
91 | vertices = this.getVertices(state)
92 |
93 | if (vertices.length > 1) {
94 | cache.set(key, vertices)
95 | }
96 | }
97 |
98 | // return a copy of these vertices even though it's coming from the cache, because
99 | // downstream logic is modifying them directly; it's the computation of the vertices
100 | // that can be expensive, not the copying.
101 | return cloneVertices(vertices)
102 | } else {
103 | return this.getVertices(state)
104 | }
105 | }
106 |
107 | // override as needed to do final vertex modifications after all layer transformations are
108 | // complete. Returns an array of vertices.
109 | finalizeVertices(vertices, state) {
110 | return vertices
111 | }
112 |
113 | // override as needed; hook to modify updates to a layer before they affect the state
114 | handleUpdate(changes) {
115 | // default is to do nothing
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/features/shapes/Star.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "./Shape"
3 |
4 | const options = {
5 | points: {
6 | title: "Number of points",
7 | min: 2,
8 | randomMax: 8,
9 | },
10 | starRatio: {
11 | title: "Size of points",
12 | step: 0.05,
13 | min: 0.05,
14 | max: 0.8,
15 | },
16 | }
17 |
18 | export default class Star extends Shape {
19 | constructor() {
20 | super("star")
21 | this.label = "Star"
22 | }
23 |
24 | getInitialState() {
25 | return {
26 | ...super.getInitialState(),
27 | ...{
28 | points: 5,
29 | starRatio: 0.5,
30 | },
31 | }
32 | }
33 |
34 | getOptions() {
35 | return options
36 | }
37 |
38 | getVertices(state) {
39 | let points = []
40 |
41 | for (let i = 0; i <= state.shape.points * 2; i++) {
42 | let angle = ((Math.PI * 2.0) / (2.0 * state.shape.points)) * i
43 | let star_scale = 1.0
44 |
45 | if (i % 2 === 0) {
46 | star_scale *= state.shape.starRatio
47 | }
48 | points.push(
49 | new Victor(star_scale * Math.cos(angle), star_scale * Math.sin(angle)),
50 | )
51 | }
52 |
53 | return points
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/features/shapes/fractal_spirograph/FractalSpirograph.js:
--------------------------------------------------------------------------------
1 | import Victor from "victor"
2 | import Shape from "../Shape"
3 | import Orbit from "./Orbit"
4 |
5 | const options = {
6 | fractalSpirographVelocity: {
7 | title: "Velocity",
8 | min: 2,
9 | },
10 | fractalSpirographResolution: {
11 | title: "Resolution",
12 | min: 1,
13 | },
14 | fractalSpirographNumCircles: {
15 | title: "Number of circles",
16 | min: 1,
17 | max: 6,
18 | randomMin: 2,
19 | },
20 | fractalSpirographRelativeSize: {
21 | title: "Relative size (parent to child circle)",
22 | min: 2,
23 | max: 6,
24 | },
25 | fractalSpirographAlternateRotation: {
26 | title: "Alternate rotation direction",
27 | type: "checkbox",
28 | },
29 | }
30 |
31 | // Inspired/adapted from https://thecodingtrain.com/CodingChallenges/061-fractal-spirograph
32 | // No license was specified.
33 | export default class FractalSpirograph extends Shape {
34 | constructor() {
35 | super("fractalSpirograph")
36 | this.label = "Fractal spirograph"
37 | this.link =
38 | "https://softologyblog.wordpress.com/2017/02/27/fractal-spirographs/"
39 | this.linkText = "this blog post"
40 | this.description =
41 | "Fractal spirographs are generated by a series of circles rotating around each other. The pattern is created by tracking a point as it rolls along the outermost circle."
42 | }
43 |
44 | getInitialState() {
45 | return {
46 | ...super.getInitialState(),
47 | ...{
48 | fractalSpirographVelocity: 8,
49 | fractalSpirographResolution: 2,
50 | fractalSpirographNumCircles: 5,
51 | fractalSpirographRelativeSize: 3,
52 | fractalSpirographAlternateRotation: true,
53 | },
54 | }
55 | }
56 |
57 | getVertices(state) {
58 | let resolution = parseInt(state.shape.fractalSpirographResolution)
59 | let settings = {
60 | resolution,
61 | velocity: parseInt(state.shape.fractalSpirographVelocity),
62 | numCircles: parseInt(state.shape.fractalSpirographNumCircles),
63 | relativeSize: parseInt(state.shape.fractalSpirographRelativeSize),
64 | alternateRotation: state.shape.fractalSpirographAlternateRotation,
65 | }
66 |
67 | let sun = new Orbit(0, 0, 1, 0, settings)
68 | let next = sun
69 | let end
70 | let points = []
71 |
72 | for (var i = 0; i < settings.numCircles; i++) {
73 | next = next.addChild()
74 | }
75 | end = next
76 |
77 | for (let i = 0; i < resolution; i++) {
78 | for (let j = 0; j < 361; j++) {
79 | let next = sun
80 |
81 | while (next != null) {
82 | next.update()
83 | next = next.child
84 | }
85 |
86 | points.push(new Victor(end.x, end.y))
87 | }
88 | }
89 |
90 | const scale = 5 // to normalize starting size
91 |
92 | points.forEach((point) => point.multiply({ x: scale, y: scale }))
93 |
94 | return points
95 | }
96 |
97 | getOptions() {
98 | return options
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/features/shapes/fractal_spirograph/Orbit.js:
--------------------------------------------------------------------------------
1 | export default class Orbit {
2 | constructor(x, y, r, level, settings, parent) {
3 | this.x = x
4 | this.y = y
5 | this.r = r
6 | this.child = null
7 | this.angle = Math.PI / 2
8 | this.level = level
9 | this.settings = settings
10 |
11 | let sign = this.settings.alternateRotation ? -1 : 1
12 | this.speed =
13 | (Math.pow(settings.velocity * sign, this.level - 1) * Math.PI) /
14 | 180 /
15 | settings.resolution
16 | this.parent = parent
17 | }
18 |
19 | addChild() {
20 | let newr = this.r / this.settings.relativeSize
21 | let newx = this.x + this.r + newr
22 | let newy = this.y
23 | this.child = new Orbit(
24 | newx,
25 | newy,
26 | newr,
27 | this.level + 1,
28 | this.settings,
29 | this,
30 | )
31 | return this.child
32 | }
33 |
34 | update() {
35 | if (this.parent) {
36 | this.angle += this.speed
37 |
38 | let rsum = this.r + this.parent.r
39 | this.x = this.parent.x + rsum * Math.cos(this.angle)
40 | this.y = this.parent.y + rsum * Math.sin(this.angle)
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Tim Alex Jacobs (mitxela)
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 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/polyspiral.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const polyspiral = (config, data) => {
5 | const getPixel = pixelProcessor(config, data)
6 | let r = 5
7 | let a = 0
8 | const cx = config.width / 2
9 | const cy = config.height / 2
10 | let points = []
11 |
12 | points.push(new Victor(cx, config.height - cy))
13 |
14 | let x = cx,
15 | y = cy
16 | let theta = 0
17 | let travelled = 0
18 | let segmentLength = 1
19 | const pi = Math.PI
20 | let incrTheta = (2 * pi) / config.Polygon
21 | let incrLength = Math.round(10 / config.Polygon)
22 |
23 | while (x > 0 && y > 0 && x < config.width && y < config.height) {
24 | const z = getPixel(x, y)
25 | r = config.Amplitude * z * 0.02 * config.Spacing
26 | a += z / config.Frequency
27 |
28 | let displacement = Math.sin(a) * r
29 |
30 | points.push(
31 | new Victor(
32 | x - displacement * Math.sin(theta),
33 | config.height - (y + displacement * Math.cos(theta)),
34 | ),
35 | )
36 |
37 | if (++travelled >= segmentLength) {
38 | travelled = 0
39 | theta += incrTheta
40 | segmentLength += incrLength
41 | }
42 |
43 | x += config.Spacing * Math.cos(theta)
44 | y += config.Spacing * Math.sin(theta)
45 | }
46 |
47 | return points
48 | }
49 |
50 | export default polyspiral
51 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/sawtooth.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor, buildLines } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const sawtooth = (config, data) => {
5 | return buildLines(buildLine, config, data)
6 | }
7 |
8 | const buildLine = (y, config, data) => {
9 | const getPixel = pixelProcessor(config, data)
10 | const lineCount = config.LineCount
11 | const amplitude = config.Amplitude / 3.141
12 | const frequency = config.Frequency
13 | const incr_x = config.Sampling
14 |
15 | let a = 0
16 | const vertices = []
17 |
18 | for (let x = 0; x <= config.width; x += incr_x) {
19 | const z = getPixel(x, y)
20 | let r = (amplitude * z) / lineCount
21 |
22 | a += z / frequency
23 | if (a > 6.35) a -= 6.35
24 |
25 | vertices.push([new Victor(x, config.height - (y + a * r)), z])
26 | }
27 |
28 | return vertices
29 | }
30 |
31 | export default sawtooth
32 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/spiral.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const spiral = (config, data) => {
5 | const getPixel = pixelProcessor(config, data)
6 | let r = 5
7 | let a = 0
8 | const cx = config.width / 2
9 | const cy = config.height / 2
10 | let points = []
11 |
12 | points.push(new Victor(cx, config.height - cy))
13 |
14 | let x = cx,
15 | y = cy
16 | let radius = 1
17 | let theta = 0
18 |
19 | while (x > 0 && y > 0 && x < config.width && y < config.height) {
20 | const z = getPixel(x, y)
21 | r = config.Amplitude * z * 0.02 * config.Spacing
22 | a += z / config.Frequency
23 |
24 | let tempradius = radius + Math.sin(a) * r
25 |
26 | points.push(
27 | new Victor(
28 | cx + tempradius * Math.sin(theta),
29 | config.height - (cy + tempradius * Math.cos(theta)),
30 | ),
31 | )
32 |
33 | let incr = Math.asin(1 / radius)
34 | radius += incr * config.Spacing
35 | theta += incr
36 |
37 | x = Math.floor(cx + radius * Math.sin(theta))
38 | y = Math.floor(cy + radius * Math.cos(theta))
39 | }
40 |
41 | return points
42 | }
43 |
44 | export default spiral
45 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/springs.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor, buildLines } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const springs = (config, data) => {
5 | return buildLines(buildLine, config, data)
6 | }
7 |
8 | const buildLine = (y, config, data) => {
9 | const getPixel = pixelProcessor(config, data)
10 | const direction = config.Direction == "clockwise" ? 1 : -1
11 | const pi = Math.PI
12 | const freq = ((config.Frequency * config.LineCount) / 2000) * direction
13 | const amplitude = config.Amplitude / config.LineCount
14 | const incr_x = config.Sampling
15 | const vertices = []
16 | let phase = 0
17 |
18 | for (let x = 0; x <= config.width; x += incr_x) {
19 | const z = getPixel(x, y)
20 | let a = amplitude * z
21 |
22 | phase += freq
23 | if (phase > pi) phase -= 2 * pi
24 |
25 | vertices.push([
26 | new Victor(
27 | x + a * Math.cos(phase),
28 | config.height - (y + a * Math.sin(phase)),
29 | ),
30 | z,
31 | ])
32 | }
33 |
34 | return vertices
35 | }
36 |
37 | export default springs
38 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/squiggle.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor, buildLines } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const squiggle = (config, data) => {
5 | return buildLines(buildLine, config, data)
6 | }
7 |
8 | const buildLine = (y, config, data) => {
9 | const getPixel = pixelProcessor(config, data)
10 | const lineCount = config.LineCount
11 | const amplitude = config.Amplitude
12 | const frequency = config.Frequency
13 | const incr_x = config.Sampling
14 | const AM = config.Modulation != "FM"
15 | const FM = config.Modulation != "AM"
16 |
17 | let a = 0
18 | const vertices = []
19 |
20 | for (let x = 0; x <= config.width; x += incr_x) {
21 | let z = getPixel(x, y)
22 | let r = (amplitude * (AM ? z : 255)) / lineCount
23 |
24 | a += (FM ? z : 255) / frequency
25 |
26 | vertices.push([new Victor(x, config.height - (y + Math.sin(a) * r)), z])
27 | }
28 |
29 | return vertices
30 | }
31 |
32 | export default squiggle
33 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/subtypes.js:
--------------------------------------------------------------------------------
1 | import sawtooth from "./sawtooth"
2 | import spiral from "./spiral"
3 | import springs from "./springs"
4 | import squiggle from "./squiggle"
5 | import polyspiral from "./polyspiral"
6 | import waves from "./waves"
7 |
8 | export const subtypes = {
9 | Squiggle: {
10 | algorithm: squiggle,
11 | settings: [
12 | "imageFrequency",
13 | "imageLineCount",
14 | "imageAmplitude",
15 | "imageSampling",
16 | "imageModulation",
17 | "imageBrightnessFilter",
18 | ],
19 | },
20 | Spiral: {
21 | algorithm: spiral,
22 | settings: ["imageFrequency", "imageAmplitude", "imageSpacing"],
23 | },
24 | "Polygon Spiral": {
25 | algorithm: polyspiral,
26 | settings: [
27 | "imageFrequency",
28 | "imagePolygon",
29 | "imageLineCount",
30 | "imageAmplitude",
31 | "imageSpacing",
32 | ],
33 | },
34 | Sawtooth: {
35 | algorithm: sawtooth,
36 | settings: [
37 | "imageFrequency",
38 | "imageLineCount",
39 | "imageAmplitude",
40 | "imageSpacing",
41 | "imageBrightnessFilter",
42 | ],
43 | },
44 | Springs: {
45 | algorithm: springs,
46 | settings: [
47 | "imageFrequency",
48 | "imageLineCount",
49 | "imageAmplitude",
50 | "imageSampling",
51 | "imageDirection",
52 | "imageBrightnessFilter",
53 | ],
54 | },
55 | Waves: {
56 | algorithm: waves,
57 | settings: ["imageAngle", "imageStepSize"],
58 | },
59 | }
60 |
61 | // some protection against bad data
62 | export const getSubtype = (subtype) => {
63 | return subtypes[subtype] || subtypes["Squiggle"]
64 | }
65 |
--------------------------------------------------------------------------------
/src/features/shapes/image_import/waves.js:
--------------------------------------------------------------------------------
1 | import { pixelProcessor, joinLines } from "./helpers"
2 | import Victor from "victor"
3 |
4 | const waves = (config, data) => {
5 | const getPixel = pixelProcessor(config, data)
6 | const pi = Math.PI
7 | const cos = Math.cos((config.Angle / 180) * pi)
8 | const sin = Math.sin((config.Angle / 180) * pi)
9 | const a = config.StepSize
10 | const w = config.width
11 | const h = config.height
12 | const L = Math.sqrt(w * w + h * h)
13 |
14 | let left = [],
15 | right = []
16 | let lastline,
17 | line = []
18 |
19 | function inside(x, y) {
20 | return x >= 0 && y >= 0 && x < w && y < h
21 | }
22 |
23 | function pix(x, y) {
24 | return inside(x, y)
25 | ? ((255 - getPixel(Math.floor(x), Math.floor(y))) * a) / 255
26 | : 0
27 | }
28 |
29 | // initial straight line
30 | let x = (w - L * cos) / 2
31 | let y = (h - L * sin) / 2
32 |
33 | for (let i = 0; i < L; i++) {
34 | x += cos
35 | y += sin
36 | line.push(new Victor(x, y))
37 | }
38 |
39 | left.push(line)
40 |
41 | for (let j = 0; j < L / 2 / a; j++) {
42 | lastline = line
43 | line = []
44 |
45 | for (let i = 0; i < L; i++) {
46 | x = lastline[i].x + sin * a
47 | y = lastline[i].y - cos * a
48 | let z = pix(x, y)
49 |
50 | x += sin * z
51 | y -= cos * z
52 | line.push(new Victor(x, y))
53 | }
54 |
55 | left.push(line)
56 | }
57 |
58 | line = left[0]
59 |
60 | for (let j = 0; j < L / 2 / a; j++) {
61 | lastline = line
62 | line = []
63 |
64 | for (let i = 0; i < L; i++) {
65 | x = lastline[i].x - sin * a
66 | y = lastline[i].y + cos * a
67 | let z = pix(x, y)
68 |
69 | x -= sin * z
70 | y += cos * z
71 | line.push(new Victor(x, y))
72 | }
73 |
74 | right.push(line)
75 | }
76 |
77 | right.reverse()
78 |
79 | let temp = right.concat(left)
80 | let output = []
81 |
82 | for (let i = 0; i < temp.length; i++) {
83 | let line = temp[i]
84 | let newline = []
85 |
86 | for (let j = 0; j < line.length; j++) {
87 | if (inside(line[j].x, line[j].y)) newline.push(line[j])
88 | }
89 | if (newline.length > 1) output.push(newline)
90 | }
91 |
92 | output.forEach((line) => {
93 | line.forEach((vertex) => {
94 | vertex.y = h - vertex.y
95 | })
96 | })
97 |
98 | return joinLines(output)
99 | }
100 |
101 | export default waves
102 |
--------------------------------------------------------------------------------
/src/features/shapes/input_text/convert_letters.py:
--------------------------------------------------------------------------------
1 | # This is a utility to convert the text from Ray to a format we can easily use in sandify.
2 |
3 | import string
4 |
5 | def getLetterCodex(infont):
6 | letters = \
7 | [' '] + \
8 | [letter for letter in string.digits + \
9 | string.ascii_uppercase + \
10 | string.ascii_lowercase] + \
11 | [' '] + \
12 | [letter + "*" for letter in string.ascii_lowercase] + \
13 | [',','?','&','$','!','%']
14 |
15 | if 'sanserif' in infont:
16 | # Add a vertex to the right, to increase the spacing a little.
17 | addSpace = True
18 | else:
19 | addSpace = False
20 | letterIndex = 0
21 | letterVertices = []
22 | letterCodex = []
23 | with open(infont, 'r') as abcd:
24 | for line in abcd:
25 | if not line.strip():
26 | continue
27 | values = line.split()
28 | xScale = 2.0
29 | scale = 8.5
30 | offsetY = -0.175-(0.149/8.5)
31 | vertex = (scale * float(values[1]) * xScale, scale * (float(values[2])+offsetY))
32 | # This is a new letter
33 | if values[0] == '1':
34 | if not letterVertices:
35 | print ("Empty Letter")
36 | else:
37 | if addSpace:
38 | # This distance, 0.02, gets varied in the original code... A lot.
39 | extraDistance = 0.02
40 | spaceVertex = (scale * xScale * extraDistance + letterVertices[-1][0], letterVertices[-1][1])
41 | letterVertices.append(spaceVertex)
42 | if letterIndex != 63:
43 | letterCodex.append((letters[letterIndex], letterVertices))
44 | letterIndex += 1
45 | letterVertices = []
46 | continue
47 |
48 | letterVertices.append(vertex)
49 | return letterCodex
50 |
51 |
52 | fonts = [ ('raysol_cursive.txt', 'raysol_cursive'),
53 | ('raysol_sanserif.txt','raysol_sanserif') ]
54 |
55 | for infont, outfont in fonts:
56 | with open(outfont + '.js', 'w') as output:
57 | output.write('export let ' + outfont + ' = {\n')
58 | for letter, vertices in getLetterCodex(infont):
59 | vertexString = ''
60 | for vertex in vertices:
61 | vertexString += "[%0.03f,%0.03f], " % vertex
62 | output.write(" '%s' : [ %s ],\n" % (letter, vertexString))
63 | output.write('}\n')
64 |
65 |
--------------------------------------------------------------------------------
/src/features/shapes/lsystem/LSystem.js:
--------------------------------------------------------------------------------
1 | import Shape from "../Shape"
2 | import {
3 | lsystem,
4 | lsystemPath,
5 | lsystemOptimize,
6 | onSubtypeChange,
7 | onMinIterations,
8 | onMaxIterations,
9 | } from "@/common/lindenmayer"
10 | import { subtypes } from "./subtypes"
11 | import { resizeVertices } from "@/common/geometry"
12 |
13 | const options = {
14 | subtype: {
15 | title: "Type",
16 | type: "dropdown",
17 | choices: Object.keys(subtypes),
18 | onChange: (model, changes, state) => {
19 | return onSubtypeChange(subtypes[changes.subtype], changes, state)
20 | },
21 | },
22 | iterations: {
23 | title: "Iterations",
24 | min: (state) => {
25 | return onMinIterations(subtypes[state.subtype], state)
26 | },
27 | max: (state) => {
28 | return onMaxIterations(subtypes[state.subtype], state)
29 | },
30 | },
31 | }
32 |
33 | export default class LSystem extends Shape {
34 | constructor() {
35 | super("lsystem")
36 | this.label = "Fractal line writer"
37 | this.link = "https://en.wikipedia.org/wiki/L-system"
38 | this.linkText = "Wikipedia"
39 | this.description =
40 | "The fractal line writer shape is a Lindenmayer (or L) system. L-systems chain symbols together to specify instructions for moving in a 2d space (e.g., turn left or right, walk left or right). When applied recursively, they generate fractal-like patterns."
41 | }
42 |
43 | getInitialState() {
44 | return {
45 | ...super.getInitialState(),
46 | ...{
47 | iterations: 3,
48 | subtype: "McWorter's Pentadendrite",
49 | },
50 | }
51 | }
52 |
53 | getVertices(state) {
54 | const shape = state.shape
55 | const iterations = shape.iterations || 1
56 |
57 | // generate our vertices using a set of l-system rules
58 | let config = subtypes[shape.subtype]
59 |
60 | config.iterations = iterations
61 | config.side = 5
62 |
63 | if (config.angle === undefined) {
64 | config.angle = Math.PI / 2
65 | }
66 |
67 | const path = lsystemOptimize(lsystemPath(lsystem(config), config), config)
68 | const scale = 18.0 // to normalize starting size
69 |
70 | return resizeVertices(path, scale, scale)
71 | }
72 |
73 | getOptions() {
74 | return options
75 | }
76 |
77 | // hack to randomly select the subtype before randomizing the other shape values
78 | randomChanges(layer) {
79 | const subtypeChoices = Object.keys(subtypes)
80 | const choice = Math.floor(Math.random() * subtypeChoices.length)
81 | const subtype = subtypeChoices[choice]
82 | const tempLayer = { ...layer, subtype }
83 | const changes = super.randomChanges(tempLayer, ["subtype"])
84 |
85 | changes.subtype = subtype
86 |
87 | return onSubtypeChange(subtypes[changes.subtype], changes, tempLayer)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/features/shapes/shapeFactory.js:
--------------------------------------------------------------------------------
1 | import Circle from "./Circle"
2 | import Epicycloid from "./Epicycloid"
3 | import FancyText from "./FancyText"
4 | import LayerImport from "./LayerImport"
5 | import FractalSpirograph from "./fractal_spirograph/FractalSpirograph"
6 | import Heart from "./Heart"
7 | import Hypocycloid from "./Hypocycloid"
8 | import ImageImport from "./image_import/ImageImport"
9 | import InputText from "./input_text/InputText"
10 | import LSystem from "./lsystem/LSystem"
11 | import Point from "./Point"
12 | import Polygon from "./Polygon"
13 | import Reuleaux from "./Reuleaux"
14 | import Rose from "./Rose"
15 | import Star from "./Star"
16 | import TessellationTwist from "./tessellation_twist/TessellationTwist"
17 | import V1Engineering from "./v1_engineering/V1Engineering"
18 | import CirclePacker from "./circle_packer/CirclePacker"
19 | import NoiseWave from "./NoiseWave"
20 | import SpaceFiller from "./space_filler/SpaceFiller"
21 | import Voronoi from "./Voronoi"
22 | import Wiper from "./Wiper"
23 |
24 | export const shapeFactory = {
25 | polygon: Polygon,
26 | star: Star,
27 | circle: Circle,
28 | heart: Heart,
29 | reuleaux: Reuleaux,
30 | epicycloid: Epicycloid,
31 | hypocycloid: Hypocycloid,
32 | rose: Rose,
33 | inputText: InputText,
34 | fancyText: FancyText,
35 | v1Engineering: V1Engineering,
36 | lsystem: LSystem,
37 | fractalSpirograph: FractalSpirograph,
38 | tessellationTwist: TessellationTwist,
39 | voronoi: Voronoi,
40 | point: Point,
41 | circlePacker: CirclePacker,
42 | wiper: Wiper,
43 | spaceFiller: SpaceFiller,
44 | noise_wave: NoiseWave,
45 | fileImport: LayerImport,
46 | imageImport: ImageImport,
47 | }
48 |
49 | export const getShape = (type, ...args) => {
50 | return new shapeFactory[type](args)
51 | }
52 |
53 | export const getDefaultShapeType = () => {
54 | const shape = localStorage.getItem("defaultShape")
55 |
56 | // minor hack: fancy text relies on fonts being loaded, so it can't be used for initial
57 | // state when the app loads
58 | return shape && shape !== "fancyText" ? shape : "polygon"
59 | }
60 |
61 | export const getDefaultShape = () => {
62 | return getShape(getDefaultShapeType())
63 | }
64 |
65 | export const getShapeSelectOptions = () => {
66 | const groupOptions = []
67 | const types = Object.keys(shapeFactory)
68 |
69 | for (const type of types) {
70 | const shape = getShape(type)
71 | const optionLabel = { value: type, label: shape.label }
72 |
73 | let found = false
74 | for (const group of groupOptions) {
75 | if (group.label === shape.selectGroup) {
76 | found = true
77 | group.options.push(optionLabel)
78 | }
79 | }
80 | if (!found) {
81 | if (shape.selectGroup === "import") {
82 | // users can't manually select this group
83 | continue
84 | }
85 |
86 | const newOptions = [optionLabel]
87 | groupOptions.push({ label: shape.selectGroup, options: newOptions })
88 | }
89 | }
90 |
91 | return groupOptions
92 | }
93 |
--------------------------------------------------------------------------------
/src/features/shapes/space_filler/SpaceFiller.js:
--------------------------------------------------------------------------------
1 | import Shape from "../Shape"
2 | import {
3 | lsystem,
4 | lsystemPath,
5 | lsystemOptimize,
6 | onSubtypeChange,
7 | onMinIterations,
8 | onMaxIterations,
9 | } from "@/common/lindenmayer"
10 | import { resizeVertices, centerOnOrigin } from "@/common/geometry"
11 | import { subtypes } from "./subtypes"
12 | import { getMachine } from "@/features/machines/machineFactory"
13 |
14 | const options = {
15 | fillerSubtype: {
16 | title: "Type",
17 | type: "dropdown",
18 | choices: Object.keys(subtypes),
19 | onChange: (model, changes, state) => {
20 | return onSubtypeChange(subtypes[changes.fillerSubtype], changes, state)
21 | },
22 | },
23 | iterations: {
24 | title: "Iterations",
25 | min: (state) => {
26 | return onMinIterations(subtypes[state.fillerSubtype], state)
27 | },
28 | max: (state) => {
29 | return onMaxIterations(subtypes[state.fillerSubtype], state)
30 | },
31 | },
32 | }
33 |
34 | export default class SpaceFiller extends Shape {
35 | constructor() {
36 | super("spaceFiller")
37 | this.label = "Space filler"
38 | this.usesMachine = true
39 | this.autosize = false
40 | this.selectGroup = "Erasers"
41 | this.linkText = "Wikipedia"
42 | this.description =
43 | "A space-filling curve draws a single, continuous line that covers every point in a space without missing any spots or crossing itself."
44 | this.link = "https://en.wikipedia.org/wiki/Space-filling_curve"
45 | }
46 |
47 | canMove(state) {
48 | return false
49 | }
50 |
51 | canChangeSize(state) {
52 | return false
53 | }
54 |
55 | canRotate(state) {
56 | return false
57 | }
58 |
59 | getInitialState() {
60 | return {
61 | ...super.getInitialState(),
62 | ...{
63 | iterations: 6,
64 | fillerSubtype: "Hilbert",
65 | },
66 | }
67 | }
68 |
69 | getVertices(state) {
70 | const machine = getMachine(state.machine)
71 | const iterations = state.shape.iterations || 1
72 | let { sizeX, sizeY } = machine
73 |
74 | if (state.machine.type === "rectangular") {
75 | sizeX = sizeX * 2
76 | sizeY = sizeY * 2
77 | }
78 |
79 | // generate our vertices using a set of l-system rules
80 | let config = subtypes[state.shape.fillerSubtype]
81 |
82 | config.iterations = iterations
83 | if (config.side === undefined) {
84 | config.side = 5
85 | }
86 | if (config.angle === undefined) {
87 | config.angle = Math.PI / 2
88 | }
89 |
90 | let curve = lsystemOptimize(lsystemPath(lsystem(config), config), config)
91 | let scale = 1
92 |
93 | if (config.iterationsGrow) {
94 | scale =
95 | typeof config.iterationsGrow === "function"
96 | ? config.iterationsGrow(config)
97 | : config.iterationsGrow
98 | }
99 |
100 | return centerOnOrigin(resizeVertices(curve, sizeX * scale, sizeY * scale))
101 | }
102 |
103 | getOptions() {
104 | return options
105 | }
106 |
107 | // hack to randomly select the subtype before randomizing the other shape values
108 | randomChanges(layer) {
109 | const subtypeChoices = Object.keys(subtypes)
110 | const choice = Math.floor(Math.random() * subtypeChoices.length)
111 | const subtype = subtypeChoices[choice]
112 | const tempLayer = { ...layer, subtype }
113 | const changes = super.randomChanges(tempLayer, ["fillerSubtype"])
114 |
115 | changes.fillerSubtype = subtype
116 |
117 | return onSubtypeChange(subtypes[changes.subtype], changes, tempLayer)
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/src/features/shapes/space_filler/subtypes.js:
--------------------------------------------------------------------------------
1 | // L-system instructions for space filling curves
2 | export const subtypes = {
3 | // http://mathforum.org/advanced/robertd/lsys2d.html
4 | "Gosper (flowsnake)": {
5 | axiom: "A",
6 | draw: ["A", "B"],
7 | rules: {
8 | A: "A-B--B+A++AA+B-",
9 | B: "+A-BB--B-A++A+B",
10 | },
11 | angle: Math.PI / 3,
12 | iterationsGrow: (config) => {
13 | return config.iterations
14 | },
15 | maxIterations: 6,
16 | },
17 | // http://mathforum.org/advanced/robertd/lsys2d.html
18 | Hilbert: {
19 | axiom: "L",
20 | draw: "F",
21 | rules: {
22 | L: "+RF-LFL-FR+",
23 | R: "-LF+RFR+FL-",
24 | },
25 | startingAngle: Math.PI,
26 | minIterations: 2,
27 | },
28 | // http://mathforum.org/advanced/robertd/lsys2d.html
29 | "Hilbert 2": {
30 | axiom: "X",
31 | draw: "F",
32 | rules: {
33 | X: "XFYFX+F+YFXFY-F-XFYFX",
34 | Y: "YFXFY-F-XFYFX+F+YFXFY",
35 | },
36 | startingAngle: Math.PI,
37 | maxIterations: 4,
38 | },
39 | // https://en.wikipedia.org/wiki/Sierpi%C5%84ski_curve
40 | Sierpinski: {
41 | axiom: "F--XF--F--XF",
42 | draw: ["F", "G"],
43 | rules: {
44 | X: "XF+G+XF--F--XF+G+X",
45 | },
46 | startingAngle: Math.PI / 4,
47 | angle: Math.PI / 4,
48 | maxIterations: 6,
49 | },
50 | // https://onlinemathtools.com/l-system-generator
51 | "Penrose Tile": {
52 | axiom: "[7]++[7]++[7]++[7]++[7]",
53 | draw: ["6", "7", "8", "9"],
54 | rules: {
55 | 6: "8++9----7[-8----6]++",
56 | 7: "+8--9[---6--7]+",
57 | 8: "-6++7[+++8++9]-",
58 | 9: "--8++++6[+9++++7]--7",
59 | },
60 | angle: Math.PI / 5,
61 | maxIterations: 5,
62 | shortestPath: 5,
63 | iterationsGrow: (config) => {
64 | return 1 + Math.max(1, 3 / config.iterations)
65 | },
66 | },
67 | // https://en.wikipedia.org/wiki/Sierpi%C5%84ski_curve
68 | "Sierpinski Square": {
69 | axiom: "F+XF+F+XF",
70 | draw: "F",
71 | rules: {
72 | X: "XF-F+F-XF+F+XF-F+F-X",
73 | },
74 | startingAngle: Math.PI / 4,
75 | maxIterations: 6,
76 | },
77 | }
78 |
--------------------------------------------------------------------------------
/src/features/shapes/tessellation_twist/LICENSE:
--------------------------------------------------------------------------------
1 | Tesselation Twist, https://codepen.io/rafaelpascoalrodrigues/pen/KpBJve
2 |
3 | Copyright (c) 2015 by Rafael Pascoal Rodrigues
4 | (https://codepen.io/rafaelpascoalrodrigues/pen/KpBJve)
5 |
6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
7 | associated documentation files (the "Software"), to deal in the Software without restriction,
8 | including without limitation the rights to use, copy, modify, merge, publish, distribute,
9 | sublicense, and/or sell 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 copies or substantial
13 | portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
16 | NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/src/features/shapes/v1_engineering/V1Engineering.js:
--------------------------------------------------------------------------------
1 | import Vicious1Vertices from "./Vicious1Vertices"
2 | import Shape from "../Shape"
3 |
4 | export default class V1Engineering extends Shape {
5 | constructor() {
6 | super("v1Engineering")
7 | this.label = "V1Engineering"
8 | this.link = "https://www.v1e.com/"
9 | this.linkText = "V1 Engineering"
10 | this.description =
11 | "This shape represents the V1 Engineering logo. V1 Engineering provides low-cost, customizable machine designs. Sandify was created in 2017 by users in their forum."
12 | this.randomizable = false
13 | }
14 |
15 | getInitialState() {
16 | return {
17 | ...super.getInitialState(),
18 | ...{
19 | // no custom attributes
20 | },
21 | }
22 | }
23 |
24 | getVertices(state) {
25 | return Vicious1Vertices()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: sans-serif;
3 | }
4 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { createRoot } from "react-dom/client"
3 | import "./features/app/reactGA"
4 | import App from "./features/app/App"
5 | import "./index.css"
6 |
7 | if (import.meta.hot) {
8 | import.meta.hot.on("vite:beforeFullReload", () => {
9 | location.reload()
10 | })
11 | }
12 |
13 | const root = createRoot(document.getElementById("root"))
14 | root.render( )
15 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import "jest-canvas-mock"
2 |
3 | jest.mock("react-ga4")
4 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs/promises';
2 | import { defineConfig } from 'vite'
3 | import path from 'path'
4 | import react from '@vitejs/plugin-react'
5 | import { NodeGlobalsPolyfillPlugin } from '@esbuild-plugins/node-globals-polyfill'
6 | import { nodePolyfills } from 'vite-plugin-node-polyfills'
7 |
8 | export default defineConfig(() => ({
9 | server: {
10 | port: 3000
11 | },
12 | plugins: [
13 | react(),
14 | nodePolyfills(),
15 | ],
16 | resolve: {
17 | alias: {
18 | '@': path.resolve(__dirname, './src'),
19 | "tinyqueue": path.join(__dirname, 'node_modules', 'tinyqueue', 'index.js')
20 | }
21 | },
22 | build: {
23 | outDir: 'build',
24 | target: 'esnext',
25 | },
26 | define: {
27 | "process.env": process.env ?? {},
28 | },
29 | // We use this configuration to treat .js file as .jsx. In the future we should rename them all
30 | // to .jsx instead and remove this config
31 | esbuild: {
32 | loader: "jsx",
33 | include: /src\/.*\.jsx?$/,
34 | exclude: [],
35 | },
36 | optimizeDeps: {
37 | esbuildOptions: {
38 | plugins: [
39 | NodeGlobalsPolyfillPlugin({ buffer: false, process: true }),
40 | {
41 | name: "load-js-files-as-jsx",
42 | setup(build) {
43 | build.onLoad({ filter: /src\/.*\.js$/ }, async (args) => ({
44 | loader: "jsx",
45 | contents: await fs.readFile(args.path, "utf8"),
46 | }));
47 | },
48 | },
49 | ],
50 | },
51 | },
52 | }));
53 |
--------------------------------------------------------------------------------