Working proof of concept

This commit is contained in:
Mark van Renswoude 2020-12-28 15:31:21 +01:00
parent 0f97197438
commit 64c066ffed
24 changed files with 12079 additions and 0 deletions

3
.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

18
README.md Normal file
View File

@ -0,0 +1,18 @@
# CuttingBoard
A web-based cutting board designer.
## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```

21
TODO.md Normal file
View File

@ -0,0 +1,21 @@
ToDo
====
Must have
----
- Implement switching units
- Re-ordering of the layers (preferably drag/drop)
- Save / load designs (clipboard, or preferably file download/upload - maybe Cloud storage integration later?)
Should have
----
- Render width and height of the boards in the previews
- Material usage overview
- Generate cutting list
- Support for fractional inches (see, not all europeans look down on freedom units!)
Nice to have
----
- More advanced options, like custom direction per strip and mixing multiple edge grain boards with different layers for the end grain board (the code is half prepared for this by having the boards array encapsulating the layers, though it's all hardcoded to board[0] now)
- 3D effect for previews emulating thickness / crosscut width
- Make it a tiny bit prettier overall

5
babel.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -0,0 +1 @@
.settings[data-v-660f17f9]{display:inline-grid;grid-template-columns:auto auto;grid-column-gap:1em;grid-row-gap:.25em}.settings h2[data-v-660f17f9]{font-size:80%;margin-top:1em;margin-bottom:0;grid-column:1/3}.layers[data-v-535527d4]{display:inline-grid;grid-template-columns:3em 20em 5em 3em;grid-column-gap:1em}.layers .add[data-v-535527d4]{grid-column:2/5;padding-bottom:1em}.layers .header[data-v-535527d4]{font-weight:700;margin-bottom:.25em}.wood[data-v-55181d8c]{display:inline-grid;grid-template-columns:3em 20em 5em 3em;grid-column-gap:1em}.wood .add[data-v-55181d8c]{grid-column:2/5;padding-bottom:1em}.wood .header[data-v-55181d8c]{font-weight:700;margin-bottom:.25em}#app{background-color:#fff;color:#000;font-family:Verdana,Arial,sans-serif;font-size:10pt;display:flex;flex-direction:horizontal}h1{background-color:#f0f0f0;border-bottom:1px solid silver;font-size:100%;margin-top:0;margin-bottom:.5em;padding:.25em}.app-settings{margin-right:1em}.app-settings .block{margin-bottom:2em}.app-preview .preview{margin-bottom:1em}.about,.todo{width:30em}

1
docs/index.html Normal file
View File

@ -0,0 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>CuttingBoard</title><link href="/css/app.037fc7fb.css" rel="preload" as="style"><link href="/js/app.f2508f9a.js" rel="preload" as="script"><link href="/js/chunk-vendors.d9b83edc.js" rel="preload" as="script"><link href="/css/app.037fc7fb.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but CuttingBoard doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/js/chunk-vendors.d9b83edc.js"></script><script src="/js/app.f2508f9a.js"></script></body></html>

2
docs/js/app.f2508f9a.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11295
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "cuttingboard",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build"
},
"dependencies": {
"core-js": "^3.6.5",
"vue": "^3.0.0",
"vuex": "^4.0.0-0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"sass": "^1.26.5",
"sass-loader": "^8.0.2"
}
}

17
public/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

95
src/App.vue Normal file
View File

@ -0,0 +1,95 @@
<template>
<div class="app-settings">
<h1>Settings</h1>
<Settings class="block" />
<h1>Layers</h1>
<Layers class="block" />
<h1>Wood types</h1>
<Wood class="block" />
<h1>About / feedback</h1>
<div class="about">
<p>
Created by Mark van Renswoude. Open-source and available under the Unlicense to the public domain on <a href="https://github.com/MvRens/CuttingBoard" target="_blank">Github</a>, where feedback is welcome under Issues.
</p>
<p>
Heavily inspired by <a href="http://www.lastalias.com/cbdesigner/">CBdesigner</a>.
</p>
</div>
</div>
<div class="app-preview">
<h1>Edge grain</h1>
<EndGrainPreview :scale="1" />
<h1>End grain</h1>
<EdgeGrainPreview :scale="1" />
</div>
</template>
<script>
import Settings from './components/Settings.vue'
import Layers from './components/Layers.vue'
import Wood from './components/Wood.vue'
import EndGrainPreview from './components/EndGrainPreview.vue'
import EdgeGrainPreview from './components/EdgeGrainPreview.vue'
export default {
name: 'App',
components: {
EndGrainPreview,
EdgeGrainPreview,
Settings,
Layers,
Wood
}
}
</script>
<style lang="scss">
#app {
background-color: white;
color: black;
font-family: 'Verdana', 'Arial', sans-serif;
font-size: 10pt;
display: flex;
flex-direction: horizontal;
}
h1
{
background-color: #f0f0f0;
border-bottom: solid 1px #c0c0c0;
font-size: 100%;
margin-top: 0;
margin-bottom: .5em;
padding: .25em;
}
.app-settings
{
margin-right: 1em;
.block
{
margin-bottom: 2em;
}
}
.app-preview
{
.preview
{
margin-bottom: 1em;
}
}
.about, .todo
{
width: 30em;
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<div class="preview">
<svg
:width="viewportWidth"
:height="viewportHeight"
:viewBox="viewBox">
<defs>
<g id="strip">
<rect
v-for="(layer, index) in layers"
:width="toPixels(settings.boardThickness)"
:height="toPixels(layer.width)"
x="0"
:y="getLayerOffset(index)"
:style="getLayerStyle(index)" />
</g>
</defs>
<use
v-for="(strip, index) in stripsPerBoard"
xlink:href="#strip"
:x="index * settings.boardThickness"
y="0"
:transform="getLayerTransform(index)" />
</svg>
</div>
</template>
<script>
import { units } from '../lib/units';
export default {
props: {
scale: Number
},
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; },
layers() { return this.$store.state.boards[0].layers; },
stripsPerBoard()
{
const stripAndKerf = this.settings.crosscutWidth + this.settings.bladeKerf;
if (stripAndKerf === 0)
return 0;
return Math.floor((this.settings.boardLength + this.settings.bladeKerf) / stripAndKerf);
},
boardWidth()
{
const boardWidth = this.stripsPerBoard * this.settings.boardThickness;
return this.toPixels(boardWidth);
},
boardHeight()
{
return this.layers
.map(currentValue => currentValue.width)
.reduce((accumulator, currentValue) => accumulator + currentValue);
},
viewportWidth() { return Math.floor(this.boardWidth * this.scale); },
viewportHeight() { return Math.floor(this.boardHeight * this.scale); },
viewBox() { return '0 0 ' + this.boardWidth + ' ' + this.boardHeight; }
},
methods: {
toPixels(value)
{
return units.toPixels(value, this.settings.units);
},
getLayerOffset(index)
{
if (index < 0 || index >= this.layers.length)
return 0;
let offset = 0;
for (let i = 0; i < index; i++)
offset += this.layers[i].width;
return offset;
},
getLayerStyle(index)
{
if (index < 0 || index >= this.layers.length)
return 'fill: fuchsia';
const woodIndex = this.layers[index].wood;
if (woodIndex === null)
return '';
const borderStyle = this.settings.borders
? '; stroke-width: 1; stroke: black'
: '';
return 'fill: ' + this.wood[woodIndex].color + borderStyle;
},
getLayerTransform(index)
{
if (!this.settings.alternateDirection || (index % 2) == 0)
return '';
return 'scale(1, -1) translate(0, -' + this.boardHeight + ')';
}
}
}
</script>

View File

@ -0,0 +1,84 @@
<template>
<div class="preview">
<svg
:width="viewportWidth"
:height="viewportHeight"
:viewBox="viewBox">
<rect
v-for="(layer, index) in layers"
:width="toPixels(settings.boardLength)"
:height="toPixels(layer.width)"
x="0"
:y="getLayerOffset(index)"
:style="getLayerStyle(index)" />
</svg>
</div>
</template>
<script>
import { units } from '../lib/units';
export default {
props: {
scale: Number
},
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; },
layers() { return this.$store.state.boards[0].layers; },
boardWidth() { return this.toPixels(this.settings.boardLength); },
boardHeight()
{
return this.layers
.map(currentValue => currentValue.width)
.reduce((accumulator, currentValue) => accumulator + currentValue);
},
viewportWidth() { return Math.floor(this.boardWidth * this.scale); },
viewportHeight() { return Math.floor(this.boardHeight * this.scale); },
viewBox() { return '0 0 ' + this.boardWidth + ' ' + this.boardHeight; }
},
methods: {
toPixels(value)
{
return units.toPixels(value, this.settings.units);
},
getLayerOffset(index)
{
if (index < 0 || index >= this.layers.length)
return 0;
let offset = 0;
for (let i = 0; i < index; i++)
offset += this.layers[i].width;
return offset;
},
getLayerStyle(index)
{
if (index < 0 || index >= this.layers.length)
return 'fill: fuchsia';
const woodIndex = this.layers[index].wood;
if (woodIndex === null)
return '';
const borderStyle = this.settings.borders
? '; stroke-width: 1; stroke: black'
: '';
return 'fill: ' + this.wood[woodIndex].color + borderStyle;
}
}
}
</script>

78
src/components/Layers.vue Normal file
View File

@ -0,0 +1,78 @@
<template>
<div class="layers">
<div class="add">
<button @click="addLayer()">Add layer</button>
</div>
<span class="header">&nbsp;</span>
<span class="header">Wood type</span>
<span class="header">Width</span>
<span class="header">&nbsp;</span>
<template v-for="(layer, index) in layers">
<div class="index">{{ index + 1 }}</div>
<select v-model="layer.wood" class="wood">
<option v-for="(item, index) in wood" :value="index">{{ item.name }}</option>
</select>
<input type="number" class="width" :value="layer.width" @input="layer.width = parseFloatDef($event.target.value)" />
<div class="remove">
<button @click="removeLayer(index)">X</button>
</div>
</template>
</div>
</template>
<script>
import { units } from '../lib/units';
export default {
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; },
layers() { return this.$store.state.boards[0].layers; },
},
methods: {
parseFloatDef(value)
{
const parsedValue = parseFloat(value);
return Object.is(parsedValue, NaN) ? 0 : parsedValue;
},
addLayer()
{
this.$store.commit('addLayer', 0);
},
removeLayer(index)
{
this.$store.commit('removeLayer', { board: 0, layer: index });
}
}
}
</script>
<style lang="scss" scoped>
.layers
{
display: inline-grid;
grid-template-columns: 3em 20em 5em 3em;
grid-column-gap: 1em;
.add
{
grid-column: 2 / 5;
padding-bottom: 1em;
}
.header
{
font-weight: bold;
margin-bottom: .25em;
}
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="settings">
<h2>Designer</h2>
<label for="units">Units</label>
<select id="units" disabled>
<option value="mm">Millimeters</option>
<option value="cm">Centimeters</option>
<option value="inch">Inches (fractional)</option>
</select>
<label for="borders">Show borders</label>
<input id="borders" type="checkbox" :checked="settings.borders" @change="$store.commit('updateSettings', { borders: $event.target.checked })" />
<h2>Material</h2>
<label for="boardThickness">Board thickness</label>
<input id="boardThickness" type="number" :value="settings.boardThickness" @change="$store.commit('updateSettings', { boardThickness: parseFloatDef($event.target.value) })" />
<label for="boardLength">Board length</label>
<input id="boardLength" type="number" :value="settings.boardLength" @change="$store.commit('updateSettings', { boardLength: parseFloatDef($event.target.value) })" />
<label for="bladeKerf">Blade kerf</label>
<input id="bladeKerf" type="number" :value="settings.bladeKerf" @change="$store.commit('updateSettings', { bladeKerf: parseFloatDef($event.target.value) })" />
<h2>End grain</h2>
<label for="crosscutWidth">Crosscut width</label>
<input id="crosscutWidth" type="number" :value="settings.crosscutWidth" @change="$store.commit('updateSettings', { crosscutWidth: parseFloatDef($event.target.value) })" />
<label for="alternateDirection">Alternate direction</label>
<input id="alternateDirection" type="checkbox" :checked="settings.alternateDirection" @change="$store.commit('updateSettings', { alternateDirection: $event.target.checked })" />
</div>
</template>
<script>
import { units } from '../lib/units';
export default {
computed: {
settings() { return this.$store.state.settings; }
},
methods: {
parseFloatDef(value)
{
const parsedValue = parseFloat(value);
return Object.is(parsedValue, NaN) ? 0 : parsedValue;
}
}
}
</script>
<style lang="scss" scoped>
.settings
{
display: inline-grid;
grid-template-columns: auto auto;
grid-column-gap: 1em;
grid-row-gap: .25em;
h2
{
font-size: 80%;
margin-top: 1em;
margin-bottom: 0;
grid-column: 1 / 3;
}
}
</style>

66
src/components/Wood.vue Normal file
View File

@ -0,0 +1,66 @@
<template>
<div class="wood">
<div class="add">
<button @click="addWood()">Add wood type</button>
</div>
<span class="header">&nbsp;</span>
<span class="header">Name</span>
<span class="header">Colour</span>
<span class="header">&nbsp;</span>
<template v-for="(item, index) in wood">
<span>&nbsp;</span>
<input type="text" class="name" v-model="item.name" />
<input type="color" class="color" v-model="item.color" />
<div class="remove">
<button @click="removeWood(index)">X</button>
</div>
</template>
</div>
</template>
<script>
export default {
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; }
},
methods: {
addWood()
{
this.$store.commit('addWood');
},
removeWood(index)
{
this.$store.commit('removeWood', index);
}
}
}
</script>
<style lang="scss" scoped>
.wood
{
display: inline-grid;
grid-template-columns: 3em 20em 5em 3em;
grid-column-gap: 1em;
.add
{
grid-column: 2 / 5;
padding-bottom: 1em;
}
.header
{
font-weight: bold;
margin-bottom: .25em;
}
}
</style>

43
src/lib/units.js Normal file
View File

@ -0,0 +1,43 @@
const millimetersPerInch = 25.4;
const millimetersPerCentimeter = 10;
const pixelsPerMillimeter = 1;
const units = {
toPixels(value, units)
{
return this.toMillimeters(value, units) * pixelsPerMillimeter;
},
toMillimeters(value, units)
{
switch (units)
{
case 'mm': return value;
case 'cm': return value * millimetersPerCentimeter;
case 'inch': return value * millimetersPerInch;
}
console.error('Invalid units type: ' + units);
return 0;
},
fromMillimeters(value, units)
{
switch (units)
{
case 'mm': return value;
case 'cm': return value / millimetersPerCentimeter;
case 'inch': return value / millimetersPerInch;
}
console.error('Invalid units type: ' + units);
return 0;
}
};
export { units }

5
src/main.js Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import store from './store'
createApp(App).use(store).mount('#app')

118
src/store.js Normal file
View File

@ -0,0 +1,118 @@
import { createStore } from 'vuex';
import { units } from './lib/units';
export default createStore({
state: {
settings: {
units: 'mm',
borders: false,
boardThickness: 20,
boardLength: 700,
bladeKerf: 3.5,
crosscutWidth: 30,
alternateDirection: true
},
wood: [
{ name: 'Walnut', color: '#58443f' },
{ name: 'Maple', color: '#f2e0aa' },
{ name: 'Cherry', color: '#bb8359' },
{ name: 'Mahogany', color: '#98473f' },
{ name: 'Yellowheart', color: '#ffff84' },
{ name: 'White oak', color: '#fdf4b9' },
{ name: 'Bubinga', color: '#7e3c34' },
{ name: 'Jatoba', color: '#9b281c' },
{ name: 'Padouk', color: '#933350' }
],
boards: [
{
layers: [
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 },
{ wood: 0, width: 20 },
{ wood: 1, width: 20 }
]
}
]
},
mutations: {
addLayer(state, board)
{
if (board < 0 || board >= state.boards.length)
return;
state.boards[board].layers.push({
wood: 0,
width: units.fromMillimeters(20, state.settings.units)
});
},
removeLayer(state, payload)
{
if (payload.board < 0 || payload.board >= state.boards.length)
return;
if (payload.layer < 0 || payload.layer >= state.boards[payload.board].length)
return;
state.boards[payload.board].layers.splice(payload.layer, 1);
},
addWood(state)
{
state.wood.push({
name: 'Wood #' + (state.wood.length + 1),
color: '#f2e0aa'
});
},
removeWood(state, index)
{
if (index < 0 || index >= state.wood.length)
return;
// Update all layers
state.boards.forEach(board =>
{
board.layers.forEach(layer =>
{
if (layer.wood === index)
layer.wood = null
else if (layer.wood > index)
layer.wood--;
});
});
state.wood.splice(index, 1);
},
updateSettings(state, payload)
{
for (const property in payload)
{
if (!payload.hasOwnProperty(property) || !state.settings.hasOwnProperty(property))
continue;
state.settings[property] = payload[property];
}
}
},
actions: {
}
})

11
vue.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
outputDir: 'docs',
chainWebpack: config => {
config
.plugin('html')
.tap(args => {
args[0].title = 'CuttingBoard';
return args;
})
}
}