diff --git a/package-lock.json b/package-lock.json index 23f43fb..83f007f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3558ceb..de3f033 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.js b/src/index.js index 9872bd6..8261f62 100644 --- a/src/index.js +++ b/src/index.js @@ -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 }; diff --git a/src/reducer/d2/addImageReducer.js b/src/reducer/d2/addImageReducer.js new file mode 100644 index 0000000..8a915b7 --- /dev/null +++ b/src/reducer/d2/addImageReducer.js @@ -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 }); +} diff --git a/src/reducer/d2/constrainMatrix.js b/src/reducer/d2/constrainMatrix.js new file mode 100644 index 0000000..ab4f2f6 --- /dev/null +++ b/src/reducer/d2/constrainMatrix.js @@ -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; +} diff --git a/src/reducer/d2/panReducer.js b/src/reducer/d2/panReducer.js new file mode 100644 index 0000000..82abad2 --- /dev/null +++ b/src/reducer/d2/panReducer.js @@ -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 } + } + }); +} diff --git a/src/reducer/d2/pinchZoomReducer.js b/src/reducer/d2/pinchZoomReducer.js new file mode 100644 index 0000000..27cd510 --- /dev/null +++ b/src/reducer/d2/pinchZoomReducer.js @@ -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 } + } + }); +} diff --git a/src/reducer/d2/toolReducer.js b/src/reducer/d2/toolReducer.js new file mode 100644 index 0000000..e596986 --- /dev/null +++ b/src/reducer/d2/toolReducer.js @@ -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 } } + }); + } +} diff --git a/src/reducer/d2/tools/bucketReducer.js b/src/reducer/d2/tools/bucketReducer.js new file mode 100644 index 0000000..bc62281 --- /dev/null +++ b/src/reducer/d2/tools/bucketReducer.js @@ -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 + }); +} diff --git a/src/reducer/d2/tools/eraserReducer.js b/src/reducer/d2/tools/eraserReducer.js new file mode 100644 index 0000000..c1e457f --- /dev/null +++ b/src/reducer/d2/tools/eraserReducer.js @@ -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 }); +} diff --git a/src/reducer/d2/tools/penReducer.js b/src/reducer/d2/tools/penReducer.js new file mode 100644 index 0000000..5d36c01 --- /dev/null +++ b/src/reducer/d2/tools/penReducer.js @@ -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; + } +} diff --git a/src/reducer/d2/tools/photoGuideReducer.js b/src/reducer/d2/tools/photoGuideReducer.js new file mode 100644 index 0000000..c4a1f09 --- /dev/null +++ b/src/reducer/d2/tools/photoGuideReducer.js @@ -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; + } +} diff --git a/src/reducer/d2/tools/shapes/circleReducer.js b/src/reducer/d2/tools/shapes/circleReducer.js new file mode 100644 index 0000000..f64dbf2 --- /dev/null +++ b/src/reducer/d2/tools/shapes/circleReducer.js @@ -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 } + } + } + } + }); +} diff --git a/src/reducer/d2/tools/shapes/circleSegmentReducer.js b/src/reducer/d2/tools/shapes/circleSegmentReducer.js new file mode 100644 index 0000000..fff1792 --- /dev/null +++ b/src/reducer/d2/tools/shapes/circleSegmentReducer.js @@ -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); +} diff --git a/src/reducer/d2/tools/shapes/heartReducer.js b/src/reducer/d2/tools/shapes/heartReducer.js new file mode 100644 index 0000000..4ff384b --- /dev/null +++ b/src/reducer/d2/tools/shapes/heartReducer.js @@ -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; + } +} diff --git a/src/reducer/d2/tools/shapes/polyPointReducer.js b/src/reducer/d2/tools/shapes/polyPointReducer.js new file mode 100644 index 0000000..a6a6961 --- /dev/null +++ b/src/reducer/d2/tools/shapes/polyPointReducer.js @@ -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; + } +} diff --git a/src/reducer/d2/tools/shapes/rectReducer.js b/src/reducer/d2/tools/shapes/rectReducer.js new file mode 100644 index 0000000..f9dd4f8 --- /dev/null +++ b/src/reducer/d2/tools/shapes/rectReducer.js @@ -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 } + } + } + } + }); +} diff --git a/src/reducer/d2/tools/shapes/skewRectReducer.js b/src/reducer/d2/tools/shapes/skewRectReducer.js new file mode 100644 index 0000000..9368125 --- /dev/null +++ b/src/reducer/d2/tools/shapes/skewRectReducer.js @@ -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; + } +} diff --git a/src/reducer/d2/tools/shapes/starReducer.js b/src/reducer/d2/tools/shapes/starReducer.js new file mode 100644 index 0000000..16b15b7 --- /dev/null +++ b/src/reducer/d2/tools/shapes/starReducer.js @@ -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 } + } + } + } + }); +} diff --git a/src/reducer/d2/tools/shapes/triangleReducer.js b/src/reducer/d2/tools/shapes/triangleReducer.js new file mode 100644 index 0000000..9e7b3b3 --- /dev/null +++ b/src/reducer/d2/tools/shapes/triangleReducer.js @@ -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 } + } + } + } + }); +} diff --git a/src/reducer/d2/tools/textReducer.js b/src/reducer/d2/tools/textReducer.js new file mode 100644 index 0000000..19d8acd --- /dev/null +++ b/src/reducer/d2/tools/textReducer.js @@ -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; +} diff --git a/src/reducer/d2/tools/transformReducer.js b/src/reducer/d2/tools/transformReducer.js new file mode 100644 index 0000000..3a41bfb --- /dev/null +++ b/src/reducer/d2/tools/transformReducer.js @@ -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); + } +} diff --git a/src/reducer/d2/wheelZoomReducer.js b/src/reducer/d2/wheelZoomReducer.js new file mode 100644 index 0000000..79a0c10 --- /dev/null +++ b/src/reducer/d2/wheelZoomReducer.js @@ -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 } + } + }); +} diff --git a/src/reducer/d3/toolReducer.js b/src/reducer/d3/toolReducer.js new file mode 100644 index 0000000..4c3b3b5 --- /dev/null +++ b/src/reducer/d3/toolReducer.js @@ -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; + } +} diff --git a/src/reducer/d3/tools/cameraReducer.js b/src/reducer/d3/tools/cameraReducer.js new file mode 100644 index 0000000..a396554 --- /dev/null +++ b/src/reducer/d3/tools/cameraReducer.js @@ -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 } + }); +} diff --git a/src/reducer/d3/tools/heightReducer.js b/src/reducer/d3/tools/heightReducer.js new file mode 100644 index 0000000..83cd2cb --- /dev/null +++ b/src/reducer/d3/tools/heightReducer.js @@ -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; + } +} diff --git a/src/reducer/d3/tools/sculptReducer.js b/src/reducer/d3/tools/sculptReducer.js new file mode 100644 index 0000000..efa5db9 --- /dev/null +++ b/src/reducer/d3/tools/sculptReducer.js @@ -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; +} diff --git a/src/reducer/d3/tools/stampReducer.js b/src/reducer/d3/tools/stampReducer.js new file mode 100644 index 0000000..78a7db1 --- /dev/null +++ b/src/reducer/d3/tools/stampReducer.js @@ -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; + } +} diff --git a/src/reducer/d3/tools/twistReducer.js b/src/reducer/d3/tools/twistReducer.js new file mode 100644 index 0000000..9d242c2 --- /dev/null +++ b/src/reducer/d3/tools/twistReducer.js @@ -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; +} diff --git a/src/reducer/index.js b/src/reducer/index.js new file mode 100644 index 0000000..e34663d --- /dev/null +++ b/src/reducer/index.js @@ -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; +} diff --git a/src/reducer/objectReducer.js b/src/reducer/objectReducer.js new file mode 100644 index 0000000..69f207a --- /dev/null +++ b/src/reducer/objectReducer.js @@ -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 + } } } + }); +} diff --git a/src/reducer/selectionOperationReducer.js b/src/reducer/selectionOperationReducer.js new file mode 100644 index 0000000..bc37ad9 --- /dev/null +++ b/src/reducer/selectionOperationReducer.js @@ -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; + } +} diff --git a/src/reducer/selectionReducer.js b/src/reducer/selectionReducer.js new file mode 100644 index 0000000..033c911 --- /dev/null +++ b/src/reducer/selectionReducer.js @@ -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: [] } + } + }); + } +} diff --git a/src/utils/clone.js b/src/utils/clone.js new file mode 100644 index 0000000..964ecb9 --- /dev/null +++ b/src/utils/clone.js @@ -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; + } +} diff --git a/src/utils/matrixUtils.js b/src/utils/matrixUtils.js new file mode 100644 index 0000000..d9d9efe --- /dev/null +++ b/src/utils/matrixUtils.js @@ -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)); +} diff --git a/src/utils/selectionUtils.js b/src/utils/selectionUtils.js new file mode 100644 index 0000000..57a6164 --- /dev/null +++ b/src/utils/selectionUtils.js @@ -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 }; +} diff --git a/src/utils/subtractShapeFromState.js b/src/utils/subtractShapeFromState.js new file mode 100644 index 0000000..a449f76 --- /dev/null +++ b/src/utils/subtractShapeFromState.js @@ -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; + } +} diff --git a/src/utils/traceUtils.js b/src/utils/traceUtils.js new file mode 100644 index 0000000..9e2e850 --- /dev/null +++ b/src/utils/traceUtils.js @@ -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 + }); + }); +} diff --git a/src/utils/tweenUtils.js b/src/utils/tweenUtils.js new file mode 100644 index 0000000..b16aa00 --- /dev/null +++ b/src/utils/tweenUtils.js @@ -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); + }); +} diff --git a/src/utils/undoFilter.js b/src/utils/undoFilter.js new file mode 100644 index 0000000..c28b06a --- /dev/null +++ b/src/utils/undoFilter.js @@ -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; +}