Navigation Menu

Skip to content

Commit

Permalink
#30 Use native SVG Quadratic and Cubic Beziers where possible
Browse files Browse the repository at this point in the history
  • Loading branch information
Ben Nortier committed Jul 12, 2019
1 parent 2ea3dc8 commit d557fb0
Show file tree
Hide file tree
Showing 12 changed files with 3,107 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/entityToPolyline.js
Expand Up @@ -72,7 +72,7 @@ const interpolateEllipse = (cx, cy, rx, ry, start, end, rotationAngle) => {
* @param knots the knot vector
* @returns the polyline
*/
const interpolateBSpline = (controlPoints, degree, knots, interpolationsPerSplineSegment) => {
export const interpolateBSpline = (controlPoints, degree, knots, interpolationsPerSplineSegment) => {
const polyline = []
const controlPointsForLib = controlPoints.map(function (p) {
return [p.x, p.y]
Expand Down
39 changes: 38 additions & 1 deletion src/toSVG.js
Expand Up @@ -7,6 +7,7 @@ import getRGBForEntity from './getRGBForEntity'
import logger from './util/logger'
import rotate from './util/rotate'
import rgbToColorAttribute from './util/rgbToColorAttribute'
import toPiecewiseBezier from './util/toPiecewiseBezier'
import transformBoundingBoxAndElement from './transformBoundingBoxAndElement'

const addFlipXIfApplicable = (entity, { bbox, element }) => {
Expand Down Expand Up @@ -136,6 +137,32 @@ const arc = (entity) => {
return transformBoundingBoxAndElement(bbox, element, entity.transforms)
}

export const piecewiseToPaths = (k, controlPoints) => {
const nSegments = (controlPoints.length - 1) / (k - 1)
const paths = []
for (let i = 0; i < nSegments; ++i) {
const cp = controlPoints.slice(i * (k - 1))
if (k === 4) {
paths.push(`<path d="M ${cp[0].x} ${cp[0].y} C ${cp[1].x} ${cp[1].y} ${cp[2].x} ${cp[2].y} ${cp[3].x} ${cp[3].y}" />`)
} else if (k === 3) {
paths.push(`<path d="M ${cp[0].x} ${cp[0].y} Q ${cp[1].x} ${cp[1].y} ${cp[2].x} ${cp[2].y}" />`)
}
}
return paths
}

const bezier = (entity) => {
let bbox = new Box2()
entity.controlPoints.forEach(p => {
bbox = bbox.expandByPoint(p)
})
const k = entity.degree + 1
const piecewise = toPiecewiseBezier(k, entity.controlPoints, entity.knots)
const paths = piecewiseToPaths(k, piecewise.controlPoints)
let element = `<g>${paths.join('')}</g>`
return transformBoundingBoxAndElement(bbox, element, entity.transforms)
}

/**
* Switcth the appropriate function on entity type. CIRCLE, ARC and ELLIPSE
* produce native SVG elements, the rest produce interpolated polylines.
Expand All @@ -150,7 +177,17 @@ const entityToBoundsAndElement = (entity) => {
return arc(entity)
case 'LINE':
case 'LWPOLYLINE':
case 'SPLINE':
case 'SPLINE': {
if ((entity.degree === 2) || (entity.degree === 3)) {
try {
return bezier(entity)
} catch (err) {
return polyline(entity)
}
} else {
return polyline(entity)
}
}
case 'POLYLINE': {
return polyline(entity)
}
Expand Down
62 changes: 62 additions & 0 deletions src/util/insertKnot.js
@@ -0,0 +1,62 @@
/**
* Knot insertion is known as "Boehm's algorithm"
*
* https://math.stackexchange.com/questions/417859/convert-a-b-spline-into-bezier-curves
* code adapted from http://preserve.mactech.com/articles/develop/issue_25/schneider.html
*/
export default (k, controlPoints, knots, newKnot) => {
const x = knots
const b = controlPoints
const n = controlPoints.length
let i = 0
let foundIndex = false
for (let j = 0; j < n + k; j++) {
if (newKnot > x[j] && newKnot <= x[j + 1]) {
i = j
foundIndex = true
break
}
}
if (!foundIndex) {
throw new Error('invalid new knot')
}

const xHat = []
for (let j = 0; j < n + k + 1; j++) {
if (j <= i) {
xHat[j] = x[j]
} else if (j === i + 1) {
xHat[j] = newKnot
} else {
xHat[j] = x[j - 1]
}
}

let alpha
const bHat = []
for (let j = 0; j < n + 1; j++) {
if (j <= i - k + 1) {
alpha = 1
} else if (i - k + 2 <= j && j <= i) {
if (x[j + k - 1] - x[j] === 0) {
alpha = 0
} else {
alpha = (newKnot - x[j]) / (x[j + k - 1] - x[j])
}
} else {
alpha = 0
}

if (alpha === 0) {
bHat[j] = b[j - 1]
} else if (alpha === 1) {
bHat[j] = b[j]
} else {
bHat[j] = {
x: (1 - alpha) * b[j - 1].x + alpha * b[j].x,
y: (1 - alpha) * b[j - 1].y + alpha * b[j].y
}
}
}
return { controlPoints: bHat, knots: xHat }
}
62 changes: 62 additions & 0 deletions src/util/toPiecewiseBezier.js
@@ -0,0 +1,62 @@
import insertKnot from './insertKnot'

/**
* For a pinned spline, the knots have to be repeated k times
* (where k is the order), at both the beginning and the end
*/
export const checkPinned = (k, knots) => {
// Pinned at the start
for (let i = 1; i < k; ++i) {
if (knots[i] !== knots[0]) {
throw Error(`not pinned. order: ${k} knots: ${knots}`)
}
}
// Pinned at the end
for (let i = knots.length - 2; i > knots.length - k - 1; --i) {
if (knots[i] !== knots[knots.length - 1]) {
throw Error(`not pinned. order: ${k} knots: ${knots}`)
}
}
}

const multiplicity = (knots, index) => {
let m = 1
for (let i = index + 1; i < knots.length; ++i) {
if (knots[i] === knots[index]) {
++m
} else {
break
}
}
return m
}

/**
* https://saccade.com/writing/graphics/KnotVectors.pdf
* A quadratic piecewise Bézier knot vector with seven control points
* will look like this [0 0 0 1 1 2 2 3 3 3]. In general, in a
* piecewise Bézier knot vector the first k knots are the same,
* then each subsequent group of k-1 knots is the same,
* until you get to the end.
*/
export const computeInsertions = (k, knots) => {
const inserts = []
let i = k
while (i < knots.length - k) {
const knot = knots[i]
const m = multiplicity(knots, i)
for (let j = 0; j < k - m - 1; ++j) {
inserts.push(knot)
}
i = i + m
}
return inserts
}

export default (k, controlPoints, knots) => {
checkPinned(k, knots)
const insertions = computeInsertions(k, knots)
return insertions.reduce((acc, tNew) => {
return insertKnot(k, acc.controlPoints, acc.knots, tNew)
}, { controlPoints, knots })
}
16 changes: 16 additions & 0 deletions test/functional/toBezier.html
@@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
background-color: #eee;
}
</style>
</head>
<body>
<div id="contents"></div>
<script src="toBezier.test.bundle.js"></script>
</body>
</html>
79 changes: 79 additions & 0 deletions test/functional/toBezier.test.js
@@ -0,0 +1,79 @@
import React from 'react'
import { render } from 'react-dom'
import { HashRouter } from 'react-router-dom'

import { interpolateBSpline } from '../../src/entityToPolyline'
import toPiecewiseBezier from '../../src/util/toPiecewiseBezier'
import { piecewiseToPaths } from '../../src/toSVG'

const controlPoints = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 },
{ x: 0, y: 20 },
{ x: 10, y: 20 }
]
const k = 4
const knots = [0, 0, 0, 0, 1, 2, 3, 3, 3, 3]
const viewBox = '-1 -21 12 22'

// const controlPoints = [
// { x: 0, y: 0 },
// { x: 122.4178296875701, y: -38.53600688262475 },
// { x: 77.52934654015353, y: 149.4771453152231 },
// { x: 200, y: 100 }
// ]
// const k = 3
// const knots = [0, 0, 0, 0.5, 1, 1, 1]
// const viewBox = '-1 -160 200 200'

const interpolated0 = interpolateBSpline(controlPoints, k - 1, knots)

const polylineToPath = (polyline) => {
const d = polyline.reduce(function (acc, point, i) {
acc += (i === 0) ? 'M' : 'L'
acc += point[0] + ',' + point[1]
return acc
}, '')
return <path d={d} />
}

const result = toPiecewiseBezier(k, controlPoints, knots)
const interpolated1 = interpolateBSpline(result.controlPoints, k - 1, result.knots)
const paths = piecewiseToPaths(k, result.controlPoints, k.knots)

render(<HashRouter>
<div>
<svg
preserveAspectRatio='xMinYMin meet'
viewBox={viewBox}
width='200'
height='400'
>
<g stroke='#000' fill='none' strokeWidth='0.1' transform='matrix(1,0,0,-1,0,0)'>
{polylineToPath(interpolated0)}
</g>
</svg>
<svg
preserveAspectRatio='xMinYMin meet'
viewBox={viewBox}
width='200'
height='400'
>
<g stroke='#000' fill='none' strokeWidth='0.1' transform='matrix(1,0,0,-1,0,0)'>
{polylineToPath(interpolated1)}
</g>
</svg>
<svg
preserveAspectRatio='xMinYMin meet'
viewBox={viewBox}
width='200'
height='400'
>
<g stroke='#000' fill='none' strokeWidth='0.1' transform='matrix(1,0,0,-1,0,0)'
dangerouslySetInnerHTML={{ __html: paths }}
/>
</svg>
</div>
</HashRouter>, document.getElementById('contents'))
3 changes: 2 additions & 1 deletion test/functional/toPolylines.test.js
Expand Up @@ -74,7 +74,8 @@ const names = [
'issue28',
'issue29',
'issue39',
'issue42'
'issue42',
'splineA'
]
const dxfs = names.map(name => require(`../resources/${name}.dxf`))
const svgs = dxfs.map(contents => toSVG(new Helper(contents).toPolylines()))
Expand Down
3 changes: 2 additions & 1 deletion test/functional/toSVG.test.js
Expand Up @@ -29,7 +29,8 @@ const names = [
'issue28',
'issue29',
'issue39',
'issue42'
'issue42',
'splineA'
]
const dxfs = names.map(name => require(`../resources/${name}.dxf`))
const svgs = dxfs.map(contents => new Helper(contents).toSVG())
Expand Down
5 changes: 5 additions & 0 deletions test/functional/webpack.config.js
Expand Up @@ -17,6 +17,11 @@ module.exports = {
`webpack-dev-server/client?http://localhost:${port}`,
'webpack/hot/dev-server',
path.resolve(__dirname, 'toSVG.test.js')
],
'toBezier.test': [
`webpack-dev-server/client?http://localhost:${port}`,
'webpack/hot/dev-server',
path.resolve(__dirname, 'toBezier.test.js')
]
},
output: {
Expand Down

0 comments on commit d557fb0

Please sign in to comment.