Added support for multiple boards in a single end grain result

...and probably loads of little things changed in the process
This commit is contained in:
Mark van Renswoude 2020-12-30 23:00:03 +01:00
parent f65a7f37e3
commit fe4d17c7f2
10 changed files with 1146 additions and 292 deletions

View File

@ -3,6 +3,8 @@ ToDo
Should have
----
- Highlight strips for the current board, highlight layer for the focused/hovered layer
- Show list of end grain board numbers and reversed in a "Glue list" for printing
- Support for fractional inches (see, not all europeans look down on freedom units!)
Nice to have
@ -10,5 +12,6 @@ Nice to have
- Show remaining material
- Theme selection for the preview background
- Render width and height of the boards in the previews (simplified version implemented, moved to 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)
- Support for mixing multiple boards in the end grain version
- 3D effect for previews emulating thickness / crosscut width
- Actual wood textures

View File

@ -27,14 +27,14 @@
Use your browser's built-in print functionality (for example, Ctrl+P on Windows) or click the button below to get a printable version of your board and cutting list.
</p>
<div>
<input type="checkbox" v-model="printEdgeGrain" id="printEdgeGrain" />
<label for="printEdgeGrain"> Edge grain preview</label>
</div>
<div>
<input type="checkbox" v-model="printEndGrain" id="printEndGrain" />
<label for="printEndGrain"> End grain preview</label>
</div>
<div>
<input type="checkbox" v-model="printEdgeGrain" id="printEdgeGrain" />
<label for="printEdgeGrain"> Edge grain preview</label>
</div>
<div>
<input type="checkbox" v-model="printCuttingList" id="printCuttingList" />
<label for="printCuttingList"> Cutting list and bill of materials</label>
@ -72,16 +72,19 @@
</div>
<div class="content">
<div :class="{ hideOnPrint: !printEdgeGrain }">
<h1>Edge grain</h1>
<EdgeGrainPreview :scale="1" />
</div>
<div :class="{ hideOnPrint: !printEndGrain }">
<h1>End grain</h1>
<EndGrainPreview :scale="1" />
</div>
<div :class="{ hideOnPrint: !printEdgeGrain }">
<h1>Edge grain</h1>
<template v-for="(board, boardIndex) in boards">
<h2 v-if="boards.length > 1">Board {{ boardIndex + 1}}</h2>
<EdgeGrainPreview :board="board" :scale="1" />
</template>
</div>
<div :class="{ hideOnPrint: !printCuttingList }">
<h1>Cutting list</h1>
<CuttingList />
@ -153,6 +156,8 @@ export default {
},
computed: {
boards() { return this.$store.state.boards; },
hash()
{
return bytesToBase64(this.$store.getters.saveMsgPack);
@ -234,6 +239,12 @@ html, body
height: 100%;
}
h2
{
color: #808080;
font-size: 80%;
}
a
{
color: #99ccff;
@ -266,7 +277,7 @@ button
padding-top: .3em;
padding-bottom: .3em;
&:hover
&:hover:not([disabled])
{
background-color: #808080;
}

View File

@ -5,27 +5,39 @@
<th>Wood species</th>
<th class="dimension">Width</th>
</tr>
<tr v-for="(layer, index) in layers">
<td>{{ index + 1 }}</td>
<td>{{ getLayerWood(index) }}</td>
<td class="dimension">{{ getLayerWidth(index) }}</td>
</tr>
<template v-for="(board, boardIndex) in boards">
<tr class="board" v-if="boards.length > 1">
<td colspan="3">Board {{ boardIndex + 1 }}</td>
</tr>
<tr v-for="(layer, index) in board.layers">
<td>{{ index + 1 }}</td>
<td>{{ getLayerWood(board, index) }}</td>
<td class="dimension">{{ getLayerWidth(board, index) }}</td>
</tr>
</template>
</table>
<h2>Bill of materials</h2>
<table class="list">
<tr>
<th>Wood species</th>
<th class="dimension">Thickness</th>
<th class="dimension">Length</th>
<th class="dimension">Width</th>
<th class="dimension">Thickness</th>
</tr>
<tr v-for="stock in bom">
<td>{{ stock.woodName }}</td>
<td class="dimension">{{ display(settings.boardThickness) }}</td>
<td class="dimension">{{ display(settings.boardLength) }}</td>
<td class="dimension">{{ display(stock.width) }}</td>
</tr>
<template v-for="(board, boardIndex) in bom">
<tr class="board" v-if="bom.length > 1">
<td colspan="3">Board {{ boardIndex + 1 }}</td>
</tr>
<tr v-for="stock in board">
<td>{{ stock.woodName }}</td>
<td class="dimension">{{ display(stock.length) }}</td>
<td class="dimension">{{ display(stock.width) }}</td>
<td class="dimension">{{ display(stock.thickness) }}</td>
</tr>
</template>
</table>
</template>
@ -35,59 +47,66 @@ import { units } from '../lib/units';
export default {
computed: {
settings() { return this.$store.state.settings; },
layers() { return this.$store.state.boards[0].layers; },
boards() { return this.$store.state.boards; },
wood() { return this.$store.state.wood; },
bom()
{
const woodTally = {};
const self = this;
this.layers.forEach(layer =>
return self.boards.map((board, boardIndex) =>
{
if (woodTally.hasOwnProperty(layer.wood))
woodTally[layer.wood] += layer.width + this.settings.bladeKerf;
else
woodTally[layer.wood] = layer.width;
});
const bom = [];
const woodTally = {};
const bom = [];
for (let wood in woodTally)
{
if (!woodTally.hasOwnProperty(wood))
continue;
bom.push({
woodName: wood !== null && wood >= 0 && wood < this.wood.length ? this.wood[wood].name : '',
width: woodTally[wood]
board.layers.forEach(layer =>
{
if (woodTally.hasOwnProperty(layer.wood))
woodTally[layer.wood] += layer.width + self.settings.bladeKerf;
else
woodTally[layer.wood] = layer.width;
});
}
return bom;
for (let wood in woodTally)
{
if (!woodTally.hasOwnProperty(wood))
continue;
bom.push({
board: boardIndex,
woodName: wood >= 0 && wood < self.wood.length ? self.wood[wood].name : '',
length: board.length,
width: woodTally[wood],
thickness: board.thickness
});
}
return bom;
});
}
},
methods: {
getLayerWood(index)
getLayerWood(board, index)
{
if (index < 0 || index >= this.layers.length)
if (index < 0 || index >= board.layers.length)
return '';
const woodIndex = this.layers[index].wood;
if (woodIndex === null || woodIndex < 0 || woodIndex >= this.wood.length)
const woodIndex = board.layers[index].wood;
if (woodIndex < 0 || woodIndex >= this.wood.length)
return '';
return this.wood[woodIndex].name;
},
getLayerWidth(index)
getLayerWidth(board, index)
{
if (index < 0 || index >= this.layers.length)
if (index < 0 || index >= board.layers.length)
return '';
return this.display(this.layers[index].width);
return this.display(board.layers[index].width);
},
@ -123,7 +142,12 @@ h2
text-align: right;
}
tr:nth-child(even) td
tr.board td
{
font-style: italic;
}
tr:nth-child(even):not(.board) td
{
background-color: #555555;

View File

@ -1,6 +1,6 @@
<template>
<div class="preview">
<div class="dimensions">Dimensions: {{ display(boardWidth) }} x {{ display(boardHeight) }} x {{ display(settings.boardThickness) }}</div>
<div class="dimensions">Dimensions: {{ display(boardWidth) }} x {{ display(boardHeight) }} x {{ display(board.thickness) }}</div>
<svg
:width="viewportWidth"
@ -9,7 +9,7 @@
<rect
v-for="(layer, index) in layers"
:width="toPixels(settings.boardLength)"
:width="toPixels(board.length)"
:height="toPixels(layer.width)"
x="0"
:y="getLayerOffset(index)"
@ -23,16 +23,17 @@ import { units } from '../lib/units';
export default {
props: {
scale: Number
scale: Number,
board: Object
},
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; },
layers() { return this.$store.state.boards[0].layers; },
layers() { return this.board.layers; },
boardWidth() { return this.settings.boardLength; },
boardWidth() { return this.board.length; },
boardHeight()
{
@ -41,7 +42,7 @@ export default {
return this.layers
.map(currentValue => currentValue.width)
.reduce((accumulator, currentValue) => accumulator + currentValue);
.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
},
boardPixelWidth()
@ -90,7 +91,7 @@ export default {
return 'fill: fuchsia';
const woodIndex = this.layers[index].wood;
if (woodIndex === null)
if (woodIndex < 0 || woodIndex >= this.wood.length)
return '';
const borderStyle = this.settings.borders

View File

@ -2,28 +2,41 @@
<div class="preview">
<div class="dimensions">Dimensions: {{ display(boardWidth) }} x {{ display(boardHeight) }} x {{ display(settings.crosscutWidth) }}</div>
<div v-if="boards.length > 1" class="draghint hideOnPrint">Click and drag strips to reorder them. Click once to reverse the direction.</div>
<svg
:width="viewportWidth"
:height="viewportHeight"
:viewBox="viewBox">
:viewBox="viewBox"
:class="{ dragging: dropTarget !== null }">
<defs>
<g id="strip">
<g v-for="(board, boardIndex) in boards" :id="'strip' + boardIndex">
<rect
v-for="(layer, index) in layers"
:width="toPixels(settings.boardThickness)"
v-for="(layer, index) in board.layers"
:width="toPixels(board.thickness)"
:height="toPixels(layer.width)"
x="0"
:y="getLayerOffset(index)"
:style="getLayerStyle(index)" />
:y="getBoardLayerOffset(board, index)"
:style="getBoardLayerStyle(board, index)" />
</g>
<g id="dropTarget">
<line x1="0" y1="0" x2="0" :y2="boardPixelHeight" style="stroke: white; stroke-width: 2" />
</g>
</defs>
<use
v-for="(strip, index) in stripsPerBoard"
xlink:href="#strip"
:x="toPixels(index * settings.boardThickness)"
v-for="(layer, index) in endGrain"
:ref="'strip' + index"
:href="'#strip' + layer.board"
:x="getLayerOffset(index)"
y="0"
:transform="getLayerTransform(index)" />
:transform="getLayerTransform(index)"
@mousedown.prevent="mouseDown(index, $event)" />
<use
v-if="dropTarget !== null"
href="#dropTarget"
:x="getLayerOffset(dropTarget)" />
</svg>
</div>
</template>
@ -37,38 +50,39 @@ export default {
},
data()
{
return {
dragIndex: null,
dropTarget: null
};
},
computed: {
settings() { return this.$store.state.settings; },
boards() { return this.$store.state.boards; },
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;
let stripsPerBoard = (this.settings.boardLength + this.settings.bladeKerf) / stripAndKerf;
// Try to account for rounding errors
stripsPerBoard = units.limitDecimals(stripsPerBoard, 3);
return Math.floor(stripsPerBoard);
},
endGrain() { return this.$store.state.endGrain },
boardWidth()
{
return this.stripsPerBoard * this.settings.boardThickness;
const self = this;
return this.endGrain
.map(layer => layer.board >= 0 && layer.board < self.boards.length ? self.boards[layer.board].thickness : 0)
.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
},
boardHeight()
{
if (this.layers.length == 0)
return 0;
return this.layers
.map(currentValue => currentValue.width)
.reduce((accumulator, currentValue) => accumulator + currentValue);
// Calculate the total width of each board (adding all the layers, inner map/reduce),
// then use the maximum value (outer map/reduce)
return this.boards
.map(board => board.layers
.map(layer => layer.width)
.reduce((accumulator, currentValue) => accumulator + currentValue, 0))
.reduce((accumulator, currentValue) => currentValue > accumulator ? currentValue : accumulator, 0);
},
boardPixelWidth()
@ -98,26 +112,26 @@ export default {
return units.display(value, this.settings.units);
},
getLayerOffset(index)
getBoardLayerOffset(board, index)
{
if (index < 0 || index >= this.layers.length)
if (index < 0 || index >= board.layers.length)
return 0;
let offset = 0;
for (let i = 0; i < index; i++)
offset += this.layers[i].width;
offset += board.layers[i].width;
return this.toPixels(offset);
},
getLayerStyle(index)
getBoardLayerStyle(board, index)
{
if (index < 0 || index >= this.layers.length)
if (index < 0 || index >= board.layers.length)
return 'fill: fuchsia';
const woodIndex = this.layers[index].wood;
if (woodIndex === null)
const woodIndex = board.layers[index].wood;
if (woodIndex < 0 || woodIndex >= this.wood.length)
return '';
const borderStyle = this.settings.borders
@ -127,12 +141,151 @@ export default {
return 'fill: ' + this.wood[woodIndex].color + borderStyle;
},
getLayerOffset(index)
{
if (index < 0 || index > this.endGrain.length)
return 0;
let offset = 0;
for (let i = 0; i < index; i++)
{
const boardIndex = this.endGrain[i].board;
if (boardIndex >= 0 && boardIndex < this.boards.length)
offset += this.boards[boardIndex].thickness;
}
return this.toPixels(offset);
},
getLayerTransform(index)
{
if (!this.settings.alternateDirection || (index % 2) == 0)
return '';
let reversed = false;
return 'scale(1, -1) translate(0, -' + this.boardPixelHeight + ')';
switch (this.settings.direction)
{
case 'alternate':
reversed = (index % 2) == 0;
break;
case 'custom':
reversed = index >= 0 && index < this.endGrain.length && this.endGrain[index].reversed;
break;
}
return reversed ? 'scale(1, -1) translate(0, -' + this.boardPixelHeight + ')' : '';
},
reverseLayer(index)
{
if (this.settings.direction !== 'custom')
return;
if (index < 0 || index >= this.endGrain.length)
return;
this.endGrain[index].reversed = !this.endGrain[index].reversed;
},
mouseDown(index, event)
{
const self = this;
const startX = event.pageX;
let dragging = false;
const dragMouseMove = (moveEvent) =>
{
if (!dragging)
{
if (Math.abs(moveEvent.pageX - startX) >= 5)
{
self.dragIndex = index;
dragging = true;
}
}
if (dragging)
self.dropTarget = self.getTargetStrip(moveEvent.pageX);
};
let dragMouseUp;
dragMouseUp = () =>
{
document.removeEventListener('mousemove', dragMouseMove);
document.removeEventListener('mouseup', dragMouseUp);
if (dragging)
{
if (self.dragIndex !== self.dropTarget)
self.$store.commit('moveEndgrain', { from: self.dragIndex, to: self.dropTarget });
self.dropTarget = null;
self.dragIndex = null;
}
else
self.reverseLayer(index);
};
document.addEventListener('mousemove', dragMouseMove);
document.addEventListener('mouseup', dragMouseUp);
},
getTargetStrip(xPos)
{
if (this.endGrain.length == 0)
return null;
const firstStrip = this.getPageOffsetRect(this.$refs.strip0);
const lastStrip = this.getPageOffsetRect(this.$refs['strip' + (this.endGrain.length - 1)]);
// On or above the first item
if (xPos <= firstStrip.right)
return 0;
// Below the last item
if (xPos >= lastStrip.right)
return this.endGrain.length;
// On the last item
if (xPos >= lastStrip.left)
return this.endGrain.length - 1;
// Check the previous target first, as it is most likely unchanged due to how
// often mouseMove events occur
if (this.dropTarget !== null && this.dropTarget > 0 && this.dropTarget < this.endGrain.length - 1)
{
const currentTarget = this.getPageOffsetRect(this.$refs['strip' + this.dropTarget]);
if (xPos >= currentTarget.left && xPos < currentTarget.right)
return this.dropTarget;
}
// Just loop through all the strips, there shouldn't be enough to warrant anything more efficient
for (let i = 1; i < this.endGrain.length - 1; i++)
{
const testTarget = this.getPageOffsetRect(this.$refs['strip' + i]);
if (xPos >= testTarget.left && xPos < testTarget.right)
return i;
}
// This should never occur, so it probably will!
return null;
},
getPageOffsetRect(element)
{
const clientRect = element.getBoundingClientRect();
const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
return {
top: clientRect.top + scrollTop,
left: clientRect.left + scrollLeft,
right: clientRect.right + scrollLeft,
bottom: clientRect.bottom + scrollTop
};
}
}
}
@ -145,8 +298,16 @@ export default {
}
.draghint
{
margin-bottom: 2em;
}
svg
{
user-select: none;
@media screen
{
box-shadow: 0 0 3em black;
@ -156,5 +317,10 @@ svg
{
max-width: 100%;
}
&.dragging
{
cursor: grabbing;
}
}
</style>

View File

@ -1,31 +1,79 @@
<template>
<div class="layers">
<div class="add">
<button @click="addLayer()">Add layer</button>
</div>
<div class="hint">
Tip: click and drag the layer number to move a layer
</div>
<span class="header">&nbsp;</span>
<span class="header">Wood species</span>
<span class="header">Width</span>
<span class="header">&nbsp;</span>
<template v-for="(layer, index) in layers">
<div class="index" :class="{ dropTargetAbove: dropTarget === index, dropTargetBelow: dropTarget === layers.length && index === layers.length - 1 }" :ref="'layer' + index" @mousedown.prevent="startDrag(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 class="board">
<button @click="previousBoard" :disabled="boardIndex == 0">&lt;</button>
<div class="name">Board {{ boardIndex + 1 }} of {{ boards.length }}</div>
<button @click="removeBoard" v-if="boards.length > 1">Remove</button>
<button @click="addBoard">Add</button>
<button @click="nextBoard" :disabled="boardIndex == boards.length - 1">&gt;</button>
</div>
<template v-if="currentBoard !== null">
<div class="boardsettings">
<label for="boardLength">Board length</label>
<input id="boardLength" type="number" :value="currentBoard.length" @change="$store.commit('updateBoard', { board: boardIndex, values: { length: parseFloatDef($event.target.value) }})" />
<label for="boardThickness">Board thickness</label>
<input id="boardThickness" type="number" :value="currentBoard.thickness" @change="$store.commit('updateBoard', { board: boardIndex, values: { thickness: parseFloatDef($event.target.value) }})" />
</div>
<div class="layers">
<div class="hint">
Tip: click and drag the layer number to move a layer
</div>
<span class="header">&nbsp;</span>
<span class="header">Wood species</span>
<span class="header">Width</span>
<span class="header">&nbsp;</span>
<template v-for="(layer, index) in currentBoard.layers">
<div class="index" :class="{ dropTargetAbove: dropTarget === index, dropTargetBelow: dropTarget === currentBoard.layers.length && index === currentBoard.layers.length - 1 }" :ref="'layer' + index" @mousedown.prevent="startDrag(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 class="widthwarning" v-if="widthWarning !== null">
{{ widthWarning }}
</div>
<div class="add">
<button @click="addLayer()">Add layer</button>
</div>
</div>
<div>
<h2>Preview settings</h2>
<input id="borders" type="checkbox" :checked="settings.borders" @change="$store.commit('updateSettings', { borders: $event.target.checked })" />
<label for="borders"> Show borders</label>
</div>
<div>
<h2>End grain layer direction</h2>
<div>
<input id="directionUniform" type="radio" :checked="settings.direction === 'uniform'" @change="setDirection($event, 'uniform')" />
<label for="directionUniform"> Uniform</label>
</div>
<div>
<input id="directionAlternate" type="radio" :checked="settings.direction === 'alternate'" @change="setDirection($event, 'alternate')" />
<label for="directionAlternate"> Alternate</label>
</div>
<div>
<input id="directionCustom" type="radio" :checked="settings.direction === 'custom'" @change="setDirection($event, 'custom')" />
<label for="directionCustom"> Custom</label>
<p v-if="settings.direction === 'custom'">
Click the strips in the preview to reverse their direction. <span v-if="!settings.borders">This may be easier if you <a href="#" @click.prevent="$store.commit('updateSettings', { borders: true })">turn on</a> the 'Show borders' setting.</span>
</p>
</div>
</div>
</template>
</template>
<script>
@ -35,6 +83,7 @@ export default {
data()
{
return {
boardIndex: 0,
dragIndex: null,
dropTarget: null
}
@ -44,7 +93,66 @@ export default {
computed: {
settings() { return this.$store.state.settings; },
wood() { return this.$store.state.wood; },
layers() { return this.$store.state.boards[0].layers; },
boards() { return this.$store.state.boards; },
currentBoard()
{
return this.boardIndex >= 0 && this.boardIndex < this.boards.length
? this.boards[this.boardIndex]
: null;
},
widthWarning()
{
const self = this;
if (self.currentBoard === null || self.boards.length == 1)
return null;
let minWidth = null;
let currentWidth = null;
let maxWidth = null;
self.boards.forEach((board, index) =>
{
const boardWidth = units.limitDecimals(
board.layers
.map(layer => layer.width)
.reduce((accumulator, currentValue) => accumulator + currentValue, 0),
3);
if (index == self.boardIndex)
currentWidth = boardWidth;
if (minWidth === null || boardWidth < minWidth)
minWidth = boardWidth;
if (maxWidth === null || boardWidth > maxWidth)
maxWidth = boardWidth;
});
if (minWidth == maxWidth)
return null;
let message = "Your board are not of equal width. The current board is " + units.display(currentWidth, this.settings.units) + " ";
if (currentWidth < maxWidth)
message += "while the widest is " + units.display(maxWidth, this.settings.units) + ". ";
else
message += "while the smallest is " + units.display(minWidth, this.settings.units) + ". ";
message += "The end grain board will not align.";
return message;
},
maxBoardWidth()
{
// This is a copy from EndGrainPreview.vue, deduplicate maybe?
return this.boards
.map(board => board.layers
.map(layer => layer.width)
.reduce((accumulator, currentValue) => accumulator + currentValue, 0))
.reduce((accumulator, currentValue) => currentValue > accumulator ? currentValue : accumulator, 0);
}
},
@ -56,26 +164,60 @@ export default {
},
previousBoard()
{
if (this.boardIndex > 0)
this.boardIndex--;
},
nextBoard()
{
if (this.boardIndex < this.boards.length - 1)
this.boardIndex++;
},
addBoard()
{
this.$store.commit('addBoard', this.boardIndex);
this.boardIndex = this.boards.length - 1;
},
removeBoard()
{
if (this.boards.length <= 1)
return;
this.$store.commit('removeBoard', this.boardIndex);
if (this.boardIndex >= this.boards.length)
this.boardIndex = this.boards.length - 1;
},
addLayer()
{
this.$store.commit('addLayer', 0);
this.$store.commit('addLayer', this.boardIndex);
},
removeLayer(index)
{
this.$store.commit('removeLayer', { board: 0, layer: index });
this.$store.commit('removeLayer', { board: this.boardIndex, layer: index });
},
startDrag(index)
{
this.dragIndex = index;
this.dropTarget = index;
const self = this;
self.dragIndex = index;
self.dropTarget = index;
const dragMouseMove = (event) =>
{
this.dropTarget = this.getTargetLayer(event.pageY);
self.dropTarget = self.getTargetLayer(event.pageY);
};
let dragMouseUp;
@ -84,11 +226,11 @@ export default {
document.removeEventListener('mousemove', dragMouseMove);
document.removeEventListener('mouseup', dragMouseUp);
if (this.dragIndex !== this.dropTarget)
this.$store.commit('moveLayer', { board: 0, from: this.dragIndex, to: this.dropTarget });
if (self.dragIndex !== self.dropTarget)
self.$store.commit('moveLayer', { board: this.boardIndex, from: self.dragIndex, to: self.dropTarget });
this.dropTarget = null;
this.dragIndex = null;
self.dropTarget = null;
self.dragIndex = null;
};
document.addEventListener('mousemove', dragMouseMove);
@ -98,11 +240,11 @@ export default {
getTargetLayer(yPos)
{
if (this.layers.length == 0)
if (this.currentBoard === null || this.currentBoard.layers.length == 0)
return null;
const firstLayer = this.getPageOffsetRect(this.$refs.layer0);
const lastLayer = this.getPageOffsetRect(this.$refs['layer' + (this.layers.length - 1)]);
const lastLayer = this.getPageOffsetRect(this.$refs['layer' + (this.currentBoard.layers.length - 1)]);
// On or above the first item
if (yPos <= firstLayer.bottom)
@ -110,15 +252,15 @@ export default {
// Below the last item
if (yPos >= lastLayer.bottom)
return this.layers.length;
return this.currentBoard.layers.length;
// On the last item
if (yPos >= lastLayer.top)
return this.layers.length - 1;
return this.currentBoard.layers.length - 1;
// Check the previous target first, as it is most likely unchanged due to how
// often mouseMove events occur
if (this.dropTarget !== null && this.dropTarget > 0 && this.dropTarget < this.layers.length - 1)
if (this.dropTarget !== null && this.dropTarget > 0 && this.dropTarget < this.currentBoard.layers.length - 1)
{
const currentTarget = this.getPageOffsetRect(this.$refs['layer' + this.dropTarget]);
if (yPos >= currentTarget.top && yPos < currentTarget.bottom)
@ -126,7 +268,7 @@ export default {
}
// Just loop through all the layers, there shouldn't be enough to warrant anything more efficient
for (let i = 1; i < this.layers.length - 1; i++)
for (let i = 1; i < this.currentBoard.layers.length - 1; i++)
{
const testTarget = this.getPageOffsetRect(this.$refs['layer' + i]);
if (yPos >= testTarget.top && yPos < testTarget.bottom)
@ -150,6 +292,15 @@ export default {
right: clientRect.right + scrollLeft,
bottom: clientRect.bottom + scrollTop
};
},
setDirection(event, direction)
{
if (!event.target.checked)
return;
this.$store.commit('updateSettings', { direction: direction });
}
}
}
@ -158,8 +309,8 @@ export default {
<style lang="scss" scoped>
.layers
{
display: inline-grid;
grid-template-columns: 3em 20em 5em 3em;
display: grid;
grid-template-columns: min-content auto 5em min-content;
grid-column-gap: 1em;
.hint
@ -173,23 +324,33 @@ export default {
.index
{
cursor: pointer;
cursor: grab;
&.dropTargetAbove
{
border-top: solid 1px white;
cursor: grabbing;
}
&.dropTargetBelow
{
border-bottom: solid 1px white;
cursor: grabbing;
}
}
.add
{
grid-column: 2 / 5;
padding-bottom: 1em;
padding-top: 1em;
}
.widthwarning
{
font-size: 80%;
color: yellow;
grid-column: 2 / 5;
padding-top: .5em;
}
.header
@ -198,4 +359,32 @@ export default {
margin-bottom: .25em;
}
}
.board
{
display: flex;
margin-bottom: 2em;
.name
{
flex-grow: 1;
padding: .5em;
}
}
.boardsettings
{
display: inline-grid;
grid-template-columns: max-content 5em;
grid-column-gap: 1em;
margin-bottom: 2em;
}
h2
{
margin-top: 2em;
}
</style>

View File

@ -10,25 +10,13 @@
<!--<option value="inchfractional">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) })" />
<h2>Tools</h2>
<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>
@ -61,8 +49,6 @@ export default {
h2
{
color: #808080;
font-size: 80%;
margin-top: 1em;
margin-bottom: .25em;
grid-column: 1 / 3;

View File

@ -1,9 +1,5 @@
<template>
<div class="wood">
<div class="add">
<button @click="addWood()">Add wood species</button>
</div>
<span class="header">Name</span>
<span class="header">Colour</span>
<span class="header">&nbsp;</span>
@ -16,6 +12,10 @@
<button @click="removeWood(index)">X</button>
</div>
</template>
<div class="add">
<button @click="addWood()">Add wood species</button>
</div>
</div>
</template>
@ -45,14 +45,14 @@ export default {
<style lang="scss" scoped>
.wood
{
display: inline-grid;
grid-template-columns: 23em 5em 3em;
display: grid;
grid-template-columns: auto min-content min-content;
grid-column-gap: 1em;
.add
{
grid-column: 1 / 4;
padding-bottom: 1em;
padding-top: 1em;
}
.header
@ -60,5 +60,10 @@ export default {
font-weight: bold;
margin-bottom: .25em;
}
button
{
height: 100%;
}
}
</style>

50
src/lib/enums.js Normal file
View File

@ -0,0 +1,50 @@
const UnitsEnum = {
mm: 'mm',
cm: 'cm',
inchdecimal: 'inchdecimal',
//inchfractional: 'inchfractional'
};
UnitsEnum.isValid = (value) =>
{
switch (value)
{
case UnitsEnum.mm:
case UnitsEnum.cm:
case UnitsEnum.inchdecimal:
//case UnitsEnum.inchfractional:
return true;
default:
return false;
}
};
const DirectionEnum = {
uniform: 'uniform',
alternate: 'alternate',
custom: 'custom'
};
DirectionEnum.isValid = (value) =>
{
switch (value)
{
case DirectionEnum.uniform:
case DirectionEnum.alternate:
case DirectionEnum.custom:
return value;
default:
return DirectionEnum.uniform;
}
};
export {
UnitsEnum,
DirectionEnum
}

View File

@ -1,118 +1,79 @@
import { createStore } from 'vuex';
import { units } from './lib/units';
import { UnitsEnum, DirectionEnum } from './lib/enums';
import { serialize, deserialize } from '@ygoe/msgpack';
function mergeObject(source, target)
{
for (const property in source)
{
if (!source.hasOwnProperty(property) || !target.hasOwnProperty(property))
continue;
target[property] = source[property];
}
function DefaultSettings()
{
return {
units: UnitsEnum.cm,
borders: false,
bladeKerf: 0.35,
crosscutWidth: 3,
direction: DirectionEnum.uniform
};
}
function parseFloatDef(value)
function DefaultWood()
{
const parsedValue = parseFloat(value);
return Object.is(parsedValue, NaN) ? 0 : parsedValue;
return [
{ 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' }
];
}
function loadObject(state, parsedPayload)
function DefaultBoards()
{
if (parsedPayload.hasOwnProperty('settings'))
mergeObject(parsedPayload.settings, state.settings);
if (parsedPayload.hasOwnProperty('boards'))
{
const newBoards = parsedPayload.boards.map(board =>
return [
{
if (!board.hasOwnProperty('layers'))
{
return {
layers: []
};
}
thickness: 2,
length: 70,
return {
layers: board.layers.map(layer =>
{
return {
wood: parseFloatDef(layer.wood),
width: parseFloatDef(layer.width)
}
})
};
})
if (newBoards.length === 0)
newBoards.push({ layers: [] });
state.boards = newBoards;
}
if (parsedPayload.hasOwnProperty('wood'))
{
const newWood = parsedPayload.wood.map(item =>
{
return {
name: item.name,
color: item.color
};
});
state.wood = newWood;
}
layers: [
{ wood: 8, width: 1 },
{ wood: 1, width: 1.5 },
{ wood: 8, width: 2 },
{ wood: 1, width: 2 },
{ wood: 8, width: 15 },
{ wood: 1, width: 2 },
{ wood: 8, width: 1.5 },
{ wood: 1, width: 1 }
]
}
];
}
function DefaultEndGrain()
{
const settings = DefaultSettings();
const boards = DefaultBoards();
const result = [];
updateEndGrain(result, settings, boards);
return result;
}
export default createStore({
state: {
settings: {
units: 'cm',
borders: false,
boardThickness: 2,
boardLength: 70,
bladeKerf: 0.35,
crosscutWidth: 3,
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: 8, width: 1 },
{ wood: 1, width: 1.5 },
{ wood: 8, width: 2 },
{ wood: 1, width: 2 },
{ wood: 8, width: 15 },
{ wood: 1, width: 2 },
{ wood: 8, width: 1.5 },
{ wood: 1, width: 1 }
]
}
]
settings: DefaultSettings(),
wood: DefaultWood(),
boards: DefaultBoards(),
endGrain: DefaultEndGrain()
},
mutations: {
@ -146,29 +107,7 @@ export default createStore({
return;
const board = state.boards[payload.board];
if (payload.from < 0 || payload.from >= board.layers.length)
return;
if (payload.to < 0 || payload.to > board.layers.length)
return;
if (payload.to == board.layers.length)
{
// Move to end
board.layers.push(board.layers[payload.from]);
board.layers.splice(payload.from, 1);
}
else
{
const item = board.layers[payload.from];
board.layers.splice(payload.from, 1);
if (payload.to > payload.from)
payload.to--;
board.layers.splice(payload.to, 0, item);
}
moveArrayItem(board.layers, payload.from, payload.to);
},
@ -191,7 +130,7 @@ export default createStore({
board.layers.forEach(layer =>
{
if (layer.wood === index)
layer.wood = null
layer.wood = -1
else if (layer.wood > index)
layer.wood--;
});
@ -204,20 +143,20 @@ export default createStore({
updateSettings(state, payload)
{
const oldUnits = state.settings.units;
mergeObject(payload, state.settings);
if (oldUnits !== state.settings.units)
{
// Convert the settings
state.settings.boardThickness = units.limitDecimals(units.convert(state.settings.boardThickness, oldUnits, state.settings.units), 3);
state.settings.boardLength = units.limitDecimals(units.convert(state.settings.boardLength, oldUnits, state.settings.units), 3);
state.settings.bladeKerf = units.limitDecimals(units.convert(state.settings.bladeKerf, oldUnits, state.settings.units), 3);
state.settings.crosscutWidth = units.limitDecimals(units.convert(state.settings.crosscutWidth, oldUnits, state.settings.units), 3);
// Convert the layers
// Convert the boards
state.boards.forEach(board =>
{
board.thickness = units.limitDecimals(units.convert(board.thickness, oldUnits, state.settings.units), 3);
board.length = units.limitDecimals(units.convert(board.length, oldUnits, state.settings.units), 3);
board.layers.forEach(layer =>
{
layer.width = units.limitDecimals(units.convert(layer.width, oldUnits, state.settings.units), 3);
@ -227,6 +166,81 @@ export default createStore({
},
addBoard(state, copyFrom)
{
if (copyFrom < 0 || copyFrom >= state.boards.length)
{
state.boards.push({
thickness: 2,
length: 70,
layers: []
});
return;
}
const source = state.boards[copyFrom];
state.boards.push({
thickness: source.thickness,
length: source.length,
layers: source.layers.map(layer =>
{
return {
wood: layer.wood,
width: layer.width
};
})
});
updateEndGrain(state.endGrain, state.settings, state.boards);
},
removeBoard(state, index)
{
if (index < 0 || index >= state.boards.length)
return;
state.boards.splice(index, 1);
// Update all references to the boards coming after
state.endGrain.forEach(layer =>
{
if (layer.board == index)
layer.board = -1;
else if (layer.board > index)
layer.board--;
});
updateEndGrain(state.endGrain, state.settings, state.boards);
},
updateBoard(state, payload)
{
if (payload.board < 0 || payload.board >= state.boards.length)
return;
const board = state.boards[payload.board];
const oldLength = board.length;
mergeObject(payload.values, board);
if (oldLength !== board.length)
updateEndGrain(state.endGrain, state.settings, state.boards);
},
moveEndgrain(state, payload)
{
moveArrayItem(state.endGrain, payload.from, payload.to);
},
// TODO some updates, like Wood properties and Layer width, don't go through the store yet. this works,
// but is kinda defeating the rules of Vuex.
load(state, payload)
{
const parsedPayload = JSON.parse(payload);
@ -238,7 +252,8 @@ export default createStore({
try
{
const parsedPayload = deserialize(payload);
loadObject(state, parsedPayload);
if (parsedPayload)
loadObject(state, parsedPayload);
}
catch(e)
{
@ -250,12 +265,416 @@ export default createStore({
getters: {
save(state)
{
return JSON.stringify(state);
const serialized = serializeState(state, false);
return JSON.stringify(serialized);
},
saveMsgPack(state)
{
return serialize(state);
const serialized = serializeState(state, true);
return serialize(serialized);
}
}
})
});
function moveArrayItem(array, from, to)
{
if (from < 0 || from >= array.length)
return;
if (to < 0 || to > array.length)
return;
if (to == array.length)
{
// Move to end
array.push(array[from]);
array.splice(from, 1);
}
else
{
const item = array[from];
array.splice(from, 1);
if (to > from)
to--;
array.splice(to, 0, item);
}
}
function stripsPerBoard(settings, board)
{
const stripAndKerf = settings.crosscutWidth + settings.bladeKerf;
if (stripAndKerf === 0)
return 0;
let stripsPerBoard = (board.length + settings.bladeKerf) / stripAndKerf;
// Try to account for rounding errors
stripsPerBoard = units.limitDecimals(stripsPerBoard, 3);
return Math.floor(stripsPerBoard);
}
function updateEndGrain(result, settings, boards)
{
const boardTally = [];
for (let i = 0; i < boards.length; i++)
boardTally[i] = boards[i].length + settings.bladeKerf;
const cutWidth = settings.crosscutWidth + settings.bladeKerf;
const remove = [];
// Check the current configuration and see if we still have enough
result.forEach((layer, index) =>
{
let boardAvailable = false;
if (layer.board >= 0 && layer.board < boards.length)
{
boardAvailable = boardTally[layer.board] >= cutWidth;
if (boardAvailable)
boardTally[layer.board] -= cutWidth;
}
if (!boardAvailable)
remove.push(index);
});
for (let i = remove.length - 1; i >= 0; i--)
result.splice(remove[i], 1);
// If we have sufficient board length left, add the layers for it
boardTally.forEach((remaining, index) =>
{
while (remaining >= cutWidth)
{
result.push({ board: index, reversed: false });
remaining -= cutWidth;
// Try to account for rounding errors
remaining = units.limitDecimals(remaining, 3);
}
});
}
function mergeObject(source, target)
{
for (const property in source)
{
if (!source.hasOwnProperty(property) || !target.hasOwnProperty(property))
continue;
target[property] = source[property];
}
}
function parseFloatDef(value)
{
const parsedValue = parseFloat(value);
return isNaN(parsedValue) ? 0 : parsedValue;
}
function parseIntDef(value)
{
const parsedValue = parseInt(value);
return isNaN(parsedValue) ? 0 : parsedValue;
}
function loadObject(state, parsedPayload)
{
const deserialized = deserializeState(parsedPayload);
mergeObject(deserialized.settings, state.settings);
state.wood = deserialized.wood;
state.boards = deserialized.boards;
state.endGrain = deserialized.endGrain;
updateEndGrain(state.endGrain, state.settings, state.boards);
}
// For the MessagePack version that is used in the URL, we minimize the
// property names as well to significantly reduce the size. Do not do
// this for the downloaded versions to make them more readable.
// Note that this map is also used to convert older MsgPack encoded payloads,
// as they used the same layout as the JSON data.
const SettingsNameMapJSON = {
settings: {
self: 'settings',
units: 'units',
borders: 'borders',
bladeKerf: 'bladeKerf',
crosscutWidth: 'crosscutWidth',
direction: 'direction'
},
wood: {
self: 'wood',
name: 'name',
color: 'color'
},
boards: {
self: 'boards',
thickness: 'thickness',
length: 'length',
layers: {
self: 'layers',
wood: 'wood',
width: 'width'
}
},
endGrain: {
self: 'endGrain',
board: 'board',
reversed: 'reversed'
}
};
const SettingsNameMapMsgPack = {
settings: {
self: 's',
units: 'u',
borders: 'b',
bladeKerf: 'k',
crosscutWidth: 'c',
direction: 'd'
},
wood: {
self: 'w',
name: 'n',
color: 'c'
},
boards: {
self: 'b',
thickness: 't',
length: 'x',
layers: {
self: 'l',
wood: 'w',
width: 'x'
}
},
endGrain: {
self: 'e',
board: 'b',
reversed: 'r'
}
};
// All external input and output is done through these functions to provide backwards
// compatibility and perform sanity checking. This means the rest of the application
// trust that the contents of the store is as expected.
function serializeState(state, messagePack)
{
const map = messagePack ? SettingsNameMapMsgPack : SettingsNameMapJSON;
const result = {};
result[map.settings.self] = serializeSettings(state.settings, map);
result[map.wood.self] = serializeWood(state.wood, map);
result[map.boards.self] = serializeBoards(state.boards, map);
result[map.endGrain.self] = serializeEndgrain(state.endGrain, map);
return result;
}
function serializeSettings(settings, map)
{
const result = {};
result[map.settings.units] = settings.units;
result[map.settings.borders] = settings.borders;
result[map.settings.bladeKerf] = settings.bladeKerf;
result[map.settings.crosscutWidth] = settings.crosscutWidth;
result[map.settings.direction] = settings.direction;
return result;
}
function serializeWood(wood, map)
{
if (wood.length === 0)
return [];
return wood.map(item =>
{
const result = {};
result[map.wood.name] = item.name;
result[map.wood.color] = item.color;
return result;
});
}
function serializeBoards(boards, map)
{
return boards.map(board =>
{
const boardResult = {};
boardResult[map.boards.length] = board.length;
boardResult[map.boards.thickness] = board.thickness;
boardResult[map.boards.layers.self] = board.layers.map(layer =>
{
const layerResult = {};
layerResult[map.boards.layers.wood] = layer.wood;
layerResult[map.boards.layers.width] = layer.width;
return layerResult;
});
return boardResult;
});
}
function serializeEndgrain(endGrain, map)
{
return endGrain.map(layer =>
{
const layerResult = {};
layerResult[map.endGrain.board] = layer.board;
layerResult[map.endGrain.reversed] = layer.reversed;
return layerResult;
});
}
function deserializeState(parsedPayload)
{
const map = parsedPayload.hasOwnProperty(SettingsNameMapMsgPack.settings.self) ? SettingsNameMapMsgPack : SettingsNameMapJSON;
const result = {
settings: deserializeSettings(parsedPayload, map),
wood: deserializeWood(parsedPayload, map),
boards: deserializeBoards(parsedPayload, map),
endGrain: deserializeEndgrain(parsedPayload, map)
};
// Backwards compatibility
if (parsedPayload.hasOwnProperty('settings'))
{
if (parsedPayload.settings.hasOwnProperty('alternateDirection'))
result.settings.direction = parsedPayload.settings.alternateDirection ? DirectionEnum.alternate : DirectionEnum.uniform;
if (parsedPayload.settings.hasOwnProperty('boardLength'))
result.boards[0].length = parseFloatDef(parsedPayload.settings.boardLength);
if (parsedPayload.settings.hasOwnProperty('boardThickness'))
result.boards[0].thickness = parseFloatDef(parsedPayload.settings.boardThickness);
}
if (result.endGrain.length === 0)
updateEndGrain(result.endGrain, result.settings, result.boards);
return result;
}
function deserializeSettings(parsedPayload, map)
{
if (!parsedPayload.hasOwnProperty(map.settings.self))
return DefaultSettings();
const settings = parsedPayload[map.settings.self];
return {
units: UnitsEnum.isValid(settings[map.settings.units]) ? settings[map.settings.units] : UnitsEnum.cm,
borders: settings[map.settings.borders] === true,
bladeKerf: parseFloatDef(settings[map.settings.bladeKerf]),
crosscutWidth: parseFloatDef(settings[map.settings.crosscutWidth]),
direction: DirectionEnum.isValid(settings[map.settings.direction]) ? settings[map.settings.direction] : DirectionEnum.uniform,
};
}
function deserializeWood(parsedPayload, map)
{
if (!parsedPayload.hasOwnProperty(map.wood.self) || !Array.isArray(parsedPayload[map.wood.self]))
return [];
return parsedPayload[map.wood.self].map(item =>
{
return {
name: item[map.wood.name],
color: /^#[0-9A-F]{6}$/i.test(item[map.wood.color] || '') ? item[map.wood.color] : '#000000'
};
});
}
function deserializeBoards(parsedPayload, map)
{
if (!parsedPayload.hasOwnProperty(map.boards.self) || !Array.isArray(parsedPayload[map.boards.self]))
return [];
const result = parsedPayload[map.boards.self].map(board =>
{
const boardResult = {
thickness: parseFloatDef(board[map.boards.thickness]),
length: parseFloatDef(board[map.boards.length]),
layers: []
};
if (board.hasOwnProperty(map.boards.layers.self) && Array.isArray(board[map.boards.layers.self]))
{
boardResult.layers = board[map.boards.layers.self].map(layer =>
{
return {
wood: parseIntDef(layer[map.boards.layers.wood]),
width: parseFloatDef(layer[map.boards.layers.width])
}
});
}
return boardResult;
});
return result.length > 0
? result
: { length: 0, thickness: 0, layers: [] };
}
function deserializeEndgrain(parsedPayload, map)
{
if (!parsedPayload.hasOwnProperty(map.endGrain.self) || !Array.isArray(parsedPayload[map.endGrain.self]))
return [];
return parsedPayload[map.endGrain.self].map(layer =>
{
return {
board: parseIntDef(layer[map.endGrain.board]),
reversed: layer[map.endGrain.reversed] === true
};
});
}