add actions and reducer

This commit is contained in:
casperlamboo 2017-11-14 15:27:48 +01:00
parent ed5c3979d3
commit 2be3bc6362
40 changed files with 3594 additions and 1 deletions

33
package-lock.json generated
View File

@ -4408,6 +4408,11 @@
}
}
},
"ramda": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz",
"integrity": "sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ=="
},
"randomatic": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz",
@ -4487,6 +4492,15 @@
"prop-types": "15.6.0"
}
},
"react-addons-update": {
"version": "15.6.2",
"resolved": "https://registry.npmjs.org/react-addons-update/-/react-addons-update-15.6.2.tgz",
"integrity": "sha1-5TdTxbNIh5dFEMiC1/sHWFHV5QQ=",
"requires": {
"fbjs": "0.8.16",
"object-assign": "4.1.1"
}
},
"react-jss": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/react-jss/-/react-jss-8.0.0.tgz",
@ -4547,6 +4561,17 @@
"set-immediate-shim": "1.0.1"
}
},
"redux": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
"integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==",
"requires": {
"lodash": "4.17.4",
"lodash-es": "4.17.4",
"loose-envify": "1.3.1",
"symbol-observable": "1.0.4"
}
},
"redux-form": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/redux-form/-/redux-form-7.1.2.tgz",
@ -4562,6 +4587,14 @@
"prop-types": "15.6.0"
}
},
"redux-undo": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/redux-undo/-/redux-undo-0.6.1.tgz",
"integrity": "sha1-/0B3Sbj0aL6tY25BOBnpLAmC7so=",
"requires": {
"redux": "3.7.2"
}
},
"regenerate": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.3.3.tgz",

View File

@ -28,12 +28,15 @@
"pouchdb": "^6.3.4",
"proptypes": "^1.1.0",
"raf": "^3.4.0",
"ramda": "^0.25.0",
"raw-loader": "^0.5.1",
"react": "^16.0.0",
"react-addons-update": "^15.6.2",
"react-jss": "^8.0.0",
"react-redux": "^5.0.6",
"react-resize-detector": "^1.1.0",
"redux-form": "^7.1.2",
"redux-undo": "^0.6.1",
"regenerator-runtime": "^0.11.0",
"semver": "^5.4.1",
"shortid": "^2.2.8",

View File

@ -3,5 +3,7 @@ import * as utils from './utils/index.js';
import * as d3 from './d3/index.js';
import * as components from './components/index.js';
import * as constants from './constants/index.js';
import * as actions from './actions/index.js';
import * as reducer from './reducer/index.js';
export { shape, utils, d3, components, constants };
export { shape, utils, d3, components, constants, actions, reducer };

View File

@ -0,0 +1,15 @@
import { CANVAS_SIZE, INITIAL_IMAGE_SCALE } from '../../constants/d2Constants.js';
import { Matrix } from 'cal';
import { addObject } from '../objectReducers.js';
const IMAGE_SIZE = CANVAS_SIZE * 2 * INITIAL_IMAGE_SCALE;
export default function addImageReducer(state, action) {
const { payload: imageData } = action;
const scale = Math.min(IMAGE_SIZE / imageData.width, IMAGE_SIZE / imageData.height);
const transform = new Matrix();
transform.scale = scale;
return addObject(state, { type: 'IMAGE_GUIDE', imageData, transform });
}

View File

@ -0,0 +1,15 @@
import { Vector, Utils as CALUtils } from 'cal';
import { MIN_ZOOM, MAX_ZOOM, CANVAS_SIZE } from '../../constants/d2Constants.js';
export default function constrainMatrix(matrix) {
const scale = matrix.sx;
const scaleClamped = CALUtils.MathExtended.clamb(scale, MIN_ZOOM, MAX_ZOOM);
matrix.scale = scaleClamped;
const pan = new Vector().copy(matrix).scale(scaleClamped / scale);
const maxTranslate = (CANVAS_SIZE / MIN_ZOOM - CANVAS_SIZE / scaleClamped) * scaleClamped;
matrix.x = CALUtils.MathExtended.clamb(pan.x, -maxTranslate, maxTranslate);
matrix.y = CALUtils.MathExtended.clamb(pan.y, -maxTranslate, maxTranslate);
matrix.rotation = 0;
}

View File

@ -0,0 +1,18 @@
import update from 'react-addons-update';
import constrainMatrix from './constrainMatrix.js';
export default function d2PanReducer(state, action) {
let { canvasMatrix } = state.d2;
const matrix = action.screenMatrixContainer.normalize().inverseMatrix();
const delta = action.position.subtract(action.previousPosition).applyMatrix(matrix);
canvasMatrix = canvasMatrix.translate(delta.x, delta.y);
constrainMatrix(canvasMatrix);
return update(state, {
d2: {
canvasMatrix: { $set: canvasMatrix }
}
});
}

View File

@ -0,0 +1,41 @@
import update from 'react-addons-update';
import { Matrix } from 'cal';
import constrainMatrix from './constrainMatrix.js';
import { calculateGestureMatrix } from '../../utils/matrixUtils.js';
import * as actions from '../../actions/index.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:d2:pinchZoom');
export default function pinchZoomReducer(state, action) {
if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.CLEAR:
return update(state, {
d2: { canvasMatrix: { $set: new Matrix() } }
});
case actions.D2_MULTITOUCH:
return multitouch(state, action);
default:
return state;
}
}
function multitouch(state, { positions, previousPositions, screenMatrixContainer }) {
const gestureMatrix = calculateGestureMatrix(positions, previousPositions, screenMatrixContainer, {
rotate: false, scale: true, pan: true
});
const { canvasMatrix } = state.d2;
const matrix = canvasMatrix.multiplyMatrix(gestureMatrix);
constrainMatrix(matrix);
return update(state, {
d2: {
canvasMatrix: { $set: matrix }
}
});
}

View File

@ -0,0 +1,85 @@
import update from 'react-addons-update';
import circleReducer from './tools/shapes/circleReducer.js';
import circleSegmentReducer from './tools/shapes/circleSegmentReducer.js';
import rectReducer from './tools/shapes/rectReducer.js';
import polyPointReducer from './tools/shapes/polyPointReducer.js';
import heartReducer from './tools/shapes/heartReducer.js';
import starReducer from './tools/shapes/starReducer.js';
import triangleReducer from './tools/shapes/triangleReducer.js';
import bucketReducer from './tools/bucketReducer.js';
import penReducer from './tools/penReducer.js';
import textReducer from './tools/textReducer.js';
import photoGuideReducer from './tools/photoGuideReducer.js';
import { transformReducer } from './tools/transformReducer.js';
import eraserReducer from './tools/eraserReducer.js';
import * as actions from '../../actions/index.js';
import * as tools from '../../constants/d2Tools.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:d2:tool');
const reducers = {
[tools.CIRCLE]: circleReducer,
[tools.CIRCLE_SEGMENT]: circleSegmentReducer,
[tools.FREE_HAND]: penReducer,
[tools.RECT]: rectReducer,
[tools.STAR]: starReducer,
[tools.TRIANGLE]: triangleReducer,
[tools.POLY_POINT]: polyPointReducer,
[tools.HEART]: heartReducer,
[tools.TRANSFORM]: transformReducer,
[tools.ERASER]: eraserReducer,
[tools.POLYGON]: penReducer,
[tools.PHOTO_GUIDE]: photoGuideReducer,
[tools.BUCKET]: bucketReducer,
[tools.TEXT]: textReducer,
[tools.BRUSH]: penReducer
};
export default function toolReducer(state, action) {
// if (action.log !== false) debug(action.type);
// change 2D tool after explicit tool change action or on some selection
if (action.type === actions.D2_CHANGE_TOOL) {
state = updateTool(state, action.tool);
}
if (action.category === actions.CAT_SELECTION) {
state = updateTool(state, tools.TRANSFORM);
}
switch (action.type) {
case actions.D2_DRAG_START:
state = update(state, {
d2: { dragging: { $set: true } }
});
break;
case actions.D2_DRAG_END:
state = update(state, {
d2: { dragging: { $set: false } }
});
break;
default:
break;
}
const tool = state.d2.tool;
const reducer = reducers[tool];
if (reducer) {
return reducer(state, action);
} else {
if (action.log !== false) debug('Unkown 2D tool: ', tool);
return state;
}
}
function updateTool(state, newTool) {
if (newTool === state.d2.tool) {
return state;
} else {
debug('d2 tool: ', newTool);
return update(state, {
d2: { tool: { $set: newTool } }
});
}
}

View File

@ -0,0 +1,130 @@
import * as actions from '../../../actions/index.js';
import { SHAPE_TYPE_PROPERTIES } from '../../../constants/shapeTypeProperties.js';
import { LINE_WIDTH, CLIPPER_PRECISION } from '../../../constants/d2Constants.js';
import createDebug from 'debug';
import { shapeToPoints, applyMatrixOnShape, pathToVectorPath } from '../../../utils/shapeDataUtils.js';
import { addObject } from '../../../reducers/objectReducers.js';
import fillPath from 'fill-path';
import ClipperShape from 'clipper-js';
import subtractShapeFromState from '../../../utils/subtractShapeFromState.js';
import update from 'react-addons-update';
import { get as getConfig } from '../../../services/config.js';
import { getColor, getFirst, filterType, getObjectsFromIds } from '../../../utils/objectSelectors.js';
const debug = createDebug('d3d:reducer:bucket');
const MITER_LIMIT = 30.0;
export default function bucketReducer(state, action) {
if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.D2_TAP:
const { experimentalColorPicker } = getConfig();
let color = state.context.color;
if (experimentalColorPicker) {
const imageColor = getColor(
getFirst(
filterType(
getObjectsFromIds(state, action.objects),
'IMAGE_GUIDE'
)
),
action.position,
action.screenMatrixZoom
);
if (imageColor !== null) color = imageColor;
}
// if clicked on a filled shape change shape color
const filledPathIndex = action.objects.findIndex(id => (
state.objectsById[id].fill &&
SHAPE_TYPE_PROPERTIES[state.objectsById[id].type].tools[state.d2.tool]
));
if (filledPathIndex !== -1) {
const id = action.objects[filledPathIndex];
return update(state, {
objectsById: { [id]: { color: { $set: color } } }
});
}
// otherwise fill paths
const {
screenMatrixContainer,
screenMatrixZoom
} = action;
// convert mouse position to container space
// discard screen matrix zoom and apply screen matrix container
const matrix = screenMatrixZoom.inverseMatrix().multiplyMatrix(screenMatrixContainer);
const position = action.position.applyMatrix(matrix);
const paths = getPaths(state, screenMatrixContainer);
const result = fillPath(paths, position, {
lineWidth: LINE_WIDTH,
miterLimit: MITER_LIMIT,
fillOffset: 'outside'
});
// TODO
// reduce number of points of result, sometimes result has 900+ points
if (result.length === 0) return state;
const fillPaths = result
.map(pathToVectorPath)
.map(path => {
path.push(path[0].clone());
return path;
});
const fillShape = new ClipperShape(fillPaths, true, true, true)
.offset(LINE_WIDTH / 2.0, {
joinType: 'jtMiter',
endType: 'etClosedPolygon',
miterLimit: MITER_LIMIT
});
state = subtractShapeFromState(state, fillShape, state.d2.tool, {
matrix: screenMatrixContainer,
skipCompoundPath: true,
scale: CLIPPER_PRECISION
});
return addCompoundPathToState(state, fillPaths, screenMatrixContainer.inverseMatrix(), color);
default:
return state;
}
}
function getPaths(state, screenMatrixContainer) {
const paths = [];
for (const id of state.spaces[state.activeSpace].objectIds) {
const shapeData = state.objectsById[id];
if (!SHAPE_TYPE_PROPERTIES[shapeData.type].tools[state.d2.tool]) continue;
const shapes = shapeToPoints(shapeData);
for (let i = 0; i < shapes.length; i ++) {
const { points, holes } = shapes[i];
const matrix = shapeData.transform.multiplyMatrix(screenMatrixContainer);
const shape = applyMatrixOnShape([points, ...holes], matrix);
paths.push(...shape);
}
}
return paths;
}
function addCompoundPathToState(state, paths, transform, color) {
const [points, ...holes] = paths;
return addObject(state, {
type: 'COMPOUND_PATH',
transform,
points,
holes,
color
});
}

View File

@ -0,0 +1,35 @@
import * as actions from '../../../actions/index';
import ClipperShape from 'clipper-js';
import * as d2Tools from '../../../constants/d2Tools';
import subtractShapeFromState from '../../../utils/subtractShapeFromState';
import { CLIPPER_PRECISION } from '../../../constants/d2Constants.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:eraser');
export default function eraserReducer(state, action) {
switch (action.type) {
case actions.D2_TAP:
return handleEraser(state, [action.position], action.screenMatrixZoom);
case actions.D2_DRAG_START:
return handleEraser(state, action.preDrags, action.screenMatrixZoom);
case actions.D2_DRAG:
return handleEraser(state, [action.previousPosition, action.position], action.screenMatrixZoom);
default:
return state;
}
}
function handleEraser(state, path, screenMatrixZoom) {
// create eraser line from prev mouse position to new mouse position
const eraserShape = new ClipperShape([path], false, true, true).offset(state.d2.eraser.size, {
jointType: 'jtRound',
endType: 'etOpenRound',
miterLimit: 2.0,
roundPrecision: 0.25
});
return subtractShapeFromState(state, eraserShape, d2Tools.ERASER, { matrix: screenMatrixZoom, scale: CLIPPER_PRECISION });
}

View File

@ -0,0 +1,257 @@
import update from 'react-addons-update';
import * as actions from '../../../actions/index.js';
import createDebug from 'debug';
import { removeObject, addObjectActive2D, setActive2D } from '../../../reducers/objectReducers.js';
import { SNAPPING_DISTANCE, LINE_WIDTH } from '../../../constants/d2Constants.js';
import { SHAPE_TYPE_PROPERTIES } from '../../../constants/shapeTypeProperties.js';
import { getSnappingPoints } from '../../../utils/objectSelectors.js';
import { fitPath, segmentBezierPath } from '../../../utils/curveUtils.js';
import * as tools from '../../../constants/d2Tools.js';
const debug = createDebug('d3d:reducer:pen');
const LINE_DIAMETER = LINE_WIDTH / 2.0;
const FREE_HAND = 'FREE_HAND';
const POLYGON = 'POLYGON';
const BRUSH = 'BRUSH';
export default function penReducer(state, action) {
if (action.log !== false && action.type) debug(action.type);
const screenPosition = action.position;
const activeShape = state.d2.activeShape;
const activeTool = state.d2.tool;
let screenMatrixZoom;
let screenMatrixZoomInverse;
if (action.screenMatrixZoom) {
screenMatrixZoom = action.screenMatrixZoom;
screenMatrixZoomInverse = action.screenMatrixZoom.inverseMatrix();
}
switch (action.type) {
case actions.D2_DRAG_START: {
const preDrags = action.preDrags.map((preDrag) => preDrag.applyMatrix(screenMatrixZoomInverse));
switch (activeTool) {
case tools.POLYGON: {
return addObjectActive2D(state, {
type: POLYGON,
// draw straight line between drag start and current position
points: [preDrags[0], preDrags[preDrags.length - 1]]
});
}
case tools.BRUSH: {
return addObjectActive2D(state, {
type: BRUSH,
strokeWidth: state.d2.brush.size,
points: preDrags
});
}
case tools.FREE_HAND: {
return addObjectActive2D(state, {
type: FREE_HAND,
points: preDrags
});
}
default:
return state;
}
}
case actions.D2_DRAG: {
if (activeShape) {
const shapeData = state.objectsById[activeShape];
const points = shapeData.points;
const matrix = shapeData.transform.multiplyMatrix(screenMatrixZoom).inverseMatrix();
const position = screenPosition.applyMatrix(matrix);
switch (activeTool) {
case tools.POLYGON: {
return update(state, {
objectsById: {
[activeShape]: {
points: { [points.length - 1]: { $set: position } }
}
}
}); // update last point to curren mouse position
}
case tools.BRUSH:
case tools.FREE_HAND: {
state = update(state, {
objectsById: {
[activeShape]: {
points: { $push: [position] }
}
}
}); // add current mouse position to points array
}
default:
return state;
}
}
return state;
}
case actions.D2_DRAG_END: {
if (activeShape) {
state = setActive2D(state, null);
const currentShapeData = state.objectsById[activeShape];
const shapeProps = SHAPE_TYPE_PROPERTIES[currentShapeData.type];
if (!shapeProps.snapping) return state;
let currentPoints = currentShapeData.points;
// smooth
if (activeTool === tools.FREE_HAND) {
// convert to screen space
currentPoints = currentPoints.map(point => point.applyMatrix(screenMatrixZoom));
// smooth path
currentPoints = segmentBezierPath(fitPath(currentPoints));
// convert back to object space
currentPoints = currentPoints.map(point => point.applyMatrix(screenMatrixZoomInverse));
}
const currentStartPoint = currentPoints[0].applyMatrix(screenMatrixZoom);
const currentEndPoint = currentPoints[currentPoints.length - 1].applyMatrix(screenMatrixZoom);
// create list of possible snapping connections
// sorts on distance to connection and filters out connections that exceed the snapping threshold
const snappingDistance = SNAPPING_DISTANCE + LINE_DIAMETER * screenMatrixZoom.sx;
const snappingPoints = getSnappingPoints(state, screenMatrixZoom)
.reduce((points, { shapeData, endPoint, startPoint }) => {
if (shapeData.UID === activeShape) {
if (currentPoints.length >= 3) {
points.push({ shapeData, hit: 'self', distance: endPoint.distanceTo(startPoint) });
}
} else {
points.push(
{ shapeData, hit: 'start-start', distance: currentStartPoint.distanceTo(startPoint) },
{ shapeData, hit: 'start-end', distance: currentStartPoint.distanceTo(endPoint) },
{ shapeData, hit: 'end-start', distance: currentEndPoint.distanceTo(startPoint) },
{ shapeData, hit: 'end-end', distance: currentEndPoint.distanceTo(endPoint) }
);
}
return points;
}, [])
.filter(({ distance }) => distance < snappingDistance)
.sort((a, b) => a.distance - b.distance);
// the active shape's start and end points can only be connected to one other shape,
// this variable can be used to check if the start or end point is already been snapped to
// when the point has a connection it stores the shape UID of the connected shape
let currentEndPointHit = false;
let currentStartPointHit = false;
for (const { hit, shapeData } of snappingPoints) {
let newPoints;
if (hit !== 'self') {
const newMatrix = shapeData.transform.multiplyMatrix(currentShapeData.transform.inverseMatrix());
newPoints = shapeData.points.map(point => point.applyMatrix(newMatrix));
}
if (hit === 'start-start' || hit === 'end-end') newPoints.reverse();
switch (hit) {
case 'self':
// can only connect to self if end point AND start point aren't connected yet
if (!currentEndPointHit && !currentStartPointHit) {
currentEndPointHit = shapeData.UID;
currentStartPointHit = shapeData.UID;
currentPoints = update(currentPoints, {
[currentPoints.length - 1]: { $set: currentPoints[0].clone() }
});
}
break;
case 'start-start':
case 'start-end':
// can only connect start point if start point isn't connected yet
if (!currentStartPointHit) {
currentStartPointHit = shapeData.UID;
if (currentEndPointHit === shapeData.UID) {
// if the end point is connected to the same shape it has already been added to
// the end of the shape and does not have to be added to the start of the shape
if (activeTool === tools.FREE_HAND) {
currentPoints = update(currentPoints, {
$push: [currentPoints[0].clone()]
});
} else if (activeTool === tools.POLYGON) {
currentPoints = update(currentPoints, {
[currentPoints.length - 1]: { $set: currentPoints[0].clone() }
});
}
} else {
// add the shape to the start of the active shape
if (activeTool === tools.FREE_HAND) {
currentPoints = update(currentPoints, {
$splice: [[0, 0, ...newPoints]]
});
} else if (activeTool === tools.POLYGON) {
currentPoints = update(currentPoints, {
$splice: [[0, 1, ...newPoints]]
});
}
}
if (state.objectsById[shapeData.UID]) state = removeObject(state, shapeData.UID);
}
break;
case 'end-start':
case 'end-end':
if (!currentEndPointHit) {
currentEndPointHit = shapeData.UID;
if (currentStartPointHit === shapeData.UID) {
// if the start point is connected to the same shape it has already been added to
// the start of the shape and does not have to be added to the end of the shape
if (activeTool === tools.FREE_HAND) {
currentPoints = update(currentPoints, {
$push: [currentPoints[0].clone()]
});
} else if (activeTool === tools.POLYGON) {
currentPoints = update(currentPoints, {
[currentPoints.length - 1]: { $set: currentPoints[0].clone() }
});
}
} else {
// add the shape to the end of the active shape
if (activeTool === tools.FREE_HAND) {
currentPoints = update(currentPoints, {
$push: newPoints
});
} else if (activeTool === tools.POLYGON) {
currentPoints = update(currentPoints, {
$splice: [[currentPoints.length - 1, 1, ...newPoints]]
});
}
}
if (state.objectsById[shapeData.UID]) state = removeObject(state, shapeData.UID);
}
break;
default:
break;
}
}
state = update(state, {
objectsById: {
[activeShape]: {
points: { $set: currentPoints }
}
}
});
}
return state;
}
default:
return state;
}
}

View File

@ -0,0 +1,62 @@
import update from 'react-addons-update';
import * as actions from '../../../actions/index.js';
import { addObject } from '../../../reducers/objectReducers.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:photoGuide');
export default function photoGuideReducer(state, action) {
if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.TRACE_DRAG: {
return update(state, {
d2: {
activeShape: { $set: action.id },
trace: {
active: { $set: true },
start: { $set: action.start.clone() },
position: { $set: action.position.clone() }
}
}
});
}
case `${actions.FLOOD_FILL}_FULFILLED`: {
return update(state, {
d2: {
trace: {
floodFillData: { $set: action.payload }
}
}
});
}
case `${actions.TRACE_FLOOD_FILL}_FULFILLED`: {
const paths = action.payload;
for (const points of paths) {
state = addObject(state, {
type: 'FREE_HAND',
points
});
}
return state;
}
case actions.TRACE_DRAG_END: {
state = update(state, {
d2: {
activeShape: { $set: null },
trace: {
active: { $set: false },
floodFillData: { $set: { edge: [], fill: [] } }
}
}
});
}
default:
return state;
}
}

View File

@ -0,0 +1,75 @@
import update from 'react-addons-update';
import { Matrix, Vector } from 'cal';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../objectReducers.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:circle');
const DEFAULT_RADIUS = 25;
const DEFAULT_SEGMENT = Math.PI * 0.5; // for segmented circle
const MAX_SEGMENT = Math.PI * 2;
export default function circleReducer(state, action, segmented = false) {
if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START: {
return addObjectActive2D(state, {
type: (segmented ? 'CIRCLE_SEGMENT' : 'CIRCLE'),
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y })
});
}
case actions.D2_DRAG: {
if (activeShape) {
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
let segment;
if (segmented) {
segment = (delta.angle() + Math.PI * 2.5) % MAX_SEGMENT;
} else {
segment = MAX_SEGMENT;
}
const radius = delta.length();
state = updateCircle(state, activeShape, radius, segment);
}
return state;
}
case actions.D2_DRAG_END: {
if (activeShape) {
state = setActive2D(state, null);
}
return state;
}
case actions.D2_TAP: {
const segment = segmented ? DEFAULT_SEGMENT : MAX_SEGMENT;
return addObject(state, {
type: (segmented ? 'CIRCLE_SEGMENT' : 'CIRCLE'),
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
circle: {
radius: DEFAULT_RADIUS,
segment
}
});
}
default:
return state;
}
}
function updateCircle(state, id, radius, segment) {
return update(state, {
objectsById: {
[id]: {
circle: {
radius: { $set: radius },
segment: { $set: segment }
}
}
}
});
}

View File

@ -0,0 +1,8 @@
import circleReducer from './circleReducer.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:circleSegment');
export default function circleSegmentReducer(state, action) {
if (action.log !== false) debug(action.type);
return circleReducer(state, action, true);
}

View File

@ -0,0 +1,71 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../objectReducers.js';
import { Matrix, Vector } from 'cal';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:star');
const DEFAULT_WIDTH = 30.0;
const DEFAULT_HEIGHT = 30.0;
export default function starReducer(state, action) {
// if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START:
return addObjectActive2D(state, {
type: 'HEART',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
heart: {
width: 1.0,
height: 1.0
}
});
case actions.D2_DRAG:
if (activeShape) {
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
const width = Math.abs(delta.x);
const height = Math.abs(delta.y);
return update(state, {
objectsById: {
[activeShape]: {
heart: {
width: { $set: width },
height: { $set: height }
}
}
}
});
}
return state;
case actions.D2_DRAG_END:
if (activeShape) {
state = setActive2D(state, null);
}
return state;
case actions.D2_TAP:
return addObject(state, {
type: 'HEART',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
heart: {
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT
}
});
default:
return state;
}
}

View File

@ -0,0 +1,69 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../objectReducers.js';
import { Matrix, Vector } from 'cal';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:star');
const DEFAULT_RADIUS = 25;
export default function starReducer(state, action) {
// if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START:
return addObjectActive2D(state, {
type: 'POLY_POINTS',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
polyPoints: {
numPoints: 6,
radius: 0
}
});
case actions.D2_DRAG:
if (activeShape) {
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
const numPoints = Math.min(15, Math.max(3, Math.round(delta.y / 10) + 6));
const radius = Math.abs(delta.x);
return update(state, {
objectsById: {
[activeShape]: {
polyPoints: {
numPoints: { $set: numPoints },
radius: { $set: radius }
}
}
}
});
}
return state;
case actions.D2_DRAG_END:
if (activeShape) {
state = setActive2D(state, null);
}
return state;
case actions.D2_TAP:
return addObject(state, {
type: 'POLY_POINTS',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
polyPoints: {
numPoints: 6,
radius: DEFAULT_RADIUS
}
});
default:
return state;
}
}

View File

@ -0,0 +1,64 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../objectReducers.js';
import { Matrix, Vector } from 'cal';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:rect');
const DEFAULT_WIDTH = 25;
const DEFAULT_HEIGHT = 25;
export default function rectReducer(state, action) {
if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START:
return addObjectActive2D(state, {
type: 'RECT',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y })
});
case actions.D2_DRAG:
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
const width = delta.x;
const height = delta.y;
return updateRect(state, activeShape, width, height);
case actions.D2_DRAG_END:
return setActive2D(state, null);
case actions.D2_TAP:
return addObject(state, {
type: 'RECT',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
rectSize: {
x: DEFAULT_WIDTH,
y: DEFAULT_HEIGHT
}
});
default:
return state;
}
}
function updateRect(state, id, width, height) {
return update(state, {
objectsById: {
[id]: {
rectSize: {
x: { $set: width },
y: { $set: height }
}
}
}
});
}

View File

@ -0,0 +1,60 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { removeObject, addObjectActive2D, setActive2D } from '../../../objectReducers.js';
import { Vector, Matrix } from 'cal';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:skewRect');
export default function skewRectReducer(state, action) {
if (action.log !== false) debug(action.type);
const mouse = action.mouse;
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_MOUSE_DOWN:
return addObjectActive2D(state, {
type: 'SKEW_RECT',
transform: new Matrix({ x: mouse.position.x, y: mouse.position.y }),
points: [
new Vector(100, 0),
new Vector(-100, 0),
new Vector(-100, 0),
new Vector(100, 0)
]
});
case actions.D2_MOUSE_MOVE:
if (action.mouse.down && activeShape) {
const delta = mouse.position.subtract(mouse.start);
state = update(state, {
objectsById: {
[activeShape]: {
points: {
[2]: { $set: new Vector(delta.x - 100, delta.y) },
[3]: { $set: new Vector(delta.x + 100, delta.y) }
}
}
}
});
}
return state;
case actions.D2_MOUSE_UP:
if (activeShape) {
state = setActive2D(state, null);
}
return state;
case actions.D2_MOUSE_CLICK:
if (activeShape) {
state = removeObject(state, activeShape);
state = setActive2D(state, null);
}
return state;
default:
return state;
}
}

View File

@ -0,0 +1,75 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../../objectReducers.js';
import { Matrix, Vector } from 'cal';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:star');
const DEFAULT_INNER_RADIUS = 10;
const DEFAULT_OUTER_RADIUS = 25;
export default function starReducer(state, action) {
// if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START:
return addObjectActive2D(state, {
type: 'STAR',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
star: {
rays: 5,
innerRadius: 0,
outerRadius: 0
}
});
case actions.D2_DRAG:
if (activeShape) {
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
const innerRadius = Math.abs(delta.y);
const outerRadius = Math.abs(delta.x);
state = updateStar(state, activeShape, innerRadius, outerRadius);
}
return state;
case actions.D2_DRAG_END:
if (activeShape) {
state = setActive2D(state, null);
}
return state;
case actions.D2_TAP:
return addObject(state, {
type: 'STAR',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
star: {
rays: 5,
innerRadius: DEFAULT_INNER_RADIUS,
outerRadius: DEFAULT_OUTER_RADIUS
}
});
default:
return state;
}
}
function updateStar(state, id, innerRadius, outerRadius) {
return update(state, {
objectsById: {
[id]: {
star: {
innerRadius: { $set: innerRadius },
outerRadius: { $set: outerRadius }
}
}
}
});
}

View File

@ -0,0 +1,70 @@
import update from 'react-addons-update';
import * as actions from '../../../../actions/index.js';
import { addObjectActive2D, addObject, setActive2D } from '../../../objectReducers.js';
import { Matrix, Vector } from 'cal';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:triangle');
const DEFAULT_WIDTH = 25;
const DEFAULT_HEIGHT = 25;
export default function triangleReducer(state, action) {
if (action.log !== false) debug(action.type);
const { position, screenMatrixZoom } = action;
let scenePosition;
if (position !== undefined && screenMatrixZoom !== undefined) {
scenePosition = position.applyMatrix(screenMatrixZoom.inverseMatrix());
}
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_DRAG_START:
return addObjectActive2D(state, {
type: 'TRIANGLE',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y })
});
case actions.D2_DRAG:
if (activeShape) {
const beginPos = new Vector().copy(state.objectsById[activeShape].transform);
const delta = scenePosition.subtract(beginPos);
const width = delta.x * 2;
const height = delta.y;
state = updateTriangle(state, activeShape, width, height);
}
return state;
case actions.D2_DRAG_END:
if (activeShape) {
state = setActive2D(state, null);
}
return state;
case actions.D2_TAP:
return addObject(state, {
type: 'TRIANGLE',
transform: new Matrix({ x: scenePosition.x, y: scenePosition.y }),
triangleSize: {
x: DEFAULT_WIDTH,
y: DEFAULT_HEIGHT
}
});
default:
return state;
}
}
function updateTriangle(state, id, width, height) {
return update(state, {
objectsById: {
[id]: {
triangleSize: {
x: { $set: width },
y: { $set: height }
}
}
}
});
}

View File

@ -0,0 +1,58 @@
import { Matrix, Vector } from 'cal';
import update from 'react-addons-update';
import * as actions from '../../../actions/index.js';
import createDebug from 'debug';
import { addObjectActive2D, setActive2D, removeObject } from '../../../reducers/objectReducers.js';
const debug = createDebug('d3d:reducer:text');
export default function textReducer(state, action) {
if (action.log !== false) debug(action.type);
const activeShape = state.d2.activeShape;
switch (action.type) {
case actions.D2_TEXT_INIT: {
const { position, textId, screenMatrixZoom } = action;
const screenPosition = (position && screenMatrixZoom) ?
position.applyMatrix(screenMatrixZoom.inverseMatrix()) :
new Vector();
if (textId) {
return setActive2D(state, textId);
} else {
return addObjectActive2D(state, {
transform: new Matrix({ x: screenPosition.x, y: screenPosition.y }),
type: 'TEXT'
});
}
return state;
}
case actions.D2_TEXT_INPUT_CHANGE: {
const { text, family, style, weight, fill } = action;
return update(state, {
objectsById: {
[activeShape]: {
text: {
text: { $set: text },
family: { $set: family },
style: { $set: style },
weight: { $set: weight }
},
fill: { $set: fill }
}
}
});
}
case actions.D2_TEXT_ADD: {
if (activeShape && state.objectsById[activeShape].text.text.length === 0) {
return setActive2D(removeObject(state, activeShape), null);
} else {
return setActive2D(state, null);
}
break;
}
default:
return state;
}
return state;
}

View File

@ -0,0 +1,383 @@
import update from 'react-addons-update';
import { Matrix, Vector } from 'cal';
import { CANVAS_SIZE } from '../../../constants/d2Constants.js';
import * as actions from '../../../actions/index.js';
import { calculateGestureMatrix } from '../../../utils/matrixUtils.js';
import * as selectionUtils from '../../../utils/selectionUtils.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:transform');
const CANVAS_WIDTH = CANVAS_SIZE * 2;
const CANVAS_HEIGHT = CANVAS_SIZE * 2;
export function transformReducer(state, action) {
// if (action.log !== false) {
// debug(action.type);
// }
switch (action.category) {
case actions.CAT_SELECTION:
state = updateInitTransform(state);
state = updateTransforms(state);
break;
default:
break;
}
switch (action.type) {
case actions.TRANSFORM_START:
case actions.TRANSFORM_END: {
const start = action.type === actions.TRANSFORM_START;
state = update(state, {
d2: {
transform: {
active: { $set: start },
handle: { $set: start ? action.handle : '' }
}
}
});
if (action.handle === 'dragselect' && start) {
state = update(state, {
d2: {
transform: {
dragSelect: {
end: { $set: action.position.clone() },
start: { $set: action.position.clone() }
}
}
}
});
}
return state;
}
case actions.TRANSFORM: {
switch (state.d2.transform.handle) {
case 'translate':
return translate(state, action);
case 'rotate':
return rotate(state, action);
case 'scale-lefttop':
case 'scale-leftbottom':
case 'scale-rightbottom':
case 'scale-righttop':
case 'scale-left':
case 'scale-bottom':
case 'scale-right':
case 'scale-top':
return scale(state, action);
case 'dragselect':
return dragSelect(state, action);
default:
return state;
}
}
case actions.MULTITOUCH_TRANSFORM_START:
case actions.MULTITOUCH_TRANSFORM_END: {
const start = action.type === actions.MULTITOUCH_TRANSFORM_START;
state = update(state, {
d2: {
transform: {
active: { $set: start }
}
}
});
return state;
}
case actions.MULTITOUCH_TRANSFORM:
return multitouch(state, action);
case actions.MOVE_SELECTION:
return moveSelection(state, action.deltaX, action.deltaY);
default:
return state;
}
}
function translate(state, { delta, screenMatrixZoom }) {
delta = delta.applyMatrix(screenMatrixZoom.normalize().inverseMatrix());
return moveSelection(state, delta.x, delta.y);
}
function rotate(state, { delta, position, screenMatrixZoom }) {
const transform = state.selection.transform;
let { center } = getBoundingBox(state, false);
center = center.applyMatrix(transform);
const centerScreen = center.applyMatrix(screenMatrixZoom);
const current = position;
const start = position.subtract(delta);
const oldAngle = start.subtract(centerScreen).angle();
const newAngle = current.subtract(centerScreen).angle();
const angle = newAngle - oldAngle;
const rotation = new Matrix().rotateAroundAbsolute(angle, center);
return containSelectedObjects(updateTransforms(update(state, {
selection: {
transform: { $set: transform.multiplyMatrix(rotation) }
}
})));
}
function scale(state, { position, screenMatrixZoom }) {
// let {min, max} = state.selection.boundingBox;
let { min, max } = getBoundingBox(state, false);
min = new Vector(min.x, min.z);
max = new Vector(max.x, max.z);
let move; // position of corner we are transforming
let scaleAround; // position of corner to use as reference point
switch (state.d2.transform.handle) {
case 'scale-lefttop':
scaleAround = new Vector(max.x, max.y);
move = new Vector(min.x, min.y);
break;
case 'scale-leftbottom':
scaleAround = new Vector(max.x, min.y);
move = new Vector(min.x, max.y);
break;
case 'scale-rightbottom':
scaleAround = new Vector(min.x, min.y);
move = new Vector(max.x, max.y);
break;
case 'scale-righttop':
scaleAround = new Vector(min.x, max.y);
move = new Vector(max.x, min.y);
break;
case 'scale-top':
scaleAround = new Vector((min.x + max.x) / 2, max.y);
move = new Vector((min.x + max.x) / 2, min.y);
break;
case 'scale-bottom':
scaleAround = new Vector((min.x + max.x) / 2, min.y);
move = new Vector((min.x + max.x) / 2, max.y);
break;
case 'scale-left':
scaleAround = new Vector(max.x, (min.y + max.y) / 2);
move = new Vector(min.x, (min.y + max.y) / 2);
break;
case 'scale-right':
scaleAround = new Vector(min.x, (min.y + max.y) / 2);
move = new Vector(max.x, (min.y + max.y) / 2);
break;
default:
throw Error('Unknown corner');
}
position = new Vector(position.x, position.y);
// transform from container space to object space
const matrix = state.selection.transform.multiplyMatrix(screenMatrixZoom).inverseMatrix();
const mousePos = position.applyMatrix(matrix);
const currentSize = move.subtract(scaleAround);
const newSize = mousePos.subtract(scaleAround);
let sx;
let sy;
switch (state.d2.transform.handle) {
case 'scale-lefttop':
case 'scale-leftbottom':
case 'scale-rightbottom':
case 'scale-righttop':
const ratioX = currentSize.x === 0 ? 1 : (newSize.x / currentSize.x);
const ratioY = currentSize.y === 0 ? 1 : (newSize.y / currentSize.y);
sx = sy = (Math.abs(ratioX) > Math.abs(ratioY)) ? ratioX : ratioY;
break;
case 'scale-top':
case 'scale-bottom':
sx = 1;
sy = currentSize.y === 0 ? 1 : (newSize.y / currentSize.y);
break;
case 'scale-left':
case 'scale-right':
sx = currentSize.x === 0 ? 1 : (newSize.x / currentSize.x);
sy = 1;
break;
default:
throw Error('Unknown corner');
}
const transform = state.selection.transform;
const newScale = new Matrix().scaleAroundAbsolute(sx, sy, scaleAround);
const newState = updateTransforms(update(state, {
selection: {
transform: { $set: newScale.multiplyMatrix(transform) }
}
}));
const boundingBox = getBoundingBox(newState, true);
// TODO: move to constants
const minX = boundingBox.min.x < -CANVAS_SIZE;
const minY = boundingBox.min.z < -CANVAS_SIZE;
const maxX = boundingBox.max.x > CANVAS_SIZE;
const maxY = boundingBox.max.z > CANVAS_SIZE;
return (minX || minY || maxX || maxY) ? state : newState;
}
function dragSelect(state, { position }) {
return update(state, {
d2: {
transform: {
dragSelect: {
end: { $set: position.clone() }
}
}
}
});
}
function multitouch(state, { positions, previousPositions, screenMatrixZoom }) {
const gestureMatrix = calculateGestureMatrix(positions, previousPositions, screenMatrixZoom, {
rotate: true, scale: true, pan: true
});
const transform = state.selection.transform.multiplyMatrix(gestureMatrix);
return containSelectedObjects(updateTransforms(update(state, {
selection: {
transform: { $set: transform }
}
})));
}
function moveSelection(state, deltaX, deltaY) {
const transform = state.selection.transform.translate(deltaX, deltaY);
return containSelectedObjects(updateTransforms(update(state, {
selection: {
transform: { $set: transform }
}
})));
}
function containSelectedObjects(state) {
const boundingBox = getBoundingBox(state, true);
let transform = new Matrix(state.selection.transform);
let minX = boundingBox.min.x;
let minY = boundingBox.min.z;
let maxX = boundingBox.max.x;
let maxY = boundingBox.max.z;
const width = maxX - minX;
const height = maxY - minY;
let change = false;
if (width > CANVAS_WIDTH || height > CANVAS_HEIGHT) {
change = true;
const constainFactor = Math.min(CANVAS_WIDTH / width, CANVAS_HEIGHT / height);
const center = new Vector((maxX + minX) / 2, (maxY + minY) / 2);
const newScale = new Matrix().scaleAroundAbsolute(constainFactor, constainFactor, center);
transform = transform.multiplyMatrix(newScale);
minX = center.x + (minX - center.x) * constainFactor;
maxX = center.x + (maxX - center.x) * constainFactor;
maxY = center.y + (maxY - center.y) * constainFactor;
minY = center.y + (minY - center.y) * constainFactor;
}
if (minX < -CANVAS_SIZE) {
change = true;
transform.x -= minX + CANVAS_SIZE;
} else if (maxX > CANVAS_SIZE) {
change = true;
transform.x -= maxX - CANVAS_SIZE;
}
if (minY < -CANVAS_SIZE) {
change = true;
transform.y -= minY + CANVAS_SIZE;
} else if (maxY > CANVAS_SIZE) {
change = true;
transform.y -= maxY - CANVAS_SIZE;
}
if (change) {
return updateTransforms(update(state, {
selection: { transform: { $set: transform } }
}));
} else {
return state;
}
}
export function updateInitTransform(state) {
// Clone object's transform into object's initialTransform
// Store new Matrix as selection transform
state = update(state, {
selection: {
objects: {
$set: state.selection.objects.map(({ id }) => {
const shapeData = state.objectsById[id];
const initialTransform = new Matrix(shapeData.transform);
return { id, initialTransform };
})
},
transform: { $set: new Matrix() }
}
});
return state;
}
// update each object's transform with multiplication of object's initial transform with selection transform
function updateTransforms(state) {
const matrix = state.selection.transform;
for (const { initialTransform, id } of state.selection.objects) {
const transform = initialTransform.multiplyMatrix(matrix);
state = update(state, {
objectsById: {
[id]: { transform: { $set: transform } }
}
});
}
return state;
}
function getBoundingBox(state, axisAligned) {
const selection = state.selection;
const selectedShapeDatas = selectionUtils.getSelectedObjectsSelector(selection.objects, state.objectsById);
if (axisAligned) {
return selectionUtils.getBoundingBox(selectedShapeDatas);
} else {
return selectionUtils.getBoundingBox(selectedShapeDatas, selection.transform);
}
}

View File

@ -0,0 +1,25 @@
import update from 'react-addons-update';
import { Matrix } from 'cal';
import constrainMatrix from './constrainMatrix.js';
import { MAX_ZOOM } from '../../constants/d2Constants.js';
export default function d2WheelZoomReducer(state, action) {
const { position, wheelDelta, screenMatrixContainer } = action;
const { canvasMatrix } = state.d2;
const targetScale = 1 + wheelDelta / -500;
if (canvasMatrix.sx === MAX_ZOOM && targetScale > 1) return state;
const rotateAround = position.applyMatrix(screenMatrixContainer.inverseMatrix());
const scaleMatrix = new Matrix().scaleAroundAbsolute(targetScale, targetScale, rotateAround);
const matrix = canvasMatrix.multiplyMatrix(scaleMatrix);
constrainMatrix(matrix);
return update(state, {
d2: {
canvasMatrix: { $set: matrix }
}
});
}

58
src/reducer/d3/toolReducer.js vendored Normal file
View File

@ -0,0 +1,58 @@
import * as actions from '../../actions/index.js';
import * as tools from '../../constants/d3Tools.js';
import heightReducer from './tools/heightReducer.js';
import twistReducer from './tools/twistReducer.js';
import sculptReducer from './tools/sculptReducer.js';
import stampReducer from './tools/stampReducer.js';
import { cameraReducer } from './tools/cameraReducer.js';
import update from 'react-addons-update';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:d3:tool');
const reducers = {
[tools.HEIGHT]: heightReducer,
[tools.TWIST]: twistReducer,
[tools.SCULPT]: sculptReducer,
[tools.STAMP]: stampReducer
};
export default function toolReducer(state, action) {
// if (action.log !== false) debug(action.type);
state = update(state, {
d3: { camera: { $set: cameraReducer(state.d3.camera, action) } }
});
switch (action.type) {
case actions.sketcher.D3_DRAG_START:
state = update(state, {
d3: { dragging: { $set: true } }
});
break;
case actions.sketcher.D3_DRAG_END:
state = update(state, {
d3: { dragging: { $set: false } }
});
break;
default:
break;
}
if (action.type === actions.sketcher.D3_CHANGE_TOOL && action.tool !== state.d3.tool) {
state = update(state, {
d3: { tool: { $set: action.tool } }
});
debug('d3 tool: ', action.tool);
}
const tool = state.d3.tool;
const reducer = reducers[tool];
if (reducer) {
return reducer(state, action);
} else {
debug('Unkown 3D tool: ', tool);
return state;
}
}

165
src/reducer/d3/tools/cameraReducer.js vendored Normal file
View File

@ -0,0 +1,165 @@
import update from 'react-addons-update';
import * as actions from '../../../actions/index.js';
import { MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM, MAX_CAMERA_PAN } from '../../../constants/d3Constants.js';
import * as THREE from 'three';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:camera');
const CAMERA_STATES = { NONE: -1, ROTATE: 0, ZOOM: 1, PAN: 2 };
const PAN_SPEED = 0.001;
const ZOOM_SPEED = 0.001;
const ROTATION_SPEED = 0.005;
export const defaultCamera = {
object: new THREE.PerspectiveCamera(),
center: new THREE.Vector3(0, 0, 0),
state: CAMERA_STATES.NONE
};
defaultCamera.object.position.set(0, 125, 250);
defaultCamera.object.lookAt(defaultCamera.center);
defaultCamera.object.updateMatrix();
export function cameraReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.D3_MOUSE_WHEEL: {
const delta = new THREE.Vector3(0, 0, action.wheelDelta);
return zoom(state, delta);
}
case actions.D3_DRAG_START: {
return update(state, {
state: { $set: CAMERA_STATES.ROTATE }
});
}
case actions.D3_SECOND_DRAG_START: {
return update(state, {
state: { $set: CAMERA_STATES.PAN }
});
}
case actions.D3_DRAG:
case actions.D3_SECOND_DRAG: {
const movement = action.position.subtract(action.previousPosition);
switch (state.state) {
case CAMERA_STATES.ROTATE: {
const delta = new THREE.Vector3(-movement.x * ROTATION_SPEED, -movement.y * ROTATION_SPEED, 0);
return rotate(state, delta);
}
case CAMERA_STATES.PAN: {
const delta = new THREE.Vector3(-movement.x, movement.y, 0);
return pan(state, delta);
}
case CAMERA_STATES.ZOOM: {
const delta = new THREE.Vector3(0, 0, movement.y);
return zoom(state, delta);
}
default:
return state;
}
break;
}
case actions.D3_DRAG_END:
case actions.D3_SECOND_DRAG_END:
return update(state, {
state: { $set: CAMERA_STATES.NONE }
});
case actions.D3_MULTITOUCH: {
const distance = action.positions[0].distanceTo(action.positions[1]);
const prevDistance = action.previousPositions[0].distanceTo(action.previousPositions[1]);
const zoomDelta = new THREE.Vector3(0, 0, prevDistance - distance);
state = zoom(state, zoomDelta);
const offset0 = new THREE.Vector3(
-(action.positions[0].x - action.previousPositions[0].x),
action.positions[0].y - action.previousPositions[0].y,
0
);
const offset1 = new THREE.Vector3(
-(action.positions[1].x - action.previousPositions[1].x),
action.positions[1].y - action.previousPositions[1].y,
0
);
const panDelta = offset0.add(offset1).multiplyScalar(0.5);
return pan(state, panDelta);
}
case actions.CLEAR:
return defaultCamera;
default:
return state;
}
}
function rotate(state, delta) {
let { center, object } = state;
object = new THREE.PerspectiveCamera().copy(state.object);
center = new THREE.Vector3().copy(center);
const vector = new THREE.Vector3().copy(object.position).sub(center);
const spherical = new THREE.Spherical().setFromVector3(vector);
spherical.theta += delta.x;
spherical.phi += delta.y;
spherical.makeSafe();
vector.setFromSpherical(spherical);
object.position.copy(center).add(vector);
object.lookAt(center);
object.updateMatrix();
return update(state, {
object: { $set: object }
});
}
function pan(state, delta) {
let { center, object } = state;
object = new THREE.PerspectiveCamera().copy(state.object);
center = new THREE.Vector3().copy(center);
const distance = object.position.distanceTo(center);
delta.multiplyScalar(distance * PAN_SPEED);
delta.applyMatrix3(new THREE.Matrix3().getNormalMatrix(object.matrix));
delta.add(center).clampLength(0.0, MAX_CAMERA_PAN).sub(center);
object.position.add(delta);
center.add(delta);
object.updateMatrix();
return update(state, {
object: { $set: object },
center: { $set: center }
});
}
function zoom(state, delta) {
let { center, object } = state;
object = new THREE.PerspectiveCamera().copy(state.object);
center = new THREE.Vector3().copy(center);
const distance = object.position.distanceTo(center);
delta.multiplyScalar(distance * ZOOM_SPEED);
if (delta.length() > distance) return state;
delta.applyMatrix3(new THREE.Matrix3().getNormalMatrix(object.matrix));
object.position.add(delta);
object.position.sub(center).clampLength(MIN_CAMERA_ZOOM, MAX_CAMERA_ZOOM).add(center);
object.updateMatrix();
return update(state, {
object: { $set: object }
});
}

100
src/reducer/d3/tools/heightReducer.js vendored Normal file
View File

@ -0,0 +1,100 @@
import update from 'react-addons-update';
import { Utils } from 'cal';
import * as THREE from 'three';
import { SHAPE_TYPE_PROPERTIES } from '../../../constatants/shapeTypeProperties.js';
import * as d3Tools from '../../../constatants/d3Tools.js';
import { getSelectedObjectsSelector, getBoundingBox } from '../../../utils/selectionUtils.js';
import * as actions from '../../../actions/index.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:height');
const { MathExtended } = Utils;
const MIN_HEIGHT = 1;
const MAX_HEIGHT = 600;
const CHANGE_FACTOR = 0.5;
export default function heightReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.HEIGHT_START:
case actions.HEIGHT_END: {
state = update(state, {
d3: {
height: {
active: { $set: action.type === actions.HEIGHT_START },
handle: { $set: action.type === actions.HEIGHT_START ? action.handle : '' }
}
}
});
return state;
}
case actions.HEIGHT: {
const cameraAngle = new THREE.Vector3()
.set(action.delta.x, -action.delta.y, 0)
.applyMatrix3(new THREE.Matrix3().getNormalMatrix(state.d3.camera.object.matrix));
const sceneUp = new THREE.Vector3(0, 1, 0)
.applyMatrix4(new THREE.Matrix4().extractRotation(state.spaces[state.activeSpace].matrix));
let delta = cameraAngle.dot(sceneUp) * CHANGE_FACTOR;
const selectedShapeDatas = getSelectedObjectsSelector(state.selection.objects, state.objectsById);
const { min, max } = getBoundingBox(selectedShapeDatas, state.selection.transform);
const totalHeight = max.y - min.y;
state = update(state, {
objectsById: state.selection.objects
.map(({ id }) => state.objectsById[id])
.filter(shapeData => SHAPE_TYPE_PROPERTIES[shapeData.type].tools[d3Tools.HEIGHT])
.reduce((updateObject, shapeData) => {
let height;
let z;
switch (state.d3.height.handle) {
case 'top': {
delta -= Math.max(max.y + delta - MAX_HEIGHT, 0);
const scale = (totalHeight + delta) / totalHeight;
height = shapeData.height * scale;
z = (shapeData.z - min.y) * scale + min.y;
break;
}
case 'bottom': {
delta -= Math.min(min.y + delta, 0);
const scale = (totalHeight - delta) / totalHeight;
height = shapeData.height * scale;
z = (shapeData.z - min.y) * scale + min.y + delta;
break;
}
case 'translate':
height = shapeData.height;
z = shapeData.z + delta;
break;
default:
return updateObject;
}
height = MathExtended.clamb(height, MIN_HEIGHT, MAX_HEIGHT - MIN_HEIGHT);
z = MathExtended.clamb(z, 0, MAX_HEIGHT - height);
updateObject[shapeData.UID] = {
height: { $set: height },
z: { $set: z }
};
return updateObject;
}, {})
});
return state;
}
default:
return state;
}
}

338
src/reducer/d3/tools/sculptReducer.js vendored Normal file
View File

@ -0,0 +1,338 @@
import update from 'react-addons-update';
import * as actions from '../../../actions/index.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:sculpt');
import { SHAPE_TYPE_PROPERTIES } from '../../../constants/shapeTypeProperties.js';
import * as d3Tools from '../../../constants/d3Tools';
// state.d3.sculpt.handles: [
// {
// pos: number // position of handle
// scale: number // scale of handle
// ids: [ // list of sculpt steps this handle influences
// {
// id: number // object id
// index: number // sculpt index
// }
// ]
// }
// ]
const CHANGE_FACTOR = 0.005; // 0.5;
const HANDLES_MERGE_DISTANCE = 2.0;
export function init(state) {
return state;
}
export default function sculptReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.category) {
case actions.CAT_SELECTION:
return initSculpt(state);
default:
break;
}
switch (action.type) {
case actions.D3_CHANGE_TOOL:
return initSculpt(state);
case actions.ADD_SCULPT_HANDLE: {
const pos = action.height;
const { handles } = state.d3.sculpt;
// find index of new handle based on position
const index = handles.findIndex(handle => action.height < handle.pos);
if (index === -1) return state;
// interpolate scale based on handles above and below new handle
const alpha = (action.height - handles[index - 1].pos) / (handles[index].pos - handles[index - 1].pos);
const scale = handles[index].scale * alpha + handles[index - 1].scale * (1 - alpha);
state = update(state, {
d3: {
sculpt: {
handles: {
$splice: [[index, 0, { pos, scale, ids: [] }]]
},
activeHandle: { $set: action.start ? index : null }
}
}
});
state = addHandles(state, index, pos);
return state;
}
case actions.REMOVE_SCULPT_HANDLE: {
const { handles } = state.d3.sculpt;
const removedHandleIndex = action.heightIndex;
// you can't remove top and bottom handles
if (removedHandleIndex === 0 || removedHandleIndex === handles.length - 1) return state;
const removedHandle = handles[removedHandleIndex];
// retrieve sculpt steps handled by this handle which can be removed
// removable sculpt steps are steps that aren't the top and bottom steps of objects
const removableSculptSteps = removedHandle.ids
.filter(sculptStep => isRemovableSculptStep(state, sculptStep));
// retrieve sculpt steps handled by this handle which can't be removed
const nonRemovableSculptSteps = removedHandle.ids
.filter(sculptStep => !isRemovableSculptStep(state, sculptStep));
// update handles
// if all sculpt handles controlled by this handle can be removed, remove handle
if (nonRemovableSculptSteps.length === 0) {
state = updateHandles(state, {
$splice: [[removedHandleIndex, 1]]
});
// else remove just the objects of which the sculpt steps can be removed
} else {
state = updateHandles(state, {
[removedHandleIndex]: {
ids: {
$set: nonRemovableSculptSteps
}
}
});
}
// convert sculpt steps of removed handle into
// objects collection with per object it's removed indexes
const objects = sculptStepsToObjects(removableSculptSteps);
// Remove sculpt steps from objects
state = update(state, {
objectsById: Object.entries(objects)
.reduce((updateObject, [id, indexes]) => {
updateObject[id] = {
sculpt: {
$splice: [[indexes[0], indexes.length]]
}
};
return updateObject;
}, {})
});
// Update sculpt step indexes in handles
state = updateHandles(state, state.d3.sculpt.handles.reduce((updateObject, handle, handleIndex) => {
// go through handle's sculpt steps
for (const key in handle.ids) {
const { id, index } = handle.ids[key];
const removedIndexes = objects[id];
// if a sculpt step index is higher than a removed step
if (removedIndexes && index > removedIndexes[0]) {
// add update object for handle if needed
if (!updateObject[handleIndex]) updateObject[handleIndex] = { ids: {} };
// update index of handle's sculpt step
updateObject[handleIndex].ids[key] = { index: { $set: index - removedIndexes.length } };
}
}
return updateObject;
}, {}));
return state;
}
case actions.SCULPT_START: {
const { pos } = state.d3.sculpt.handles[action.heightIndex];
const index = action.heightIndex;
state = update(state, {
d3: {
sculpt: {
activeHandle: { $set: index }
}
}
});
state = addHandles(state, index, pos);
return state;
}
case actions.SCULPT_END: {
return update(state, {
d3: {
sculpt: {
activeHandle: { $set: null }
}
}
});
}
case actions.SCULPT: {
const delta = action.delta * CHANGE_FACTOR;
const heightIndex = state.d3.sculpt.activeHandle;
const selectionScale = Math.max(0.1, state.d3.sculpt.handles[heightIndex].scale + delta);
state = update(state, {
d3: { sculpt: { handles: { [heightIndex]: { scale: { $set: selectionScale } } } } }
});
for (const { id, index } of state.d3.sculpt.handles[heightIndex].ids) {
const shapeData = state.objectsById[id];
if (!SHAPE_TYPE_PROPERTIES[shapeData.type].tools[d3Tools.SCULPT]) continue;
const shapeScale = Math.max(0.1, shapeData.sculpt[index].scale + delta);
state = update(state, {
objectsById: { [id]: { sculpt: { [index]: { scale: { $set: shapeScale } } } } }
});
}
return state;
}
default:
return state;
}
}
// go through sculpt steps and group / nest them per object
// sort the indexes per objects
function sculptStepsToObjects(sculptSteps) {
const objects = sculptSteps.reduce((result, { id, index }) => {
if (result[id] === undefined) result[id] = [];
result[id].push(index);
return result;
}, {});
for (const id in objects) {
objects[id].sort((a, b) => a - b);
}
return objects;
}
function isRemovableSculptStep(state, sculptStep) {
const { id, index } = sculptStep;
const { sculpt } = state.objectsById[id];
return index !== 0 && index !== sculpt.length - 1;
}
function updateHandles(state, handlesUpdates) {
return update(state, {
d3: {
sculpt: {
handles: handlesUpdates
}
}
});
}
function initSculpt(state) {
const sculpt = getInitialSculpt(state);
return update(state, {
d3: {
sculpt: {
handles: { $set: sculpt }
}
}
});
}
function getInitialSculpt(state) {
// determine initial sculpt
return state.selection.objects
.map(({ id }) => state.objectsById[id])
// get sculpt steps per object
.map(({ sculpt, UID, z, height }) => {
return sculpt.map(({ pos, scale }) => ({
pos: pos * height + z,
scale,
id: UID
}));
})
// convert to handles
.reduce((handles, sculpt) => {
for (let index = 0; index < sculpt.length; index ++) {
const { pos, scale, id } = sculpt[index];
// if there is already a handle nearby
const nearbyHandle = handles.find(handle => Math.abs(handle.pos - pos) < HANDLES_MERGE_DISTANCE);
if (nearbyHandle) {
// have the same handle also influence this object
nearbyHandle.ids.push({ id, index });
} else {
// otherwise create new handle
handles.push({
pos,
scale: state.selection.objects.length === 1 ? scale : 1.0,
ids: [{ id, index }]
});
}
}
return handles;
}, [])
// sort on y position
.sort((a, b) => a.pos - b.pos);
}
// add sculpt steps if needed and id added update existing sculpt steps
function addHandles(state, addedHandleIndex, pos) {
const { handles } = state.d3.sculpt;
// id's of objects that are already handled by handle
const objectIds = handles[addedHandleIndex].ids.map(({ id }) => id);
for (const { id } of state.selection.objects) {
// if selected object is already handled by handle skip
if (objectIds.includes(id)) continue;
// calculate relative y position (0 - 1) within object
const { z, height, sculpt } = state.objectsById[id];
const handlePos = (pos - z) / height;
// if handle position is above of below an object skip
if (handlePos <= 0.0 || handlePos >= 1.0) continue;
// find index of new sculpt step based on position
const index = sculpt.findIndex(handle => handlePos < handle.pos);
// interpolate scale based on sculpt steps above and below
const alpha = (handlePos - sculpt[index - 1].pos) / (sculpt[index].pos - sculpt[index - 1].pos);
const scale = sculpt[index].scale * alpha + sculpt[index - 1].scale * (1 - alpha);
// add handle
state = updateHandles(state, {
// add object to handle
[addedHandleIndex]: {
ids: { $push: [{ id, index }] }
}
});
// add sculpt step to object
state = update(state, {
objectsById: {
[id]: {
sculpt: {
$splice: [[index, 0, {
pos: handlePos,
scale
}]]
}
}
}
});
}
// per handle: update sculpt step indexes by incrementing all higher indexes
state = updateHandles(state, state.d3.sculpt.handles.reduce((updateObject, handle, handleIndex) => {
// skip current and lower handles
if (handleIndex <= addedHandleIndex) return updateObject;
for (const key in handle.ids) {
const { index, id } = handle.ids[key];
// skip sculpt steps for objects that are already handled by current handle
if (objectIds.includes(id)) continue;
// add update object for handle if needed
if (!updateObject[handleIndex]) updateObject[handleIndex] = { ids: {} };
// update index of handle's sculpt step
updateObject[handleIndex].ids[key] = { index: { $set: index + 1 } };
}
return updateObject;
}, {}));
return state;
}

54
src/reducer/d3/tools/stampReducer.js vendored Normal file
View File

@ -0,0 +1,54 @@
import * as actions from '../../actions/index.js';
import { addObject, addSpaceActive } from '../../objectReducers.js';
import { recursiveClone } from '../../utils/clone.js';
import { getBoundingBox, getSelectedObjectsSelector } from '../../utils/selectionUtils.js';
import { updateInitTransform } from '../../d2/tools/transformReducer.js';
import update from 'react-addons-update';
import * as THREE from 'three';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:height');
const ROTATION_MATRIX = new THREE.Matrix4().makeRotationX(Math.PI / 2);
export default function heightReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.STAMP: {
const { point: position, face: { normal }, object } = action.hit;
// apply world rotation on normal so 'space transformations' are applied on the normal
normal.applyEuler(object.getWorldRotation());
// flip normal when clicking on backside of a face
const incidence = new THREE.Vector3().subVectors(state.d3.camera.object.position, position).normalize();
if (normal.dot(incidence) > 0) normal.multiplyScalar(-1);
// calculate space transformation based on collision click and face normal
const matrix = new THREE.Matrix4();
matrix.lookAt(new THREE.Vector3(0, 0, 0), normal, new THREE.Vector3(0, 1, 0));
matrix.multiply(ROTATION_MATRIX);
matrix.setPosition(position);
state = addSpaceActive(state, matrix);
const selectedObjects = state.selection.objects;
const boundingbox = getBoundingBox(getSelectedObjectsSelector(selectedObjects, state.objectsById));
for (const { id } of selectedObjects) {
const shapeData = recursiveClone(state.objectsById[id]);
shapeData.transform = shapeData.transform.translate(-boundingbox.center.x, -boundingbox.center.y);
delete shapeData.space;
state = addObject(state, shapeData);
}
return updateInitTransform(update(state, {
selection: {
objects: { $set: state.spaces[state.activeSpace].objectIds.map(id => ({ id })) }
}
}));
}
default:
return state;
}
}

94
src/reducer/d3/tools/twistReducer.js vendored Normal file
View File

@ -0,0 +1,94 @@
import update from 'react-addons-update';
import { Utils } from 'cal';
import * as actions from '../../../actions/index.js';
import * as d3Tools from '../../../constants/d3Tools';
import { SHAPE_TYPE_PROPERTIES } from '../../../constants/shapeTypeProperties.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:twist');
const CHANGE_FACTOR = 0.002;
const maxTwist = 1;
export default function twistReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.category) {
case actions.CAT_SELECTION:
return initTwist(state);
default:
break;
}
switch (action.type) {
case actions.D3_CHANGE_TOOL:
return initTwist(state);
case actions.TWIST_START:
case actions.TWIST_END:
state = update(state, {
d3: {
twist: {
active: { $set: action.type === actions.TWIST_START }
}
}
});
return state;
case actions.TWIST:
const delta = action.delta.x * 1 * CHANGE_FACTOR;
state = update(state, {
d3: {
twist: {
rotation: { $set: state.d3.twist.rotation + delta }
}
}
});
for (const selectionData of state.selection.objects) {
const shapeData = state.objectsById[selectionData.id];
if (!SHAPE_TYPE_PROPERTIES[shapeData.type].tools[d3Tools.TWIST]) continue;
const twist = Utils.MathExtended.clamb(shapeData.twist + delta, -maxTwist, maxTwist);
// debug(UID, ': twist: ', shapeData.twist, '>', twist);
state = update(state, {
objectsById: {
[shapeData.UID]: { twist: { $set: twist } }
}
});
}
return state;
default:
return state;
}
}
function initTwist(state) {
const twist = getInitialTwist(state);
return update(state, {
d3: {
twist: {
rotation: { $set: twist },
active: { $set: false }
}
}
});
}
function getInitialTwist(state) {
const selectedObjects = state.selection.objects.map(({ id }) => state.objectsById[id]);
let twist;
if (selectedObjects.length === 0) {
twist = 0;
} else if (selectedObjects.length === 1) {
// one shape selected: use shape's twist
const [selectedObject] = selectedObjects;
twist = selectedObject.twist;
} else {
twist = 0;
}
return twist;
}

267
src/reducer/index.js Normal file
View File

@ -0,0 +1,267 @@
import * as THREE from 'three';
import undoable from 'redux-undo';
import undoFilter from '../utils/undoFilter.js';
import * as actions from '../actions/index.js';
import * as d2Tools from '../constants/d2Tools.js';
import * as d3Tools from '../constants/d3Tools.js';
import * as contextTools from '../constants/contextTools.js';
import { ERASER_SIZES, BRUSH_SIZES } from '../constants/d2Constants.js';
import update from 'react-addons-update';
import { defaultCamera, cameraReducer } from './d3/tools/cameraReducer.js';
import d2AddImageReducer from './d2/addImageReducer.js';
import d2ToolReducer from './d2/toolReducer.js';
import d3ToolReducer from './d3/toolReducer.js';
import d2PinchZoomReducer from './d2/pinchZoomReducer.js';
import d2WheelZoomReducer from './d2/wheelZoomReducer.js';
import d2PanZoomReducer from './d2/panReducer.js';
import selectionReducer from './selectionReducer.js';
import selectionOperationReducer from './selectionOperationReducer.js';
import contextReducer from './contextReducer.js';
import { Matrix, Vector } from 'cal';
import {
setActiveSpace, addSpaceActive, setActive2D, removeAllObjects, getActive2D, addObject
} from './objectReducers.js';
import menusReducer from './menusReducer.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:reducer:sketcher');
const initialState = {
spaces: {
world: {
matrix: new THREE.Matrix4(),
objectIds: []
}
},
objectsById: {},
activeSpace: 'world',
objectIdCounter: 0,
context: {
color: 0x96cbef
},
selection: {
transform: new Matrix(),
// array of objects (one per selected object),
// containing the object id and it's initialTransform (pre selection transform)
objects: []
},
d2: {
brush: {
size: BRUSH_SIZES[contextTools.BRUSH_SIZE_MEDIUM]
},
eraser: {
active: false,
size: ERASER_SIZES[contextTools.ERASER_SIZE_MEDIUM]
},
transform: {
active: false,
handle: '',
dragSelect: {
start: new Vector(),
end: new Vector()
}
},
trace: {
start: new Vector(),
position: new Vector(),
active: false,
floodFillData: {
edge: [],
fill: []
}
},
tool: d2Tools.FREE_HAND,
toolbar: {
open: []
},
canvasMatrix: new Matrix(),
dragging: false
},
d3: {
height: {
handle: '',
active: false
},
twist: {
rotation: 0,
active: false
},
sculpt: {
handles: [],
activeHandle: null
},
tool: d3Tools.HEIGHT,
toolbar: {
open: []
},
camera: defaultCamera,
dragging: false
},
menus: menusReducer(undefined, {})
};
function sketcherReducer(state = initialState, action) {
// if (action.log !== false) debug(action.type);
switch (action.category) {
case actions.CAT_SELECTION:
const preSelectionState = state;
state = selectionReducer(state, action);
// if selection changed
if (state.selection.objects !== preSelectionState.selection.objects) {
state = d2ToolReducer(state, action);
state = d3ToolReducer(state, action);
state = updateMenus(state, action);
state = contextReducer(state, action);
}
return state;
default:
break;
}
switch (action.type) {
case actions.ADD_OBJECT:
return addObject(state, action.objectData);
case actions.D2_DRAG_START:
case actions.D2_DRAG:
case actions.D2_DRAG_END:
case actions.D2_TAP:
case actions.TRACE_DRAG:
case actions.TRACE_DRAG_END:
case actions.TRACE_TAP:
case `${actions.FLOOD_FILL}_PENDING`:
case `${actions.FLOOD_FILL}_FULFILLED`:
case `${actions.FLOOD_FILL}_REJECTED`:
case `${actions.TRACE_FLOOD_FILL}_PENDING`:
case `${actions.TRACE_FLOOD_FILL}_FULFILLED`:
case `${actions.TRACE_FLOOD_FILL}_REJECTED`:
case actions.TRANSFORM_START:
case actions.TRANSFORM:
case actions.TRANSFORM_END:
case actions.MULTITOUCH_TRANSFORM_START:
case actions.MULTITOUCH_TRANSFORM:
case actions.MULTITOUCH_TRANSFORM_END:
case actions.D2_TEXT_INIT:
case actions.D2_TEXT_INPUT_CHANGE:
case actions.D2_TEXT_ADD:
case actions.MOVE_SELECTION:
return d2ToolReducer(state, action);
case `${actions.ADD_IMAGE}_FULFILLED`:
return d2AddImageReducer(state, action);
case actions.D2_MOUSE_WHEEL:
return d2WheelZoomReducer(state, action);
case actions.D2_SECOND_DRAG:
return d2PanZoomReducer(state, action);
case actions.D2_MULTITOUCH_START:
case actions.D2_MULTITOUCH:
case actions.D2_MULTITOUCH_END:
state = d2ToolReducer(state, action);
state = d2PinchZoomReducer(state, action);
return state;
case actions.D3_DRAG_START:
case actions.D3_DRAG:
case actions.D3_DRAG_END:
case actions.D3_SECOND_DRAG_START:
case actions.D3_SECOND_DRAG:
case actions.D3_SECOND_DRAG_END:
case actions.D3_TAP:
case actions.D3_MOUSE_WHEEL:
case actions.D3_MULTITOUCH_START:
case actions.D3_MULTITOUCH:
case actions.D3_MULTITOUCH_END:
case actions.HEIGHT_START:
case actions.HEIGHT:
case actions.HEIGHT_END:
case actions.TWIST_START:
case actions.TWIST:
case actions.TWIST_END:
case actions.SCULPT_START:
case actions.SCULPT:
case actions.SCULPT_END:
case actions.ADD_SCULPT_HANDLE:
case actions.REMOVE_SCULPT_HANDLE:
case actions.STAMP:
return d3ToolReducer(state, action);
case actions.D2_CHANGE_TOOL:
state = setActive2D(state, null);
state = selectionReducer(state, action);
state = d2ToolReducer(state, action); // switch and initialize tool
state = updateMenus(state, action);
state = contextReducer(state, action);
return state;
case actions.D3_CHANGE_TOOL:
state = d3ToolReducer(state, action); // switch and initialize tool
state = updateMenus(state, action);
return state;
case actions.CONTEXT_CHANGE_TOOL:
state = contextReducer(state, action);
state = updateMenus(state, action);
return state;
case actions.CLEAR:
state = setActive2D(state, null);
state = removeAllObjects(state);
state = selectionReducer(state, action); // deselect all
state = d2PinchZoomReducer(state, action); // reset zoom
state = update(state, {
d3: { camera: { $set: cameraReducer(state.d3.camera, action) } }
});
return state;
case actions.DUPLICATE_SELECTION:
case actions.DELETE_SELECTION:
case actions.UNION:
case actions.INTERSECT:
return selectionOperationReducer(state, action);
case actions.MENU_OPEN:
case actions.MENU_CLOSE:
return updateMenus(state, action);
case actions.UPDATE_MATRIX:
return update(state, {
objectsById: { [action.id]: { transform: { $set: action.transform } } }
});
// TODO these actions should reset
// actions.user.USER_LOGOUT_FULFILLED
// actions.files.OPEN_SKETCH:
// actions.files.FILES_LOAD_FULFILLED:
case actions.CLEAR:
return initialState;
default:
return state;
}
}
export default undoable(sketcherReducer, {
filter: undoFilter,
initTypes: []
// debug: true
});
function updateMenus(state, action) {
return {
...state,
menus: menusReducer(state.menus, action)
};
}
export function getD2ActiveShape(state) {
return getActive2D(state);
}
export function isEmpty(state) {
return Object.keys(state.sketcher.present.objectsById).length === 0;
}

View File

@ -0,0 +1,100 @@
import update from 'react-addons-update';
import * as THREE from 'three';
import shortid from 'shortid';
import { SHAPE_TYPE_PROPERTIES } from '../constants/shapeTypeProperties.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:object');
function generateUID(state) {
return `S${state.objectIdCounter}`;
}
export function addObject(state, object, UID = generateUID(state)) {
const { defaultProperties } = SHAPE_TYPE_PROPERTIES[object.type];
object = {
...defaultProperties,
color: state.context.color,
space: state.activeSpace,
...object,
UID
};
debug('addObject: ', UID);
state = update(state, {
spaces: { [object.space]: { objectIds: { $push: [UID] } } },
objectsById: { [UID]: { $set: object } },
objectIdCounter: { $set: state.objectIdCounter + 1 }
});
return state;
}
export function removeObject(state, UID) {
debug('removeObject: ', UID);
const object = state.objectsById[UID];
const { space } = object;
const filteredObjectIds = state.spaces[space].objectIds;
const filteredObjectsById = { ...state.objectsById };
delete filteredObjectsById[UID];
return update(state, {
spaces: { [space]: { objectIds: { $splice: [[filteredObjectIds.indexOf(UID), 1]] } } },
objectsById: { $set: filteredObjectsById }
});
}
export function removeAllObjects(state) {
debug('removeAllObjects: ');
return update(state, {
objectsById: { $set: {} },
activeSpace: { $set: 'world' },
spaces: { $set: {
world: {
matrix: new THREE.Matrix4(),
objectIds: []
}
} }
});
}
export function setActive2D(state, UID) {
debug('activeShape: ', UID);
return update(state, {
d2: { activeShape: { $set: UID } }
});
}
export function addObjectActive2D(state, object) {
const UID = generateUID(state);
state = addObject(state, object, UID);
return setActive2D(state, UID);
}
export function removeObjectActive2D(state, UID) {
const newState = removeObject(state, UID);
return setActive2D(newState, null);
}
export function getActive2D(state) {
return state.d2.activeShape ? state.objectsById[state.d2.activeShape] : null;
}
export function addSpaceActive(state, matrix, space = shortid.generate()) {
state = addSpace(state, matrix, space);
return setActiveSpace(state, space);
}
export function setActiveSpace(state, space) {
debug('activeSpace: ', space);
return update(state, { activeSpace: { $set: space } });
}
export function addSpace(state, matrix, space = shortid.generate()) {
debug('addSpace: ', space);
return update(state, {
spaces: { [space]: { $set: {
objectIds: [],
matrix
} } }
});
}

View File

@ -0,0 +1,139 @@
import update from 'react-addons-update';
import ClipperShape from 'clipper-js';
import { Matrix } from 'cal';
import { addObject, removeObject } from '../objectReducers.js';
import { recursiveClone } from '../utils/clone.js';
import { shapeToPoints } from '../shape/shapeToPoints.js';
import { applyMatrixOnShape, pathToVectorPath } from '../utils/vectorUtils.js';
import subtractShapeFromState from '../utils/subtractShapeFromState.js';
import * as d2Tools from '../constants/d2Tools';
import { CLIPPER_PRECISION } from '../constants/d2Constants.js';
const LINE_WIDTH = 0.5;
export default function (state, action) {
switch (action.type) {
case actions.DELETE_SELECTION:
for (const { id } of state.selection.objects) {
state = removeObject(state, id);
}
state = update(state, {
selection: {
objects: { $set: [] }
}
});
return state;
case actions.DUPLICATE_SELECTION:
for (const { id } of state.selection.objects) {
const shapeData = state.objectsById[id];
state = addObject(state, recursiveClone(shapeData));
}
// force update selection so alpha is redrawn
state = update(state, {
selection: {
objects: { $set: [...state.selection.objects] }
}
});
return state;
case actions.UNION: {
let unionShape = new ClipperShape([], true);
const shapeDataDictation = state.objectsById[state.selection.objects[0].id];
for (const { id } of state.selection.objects) {
const shapeData = state.objectsById[id];
if (!shapeData.fill) continue;
state = removeObject(state, id);
const shapes = applyMatrixOnShape(
shapeToPoints(shapeData).reduce((a, { points, holes }) => a.concat([points, ...holes]), []),
shapeData.transform
);
const shape = new ClipperShape(shapes, shapeData.fill, true, false, false)
.scaleUp(CLIPPER_PRECISION)
.round();
unionShape = unionShape.union(shape);
}
const unionShapes = unionShape.scaleDown(CLIPPER_PRECISION).seperateShapes().map(shape => {
shape = shape
.removeDuplicates()
.mapToLower()
.map(pathToVectorPath);
shape.forEach(path => path.push(path[0].clone()));
return shape;
});
for (const shape of unionShapes) {
const [points, ...holes] = shape;
state = addObject(state, {
...recursiveClone(shapeDataDictation),
type: 'COMPOUND_PATH',
transform: new Matrix(),
points,
holes
});
}
state = update(state, {
selection: {
objects: { $set: [] }
}
});
return state;
}
case actions.INTERSECT: {
let unionShape = new ClipperShape([], true);
for (const { id } of state.selection.objects) {
const shapeData = state.objectsById[id];
const shapes = applyMatrixOnShape(
shapeToPoints(shapeData).reduce((a, { points, holes }) => a.concat([points, ...holes]), []),
shapeData.transform
);
let shape = new ClipperShape(shapes, shapeData.fill, true, false, false)
.scaleUp(CLIPPER_PRECISION)
.round();
if (!shapeData.fill) {
shape = shape
.offset(LINE_WIDTH * CLIPPER_PRECISION, { jointType: 'jtSquare', endType: 'etOpenButt' })
.simplify('pftNonZero');
}
unionShape = unionShape.union(shape);
}
unionShape = unionShape.scaleDown(CLIPPER_PRECISION);
state = subtractShapeFromState(state, unionShape, d2Tools.ERASER, {
scale: CLIPPER_PRECISION,
skip: state.selection.objects.map(({ id }) => id)
});
// force update selection so alpha is redrawn
state = update(state, {
selection: {
objects: { $set: [...state.selection.objects] }
}
});
return state;
}
default:
return state;
}
}

View File

@ -0,0 +1,136 @@
import update from 'react-addons-update';
import * as actions from '../actions/index.js';
import { Vector } from 'cal';
import { shapeToPoints } from '../utils/shapeDataUtils.js';
import createDebug from 'debug';
const debug = createDebug('d3d:reducer:selection');
export default function selectionReducer(state, action) {
// if (action.log !== false) debug(action.type);
switch (action.type) {
case actions.sketcher.SELECT: {
const { shapeID } = action;
return selectObject(state, shapeID);
}
case actions.sketcher.BED_SELECT: {
return update(state, {
activeSpace: { $set: 'world' }
});
}
case actions.sketcher.DESELECT: {
const { shapeID } = action;
return deselectObject(state, shapeID);
}
case actions.sketcher.TOGGLE_SELECT: {
const { shapeID } = action;
return toggleSelectObject(state, shapeID);
}
case actions.sketcher.SELECT_ALL: {
return state.spaces.world.objectIds.reduce((_state, id) => selectObject(_state, id), state);
}
case actions.sketcher.D2_CHANGE_TOOL:
case actions.sketcher.DESELECT_ALL:
case actions.sketcher.CLEAR:
case actions.files.OPEN_SKETCH:
case actions.files.FILES_LOAD_FULFILLED:
return deselectAll(state);
case actions.sketcher.DRAG_SELECT:
const { start, end } = state.d2.transform.dragSelect;
const matrix = action.screenMatrixZoom.inverseMatrix();
const min = new Vector(Math.min(start.x, end.x), Math.min(start.y, end.y)).applyMatrix(matrix);
const max = new Vector(Math.max(start.x, end.x), Math.max(start.y, end.y)).applyMatrix(matrix);
return addSelectFromBoundingBox(state, min, max);
default:
return state;
}
}
function addSelectFromBoundingBox(state, min, max) {
for (const id of state.spaces[state.activeSpace].objectIds) {
if (isSelected(state, id)) continue;
const shapeData = state.objectsById[id];
const compoundPaths = shapeToPoints(shapeData);
const points = compoundPaths.reduce((a, { points: b }) => a.concat(b), []);
for (let point of points) {
point = point.applyMatrix(shapeData.transform);
if (point.x > min.x && point.y > min.y && point.x < max.x && point.y < max.y) {
state = selectObject(state, id);
break;
}
}
}
return state;
}
function isSelected(state, shapeUID) {
return state.selection.objects
.map(({ id }) => id)
.indexOf(shapeUID) !== -1;
}
function selectObject(state, id) {
debug('select: ', id);
if (isSelected(state, id)) return state;
const { space } = state.objectsById[id];
const objects = state.selection.objects
.filter(object => state.objectsById[object.id].space === space);
objects.push({ id });
return update(state, {
activeSpace: { $set: space },
selection: {
objects: { $set: objects }
}
});
}
function deselectObject(state, id) {
debug('deselect: ', id);
if (!isSelected(state, id)) return state;
const index = state.selection.objects.map((object) => object.id).indexOf(id);
return update(state, {
selection: {
objects: {
$splice: [[index, 1]]
}
}
});
}
function toggleSelectObject(state, id) {
if (isSelected(state, id)) {
return deselectObject(state, id);
} else {
return selectObject(state, id);
}
}
function deselectAll(state) {
debug('deselect all');
if (state.selection.objects.length === 0) {
return state;
} else {
return update(state, {
selection: {
objects: { $set: [] }
}
});
}
}

17
src/utils/clone.js Normal file
View File

@ -0,0 +1,17 @@
export function recursiveClone(object) {
if (object instanceof Image || object instanceof HTMLCanvasElement) {
return object;
} else if (object.clone instanceof Function) {
return object.clone();
} else if (object instanceof Array) {
return object.map(recursiveClone);
} else if (typeof object === 'object') {
const clone = {};
for (const key in object) {
clone[key] = recursiveClone(object[key]);
}
return clone;
} else {
return object;
}
}

50
src/utils/matrixUtils.js Normal file
View File

@ -0,0 +1,50 @@
import { Matrix, Vector } from 'cal';
export function calculateGestureMatrix(positions, previousPositions, screenMatrix, { rotate, scale, pan }) {
const matrix = screenMatrix.inverseMatrix();
const screenCenter = positions[0].add(positions[1]).scale(0.5).applyMatrix(matrix);
let gestureMatrix = new Matrix();
if (pan) {
const previousCenter = previousPositions[0].add(previousPositions[1]).scale(0.5);
const center = positions[0].add(positions[1]).scale(0.5);
const gesturePan = center.subtract(previousCenter).applyMatrix(matrix.normalize());
gestureMatrix = gestureMatrix.translate(gesturePan.x, gesturePan.y);
}
if (scale) {
const previousLength = previousPositions[0].distanceTo(previousPositions[1]);
const length = positions[0].distanceTo(positions[1]);
const gestureScale = length / previousLength;
gestureMatrix = gestureMatrix.scaleAroundAbsolute(gestureScale, gestureScale, screenCenter);
}
if (rotate) {
const previousAngle = previousPositions[1].subtract(previousPositions[0]).angle();
const angle = positions[1].subtract(positions[0]).angle();
const gestureRotation = angle - previousAngle;
gestureMatrix = gestureMatrix.rotateAroundAbsolute(gestureRotation, screenCenter);
}
return gestureMatrix;
}
export function decomposeMatrix(matrix) {
const { sx, sy, x, y, rotation } = matrix;
return { position: new Vector(x, y), scale: new Vector(sx, sy), rotation };
}
export function calculatePointInImage(point, shapeData, screenMatrix) {
const { width, height } = shapeData.imageData;
const centerOffset = new Vector(width / 2, height / 2);
const matrix = shapeData.transform.multiplyMatrix(screenMatrix).inverseMatrix();
return point
.applyMatrix(matrix)
.add(centerOffset)
.round();
}
export function isNegative(transform) {
const negativeX = transform.sx > 0;
const negativeY = transform.sy > 0;
return !((negativeX && negativeY) || (!negativeX && !negativeY));
}

View File

@ -0,0 +1,75 @@
import * as THREE from 'three';
import { shapeToPoints, getPointsBounds } from './shapeDataUtils.js';
import { Vector } from 'cal';
import arrayMemoizer from './arrayMemoizer.js';
import memoize from 'memoizee';
// import createDebug from 'debug';
// const debug = createDebug('d3d:util:selection');
// Memoized selector that returns the same array of shapeSata's when
// - the selection array didn't change
// - the objects in the resulting array didn't change
// enables memoization of utils that use this array
export const getSelectedObjectsSelector = arrayMemoizer(getSelectedObjects);
// export const getSelectedObjectsSelector = getSelectedObjects;
function getSelectedObjects(selectedObjects, objectsById) {
return selectedObjects.map(({ id }) => objectsById[id]);
}
/*
* Get the boundingbox of (multiple) shape data's
* This generally can by two types of boundingboxes.
* 1) The boundingbox of the shapes without the selection transformations (rotation, scale etc).
* Requires a selectionTransform matrix.
* This is used to display the transformations that are applied to a selection,
* showing a for example rotated boundingbox.
* Views like the 2D SelectionView will place it over the selection
* by applying the selection transformations (translating it to Objects Container Space).
* 2) The boundingbox of the shapes with the selection transformations.
* This is used to get the axis-aligned bounding box of shapes, this is
* usually used to contain the shapes within the drawing area.
*/
export const getBoundingBox = memoize(getBoundingBoxRaw, { max: 1 });
// export const getBoundingBox = getBoundingBoxRaw;
function getBoundingBoxRaw(shapeDatas, selectionTransform) {
if (selectionTransform !== undefined) {
// To show a rectangle / box around the selection that has the
// selection transforms (rotation, scale) of the selection we first have to
// remove the selection transforms so we can get the boundingbox of
// all the selected shapes as if they where not transformed yet.
// We do this by multiplying it by the inverse of the selection transforms.
// In the case of one shape the selection transforms equals it's own
// transform, but in the case of multiple shapes this only contains the
// transformations since the last selection change.
const selectionTransformInverse = selectionTransform.inverseMatrix();
shapeDatas = shapeDatas.map(shapeData => ({
...shapeData,
transform: shapeData.transform.multiplyMatrix(selectionTransformInverse)
}));
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const shapeData of shapeDatas) {
const compoundPath = shapeToPoints(shapeData);
const { min, max } = getPointsBounds(compoundPath, shapeData.transform);
minX = (min.x < minX) ? min.x : minX;
minY = (min.y < minY) ? min.y : minY;
maxX = (max.x > maxX) ? max.x : maxX;
maxY = (max.y > maxY) ? max.y : maxY;
const { z, height } = shapeData;
minZ = (z < minZ) ? z : minZ;
maxZ = (z + height > maxZ) ? z + height : maxZ;
}
const min = new THREE.Vector3(minX, minZ, minY);
const max = new THREE.Vector3(maxX, maxZ, maxY);
const center = new Vector(minX, minY).add(new Vector(maxX, maxY)).scale(0.5);
return { min, max, center };
}

View File

@ -0,0 +1,165 @@
import update from 'react-addons-update';
import { Matrix } from 'cal';
import ClipperShape from 'clipper-js';
import { recursiveClone } from './clone.js';
import { addObject, removeObject } from '../reducers/objectReducers.js';
import { SHAPE_TYPE_PROPERTIES } from '../constants/shapeTypeProperties.js';
import { shapeToPoints } from '../shape/shapeToPoints.js';
import { applyMatrixOnShape, pathToVectorPath } from './vectorUtils.js';
import { findEndPointIndexesOfPaths, mergePaths } from '../reducers/pointsReducers.js';
import R from 'ramda';
// import createDebug from 'debug';
// const debug = createDebug('d3d:util:substractShapeFromState');
function getShapePaths(shapeData, matrix) {
matrix = shapeData.transform.multiplyMatrix(matrix);
const shapes = shapeToPoints(shapeData)
.reduce((a, { points, holes }) => a.concat([points, ...holes]), []);
// collect all paths (transform to Scene Space)
return applyMatrixOnShape(shapes, matrix);
}
// Does the last point of the outline equal the last point?
// TODO move to pointsReducer
function isClosed(paths) {
const outline = paths[0];
const firstPoint = outline[0];
const lastPoint = outline[outline.length - 1];
return firstPoint.equals(lastPoint);
}
export default function subtractShapeFromState(state, differenceShape, tool, options = {}) {
const { matrix = new Matrix(), skipCompoundPath = false, scale, skip } = options;
const objectsToAdd = [];
if (scale !== undefined) differenceShape.scaleUp(scale);
for (const id of [...state.spaces[state.activeSpace].objectIds]) {
const shapeData = state.objectsById[id];
if (skip && skip.includes(id)) continue;
// if shape doens't interact with tool go to next shape
if (!SHAPE_TYPE_PROPERTIES[shapeData.type].tools[tool]) continue;
const paths = getShapePaths(shapeData, matrix);
if (skipCompoundPath && shapeData.fill) continue;
let shape = new ClipperShape(paths, shapeData.fill, true, false, false);
if (scale !== undefined) shape = shape.scaleUp(scale);
shape = shape.round().removeDuplicates();
// shape touched differenceShape ?
// TODO optimize: can we find a cheaper check?
const hasCollsion = shape.intersect(differenceShape).paths.length > 0;
if (!hasCollsion) continue;
const resultShape = shape.difference(differenceShape);
// Nothing left?
if (resultShape.paths.length === 0) {
state = removeObject(state, shapeData.UID);
continue;
}
// Seperate shapes, group hole shapes with their outline (shape they're a hole in).
// Returns two dimentional array, with per shape an array of paths.
// The first item of each path is the outline, the others are holes, if there are any.
let resultShapes = resultShape
.seperateShapes()
.map(resultPaths => { // go through all created shapes (1 shape can be a combination of outline+holes)
if (scale !== undefined) resultPaths.scaleDown(scale);
resultPaths = resultPaths
.removeDuplicates()
.mapToLower()
.filter(path => path.length > 1)
.map(pathToVectorPath);
// Clipper never adds a line from the last point to first point,
// so we add these lines for all paths of this shape manually.
// But only when this was a filled shape.
if (shapeData.fill) {
resultPaths.forEach(path => path.push(path[0].clone()));
}
return resultPaths;
})
.filter(resultPaths => resultPaths.length > 0);
if (resultShapes.length === 0) {
state = removeObject(state, shapeData.UID);
continue;
}
if (!shapeData.fill && isClosed(paths)) {
resultShapes = mergeShapes(resultShapes);
}
// go through all (merged) shapes
for (let i = 0; i < resultShapes.length; i ++) {
const [points, ...holes] = resultShapes[i];
// try updating shape with first path.
// only possible if compount path or free hand shape
if (i === 0 && ['COMPOUND_PATH', 'FREE_HAND', 'POLYGON'].includes(shapeData.type)) {
state = update(state, {
objectsById: {
[shapeData.UID]: {
points: { $set: points },
holes: { $set: holes },
transform: { $set: matrix.inverseMatrix() }
}
}
});
} else {
// if first shape but not a compount path or free hand shape,
// we can't update so we have to remove
if (i === 0) {
state = removeObject(state, shapeData.UID);
}
objectsToAdd.push({
...recursiveClone(shapeData),
type: shapeData.fill ? 'COMPOUND_PATH' : 'FREE_HAND',
transform: matrix.inverseMatrix(),
points,
holes
});
}
}
}
for (let i = 0; i < objectsToAdd.length; i ++) {
const object = objectsToAdd[i];
state = addObject(state, object);
}
return state;
}
const getOutline = R.head();
function mergeShapes(shapes) {
// debug('mergeShapes');
// get outlines from shapes
const paths = R.map(getOutline, shapes);
// debug(' paths: ', toString(paths));
const match = findEndPointIndexesOfPaths(paths);
// debug(' match: ', toString(match));
if (match) {
const [pathAIndex, pathBIndex, matchingIndexes] = match;
const mergedPath = mergePaths(paths[pathAIndex], paths[pathBIndex], matchingIndexes); // merge path
// debug(' mergedPath: ', toString(mergedPath));
const mergedShape = [mergedPath]; // turn path into shape
// update shapes
const newShapes = R.pipe(
R.update(pathAIndex, mergedShape), // update one shape with merged shape
R.remove(pathBIndex, 1) // remove other path
)(shapes);
// if merge was found we start over
return mergeShapes(newShapes);
} else { // no merge, just return same shapes
return shapes;
}
}

103
src/utils/traceUtils.js Normal file
View File

@ -0,0 +1,103 @@
import { pathToVectorPath, applyMatrixOnPath } from 'src/js/utils/shapeDataUtils.js';
import { Matrix } from 'cal';
import TraceWorker from 'workers/worker.js';
import { getPixel } from './colorUtils.js';
import memoize from 'memoizee';
const TRACE_WORKER = new TraceWorker();
export const prepareImageData = memoize(prepareImageDataRaw, { max: 1 });
function prepareImageDataRaw(canvas, x, y) {
const { width, height } = canvas;
const imageData = canvas.getContext('2d').getImageData(0, 0, width, height);
const compairValue = getPixel(imageData, y * width + x);
const data = new Uint8Array(width * height);
for (let i = 0; i < data.length; i ++) {
const pixel = getPixel(imageData, i);
const r = Math.abs(pixel.r - compairValue.r);
const g = Math.abs(pixel.g - compairValue.g);
const b = Math.abs(pixel.b - compairValue.b);
const value = Math.max(r, g, b);
data[i] = value;
}
return { width, height, data };
}
const FLOOD_FILL_MULTIPLIER = 0.4;
export function calculateTolerance(start, current) {
const tolerance = start.distanceTo(current) * FLOOD_FILL_MULTIPLIER;
return tolerance;
}
export function floodFill(tolerance, image, start) {
const imageData = prepareImageData(image, start.x, start.y);
return new Promise((resolve, reject) => {
const callback = ({ data }) => {
switch (data.msg) {
case 'FLOOD_FILL':
TRACE_WORKER.removeEventListener('message', callback);
if (data.status === 'OK') {
resolve(data.traceData);
} else {
reject(data.error);
}
break;
default:
break;
}
};
TRACE_WORKER.addEventListener('message', callback);
TRACE_WORKER.postMessage({
msg: 'FLOOD_FILL',
tolerance, imageData, start
});
});
}
export function traceFloodFill(traceData, shapeData) {
const { fill } = traceData;
const { width, height } = shapeData.imageData;
return new Promise((resolve, reject) => {
const callback = ({ data }) => {
switch (data.msg) {
case 'TRACE':
TRACE_WORKER.removeEventListener('message', callback);
const transform = new Matrix()
.translate(width / -2, height / -2)
.multiplyMatrix(shapeData.transform);
const paths = data.paths
.map(pathToVectorPath)
.map(path => applyMatrixOnPath(path, transform));
if (data.status === 'OK') {
resolve(paths);
} else {
reject(data.error);
}
break;
default:
break;
}
};
TRACE_WORKER.addEventListener('message', callback);
TRACE_WORKER.postMessage({
msg: 'TRACE',
fill, width, height
});
});
}

26
src/utils/tweenUtils.js Normal file
View File

@ -0,0 +1,26 @@
import raf from 'raf';
export function tween(duration, callback) {
return new Promise(resolve => {
let elapsedTime = 0;
let lastTime = performance.now();
const step = () => {
const currentTime = performance.now();
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
elapsedTime += deltaTime;
const progress = elapsedTime / duration;
if (progress >= 1.0) {
callback(1.0);
resolve();
} else {
callback(progress);
raf(step);
}
};
step(lastTime);
});
}

52
src/utils/undoFilter.js Normal file
View File

@ -0,0 +1,52 @@
import * as actions from '../actions/index.js';
import * as d2Tools from 'src/js/constants/d2Tools.js';
import * as contextTools from 'src/js/constants/contextTools.js';
// import createDebug from 'debug';
// const debug = createDebug('d3d:util:undoFilter');
const INCLUDE = [
`${actions.TRACE_FLOOD_FILL}_FULFILLED`,
`${actions.ADD_IMAGE}_FULFILLED`,
actions.TOGGLE_SELECT,
actions.TRANSFORM_END,
actions.MULTITOUCH_TRANSFORM_END,
actions.SELECT_ALL,
actions.DESELECT_ALL,
actions.STAMP,
actions.SELECT,
actions.D2_TEXT_ADD,
actions.CLEAR,
actions.TWIST_END,
actions.SCULPT_END,
actions.ADD_SCULPT_HANDLE,
actions.REMOVE_SCULPT_HANDLE,
actions.HEIGHT_END,
actions.DELETE_SELECTION,
actions.DUPLICATE_SELECTION,
actions.UNION,
actions.INTERSECT
];
const ACTION_INCLUDES = {
[actions.D2_DRAG_END]: [...d2Tools.SHAPE_TOOLS, ...d2Tools.PEN_TOOLS, d2Tools.ERASER],
[actions.D2_TAP]: [...d2Tools.SHAPE_TOOLS, d2Tools.BUCKET, d2Tools.ERASER]
};
const CONTEXT_TOOL_CHANGES = [
contextTools.ALIGN_LEFT,
contextTools.ALIGN_HORIZONTAL,
contextTools.ALIGN_RIGHT,
contextTools.ALIGN_TOP,
contextTools.ALIGN_VERTICAL,
contextTools.ALIGN_BOTTOM
];
export default function undoFilter(action, currentState) {
if (INCLUDE.indexOf(action.type) !== -1) return true;
if (ACTION_INCLUDES[action.type] && ACTION_INCLUDES[action.type].indexOf(currentState.d2.tool) !== -1) {
return true;
}
if (action.type === actions.CONTEXT_CHANGE_TOOL && CONTEXT_TOOL_CHANGES.includes(action.tool)) return true;
return false;
}