UNPKG

29.2 kBJavaScriptView Raw
1const {EPS, angleEPS, areaEPS, defaultResolution3D} = require('./constants')
2const {Connector} = require('./connectors')
3const OrthoNormalBasis = require('./math/OrthoNormalBasis')
4const Vertex2D = require('./math/Vertex2')
5const Vertex3D = require('./math/Vertex3')
6const Vector2D = require('./math/Vector2')
7const Vector3D = require('./math/Vector3')
8const Polygon = require('./math/Polygon3')
9const Path2D = require('./math/Path2')
10const Side = require('./math/Side')
11const {linesIntersect} = require('./math/lineUtils')
12const {parseOptionAs3DVector, parseOptionAsBool, parseOptionAsFloat, parseOptionAsInt} = require('./optionParsers')
13const FuzzyCAGFactory = require('./FuzzyFactory2d')
14/**
15 * Class CAG
16 * Holds a solid area geometry like CSG but 2D.
17 * Each area consists of a number of sides.
18 * Each side is a line between 2 points.
19 * @constructor
20 */
21let CAG = function () {
22 this.sides = []
23 this.isCanonicalized = false
24}
25
26/** Construct a CAG from a list of `Side` instances.
27 * @param {Side[]} sides - list of sides
28 * @returns {CAG} new CAG object
29 */
30CAG.fromSides = function (sides) {
31 let cag = new CAG()
32 cag.sides = sides
33 return cag
34}
35
36
37// Converts a CSG to a The CSG must consist of polygons with only z coordinates +1 and -1
38// as constructed by _toCSGWall(-1, 1). This is so we can use the 3D union(), intersect() etc
39CAG.fromFakeCSG = function (csg) {
40 let sides = csg.polygons.map(function (p) {
41 return Side._fromFakePolygon(p)
42 })
43 .filter(function (s) {
44 return s !== null
45 })
46 return CAG.fromSides(sides)
47}
48
49/** Construct a CAG from a list of points (a polygon).
50 * The rotation direction of the points is not relevant.
51 * The points can define a convex or a concave polygon.
52 * The polygon must not self intersect.
53 * @param {points[]} points - list of points in 2D space
54 * @returns {CAG} new CAG object
55 */
56CAG.fromPoints = function (points) {
57 let numpoints = points.length
58 if (numpoints < 3) throw new Error('CAG shape needs at least 3 points')
59 let sides = []
60 let prevpoint = new Vector2D(points[numpoints - 1])
61 let prevvertex = new Vertex2D(prevpoint)
62 points.map(function (p) {
63 let point = new Vector2D(p)
64 let vertex = new Vertex2D(point)
65 let side = new Side(prevvertex, vertex)
66 sides.push(side)
67 prevvertex = vertex
68 })
69 let result = CAG.fromSides(sides)
70 if (result.isSelfIntersecting()) {
71 throw new Error('Polygon is self intersecting!')
72 }
73 let area = result.area()
74 if (Math.abs(area) < areaEPS) {
75 throw new Error('Degenerate polygon!')
76 }
77 if (area < 0) {
78 result = result.flipped()
79 }
80 result = result.canonicalized()
81 return result
82}
83
84const CAGFromCAGFuzzyFactory = function (factory, sourcecag) {
85 let _this = factory
86 let newsides = sourcecag.sides.map(function (side) {
87 return _this.getSide(side)
88 })
89 // remove bad sides (mostly a user input issue)
90 .filter(function (side) {
91 return side.length() > EPS
92 })
93 return CAG.fromSides(newsides)
94}
95
96CAG.prototype = {
97 toString: function () {
98 let result = 'CAG (' + this.sides.length + ' sides):\n'
99 this.sides.map(function (side) {
100 result += ' ' + side.toString() + '\n'
101 })
102 return result
103 },
104
105 _toCSGWall: function (z0, z1) {
106 const CSG = require('./CSG') // FIXME: circular dependencies CAG=>CSG=>CAG
107 let polygons = this.sides.map(function (side) {
108 return side.toPolygon3D(z0, z1)
109 })
110 return CSG.fromPolygons(polygons)
111 },
112
113 _toVector3DPairs: function (m) {
114 // transform m
115 let pairs = this.sides.map(function (side) {
116 let p0 = side.vertex0.pos
117 let p1 = side.vertex1.pos
118 return [Vector3D.Create(p0.x, p0.y, 0),
119 Vector3D.Create(p1.x, p1.y, 0)]
120 })
121 if (typeof m !== 'undefined') {
122 pairs = pairs.map(function (pair) {
123 return pair.map(function (v) {
124 return v.transform(m)
125 })
126 })
127 }
128 return pairs
129 },
130
131 /*
132 * transform a cag into the polygons of a corresponding 3d plane, positioned per options
133 * Accepts a connector for plane positioning, or optionally
134 * single translation, axisVector, normalVector arguments
135 * (toConnector has precedence over single arguments if provided)
136 */
137 _toPlanePolygons: function (options) {
138 const CSG = require('./CSG') // FIXME: circular dependencies CAG=>CSG=>CAG
139 let flipped = options.flipped || false
140 // reference connector for transformation
141 let origin = [0, 0, 0]
142 let defaultAxis = [0, 0, 1]
143 let defaultNormal = [0, 1, 0]
144 let thisConnector = new Connector(origin, defaultAxis, defaultNormal)
145 // translated connector per options
146 let translation = options.translation || origin
147 let axisVector = options.axisVector || defaultAxis
148 let normalVector = options.normalVector || defaultNormal
149 // will override above if options has toConnector
150 let toConnector = options.toConnector ||
151 new Connector(translation, axisVector, normalVector)
152 // resulting transform
153 let m = thisConnector.getTransformationTo(toConnector, false, 0)
154 // create plane as a (partial non-closed) CSG in XY plane
155 let bounds = this.getBounds()
156 bounds[0] = bounds[0].minus(new Vector2D(1, 1))
157 bounds[1] = bounds[1].plus(new Vector2D(1, 1))
158 let csgshell = this._toCSGWall(-1, 1)
159 let csgplane = CSG.fromPolygons([new Polygon([
160 new Vertex3D(new Vector3D(bounds[0].x, bounds[0].y, 0)),
161 new Vertex3D(new Vector3D(bounds[1].x, bounds[0].y, 0)),
162 new Vertex3D(new Vector3D(bounds[1].x, bounds[1].y, 0)),
163 new Vertex3D(new Vector3D(bounds[0].x, bounds[1].y, 0))
164 ])])
165 if (flipped) {
166 csgplane = csgplane.invert()
167 }
168 // intersectSub -> prevent premature retesselate/canonicalize
169 csgplane = csgplane.intersectSub(csgshell)
170 // only keep the polygons in the z plane:
171 let polys = csgplane.polygons.filter(function (polygon) {
172 return Math.abs(polygon.plane.normal.z) > 0.99
173 })
174 // finally, position the plane per passed transformations
175 return polys.map(function (poly) {
176 return poly.transform(m)
177 })
178 },
179
180 /*
181 * given 2 connectors, this returns all polygons of a "wall" between 2
182 * copies of this cag, positioned in 3d space as "bottom" and
183 * "top" plane per connectors toConnector1, and toConnector2, respectively
184 */
185 _toWallPolygons: function (options) {
186 // normals are going to be correct as long as toConn2.point - toConn1.point
187 // points into cag normal direction (check in caller)
188 // arguments: options.toConnector1, options.toConnector2, options.cag
189 // walls go from toConnector1 to toConnector2
190 // optionally, target cag to point to - cag needs to have same number of sides as this!
191 let origin = [0, 0, 0]
192 let defaultAxis = [0, 0, 1]
193 let defaultNormal = [0, 1, 0]
194 let thisConnector = new Connector(origin, defaultAxis, defaultNormal)
195 // arguments:
196 let toConnector1 = options.toConnector1
197 // let toConnector2 = new Connector([0, 0, -30], defaultAxis, defaultNormal);
198 let toConnector2 = options.toConnector2
199 if (!(toConnector1 instanceof Connector && toConnector2 instanceof Connector)) {
200 throw new Error('could not parse Connector arguments toConnector1 or toConnector2')
201 }
202 if (options.cag) {
203 if (options.cag.sides.length !== this.sides.length) {
204 throw new Error('target cag needs same sides count as start cag')
205 }
206 }
207 // target cag is same as this unless specified
208 let toCag = options.cag || this
209 let m1 = thisConnector.getTransformationTo(toConnector1, false, 0)
210 let m2 = thisConnector.getTransformationTo(toConnector2, false, 0)
211 let vps1 = this._toVector3DPairs(m1)
212 let vps2 = toCag._toVector3DPairs(m2)
213
214 let polygons = []
215 vps1.forEach(function (vp1, i) {
216 polygons.push(new Polygon([
217 new Vertex3D(vps2[i][1]), new Vertex3D(vps2[i][0]), new Vertex3D(vp1[0])]))
218 polygons.push(new Polygon([
219 new Vertex3D(vps2[i][1]), new Vertex3D(vp1[0]), new Vertex3D(vp1[1])]))
220 })
221 return polygons
222 },
223
224 /**
225 * Convert to a list of points.
226 * @return {points[]} list of points in 2D space
227 */
228 toPoints: function () {
229 let points = this.sides.map(function (side) {
230 let v0 = side.vertex0
231 // let v1 = side.vertex1
232 return v0.pos
233 })
234 // due to the logic of CAG.fromPoints()
235 // move the first point to the last
236 if (points.length > 0) {
237 points.push(points.shift())
238 }
239 return points
240 },
241
242 union: function (cag) {
243 let cags
244 if (cag instanceof Array) {
245 cags = cag
246 } else {
247 cags = [cag]
248 }
249 let r = this._toCSGWall(-1, 1)
250 r = r.union(
251 cags.map(function (cag) {
252 return cag._toCSGWall(-1, 1).reTesselated()
253 }), false, false)
254 return CAG.fromFakeCSG(r).canonicalized()
255 },
256
257 subtract: function (cag) {
258 let cags
259 if (cag instanceof Array) {
260 cags = cag
261 } else {
262 cags = [cag]
263 }
264 let r = this._toCSGWall(-1, 1)
265 cags.map(function (cag) {
266 r = r.subtractSub(cag._toCSGWall(-1, 1), false, false)
267 })
268 r = r.reTesselated()
269 r = r.canonicalized()
270 r = CAG.fromFakeCSG(r)
271 r = r.canonicalized()
272 return r
273 },
274
275 intersect: function (cag) {
276 let cags
277 if (cag instanceof Array) {
278 cags = cag
279 } else {
280 cags = [cag]
281 }
282 let r = this._toCSGWall(-1, 1)
283 cags.map(function (cag) {
284 r = r.intersectSub(cag._toCSGWall(-1, 1), false, false)
285 })
286 r = r.reTesselated()
287 r = r.canonicalized()
288 r = CAG.fromFakeCSG(r)
289 r = r.canonicalized()
290 return r
291 },
292
293 transform: function (matrix4x4) {
294 let ismirror = matrix4x4.isMirroring()
295 let newsides = this.sides.map(function (side) {
296 return side.transform(matrix4x4)
297 })
298 let result = CAG.fromSides(newsides)
299 if (ismirror) {
300 result = result.flipped()
301 }
302 return result
303 },
304
305 // see http://local.wasp.uwa.edu.au/~pbourke/geometry/polyarea/ :
306 // Area of the polygon. For a counter clockwise rotating polygon the area is positive, otherwise negative
307 // Note(bebbi): this looks wrong. See polygon getArea()
308 area: function () {
309 let polygonArea = 0
310 this.sides.map(function (side) {
311 polygonArea += side.vertex0.pos.cross(side.vertex1.pos)
312 })
313 polygonArea *= 0.5
314 return polygonArea
315 },
316
317 flipped: function () {
318 let newsides = this.sides.map(function (side) {
319 return side.flipped()
320 })
321 newsides.reverse()
322 return CAG.fromSides(newsides)
323 },
324
325 getBounds: function () {
326 let minpoint
327 if (this.sides.length === 0) {
328 minpoint = new Vector2D(0, 0)
329 } else {
330 minpoint = this.sides[0].vertex0.pos
331 }
332 let maxpoint = minpoint
333 this.sides.map(function (side) {
334 minpoint = minpoint.min(side.vertex0.pos)
335 minpoint = minpoint.min(side.vertex1.pos)
336 maxpoint = maxpoint.max(side.vertex0.pos)
337 maxpoint = maxpoint.max(side.vertex1.pos)
338 })
339 return [minpoint, maxpoint]
340 },
341
342 isSelfIntersecting: function (debug) {
343 let numsides = this.sides.length
344 for (let i = 0; i < numsides; i++) {
345 let side0 = this.sides[i]
346 for (let ii = i + 1; ii < numsides; ii++) {
347 let side1 = this.sides[ii]
348 if (linesIntersect(side0.vertex0.pos, side0.vertex1.pos, side1.vertex0.pos, side1.vertex1.pos)) {
349 if (debug) { console.log('side ' + i + ': ' + side0); console.log('side ' + ii + ': ' + side1) }
350 return true
351 }
352 }
353 }
354 return false
355 },
356
357 expandedShell: function (radius, resolution) {
358 resolution = resolution || 8
359 if (resolution < 4) resolution = 4
360 let cags = []
361 let pointmap = {}
362 let cag = this.canonicalized()
363 cag.sides.map(function (side) {
364 let d = side.vertex1.pos.minus(side.vertex0.pos)
365 let dl = d.length()
366 if (dl > EPS) {
367 d = d.times(1.0 / dl)
368 let normal = d.normal().times(radius)
369 let shellpoints = [
370 side.vertex1.pos.plus(normal),
371 side.vertex1.pos.minus(normal),
372 side.vertex0.pos.minus(normal),
373 side.vertex0.pos.plus(normal)
374 ]
375 // let newcag = CAG.fromPointsNoCheck(shellpoints);
376 let newcag = CAG.fromPoints(shellpoints)
377 cags.push(newcag)
378 for (let step = 0; step < 2; step++) {
379 let p1 = (step === 0) ? side.vertex0.pos : side.vertex1.pos
380 let p2 = (step === 0) ? side.vertex1.pos : side.vertex0.pos
381 let tag = p1.x + ' ' + p1.y
382 if (!(tag in pointmap)) {
383 pointmap[tag] = []
384 }
385 pointmap[tag].push({
386 'p1': p1,
387 'p2': p2
388 })
389 }
390 }
391 })
392 for (let tag in pointmap) {
393 let m = pointmap[tag]
394 let angle1, angle2
395 let pcenter = m[0].p1
396 if (m.length === 2) {
397 let end1 = m[0].p2
398 let end2 = m[1].p2
399 angle1 = end1.minus(pcenter).angleDegrees()
400 angle2 = end2.minus(pcenter).angleDegrees()
401 if (angle2 < angle1) angle2 += 360
402 if (angle2 >= (angle1 + 360)) angle2 -= 360
403 if (angle2 < angle1 + 180) {
404 let t = angle2
405 angle2 = angle1 + 360
406 angle1 = t
407 }
408 angle1 += 90
409 angle2 -= 90
410 } else {
411 angle1 = 0
412 angle2 = 360
413 }
414 let fullcircle = (angle2 > angle1 + 359.999)
415 if (fullcircle) {
416 angle1 = 0
417 angle2 = 360
418 }
419 if (angle2 > (angle1 + angleEPS)) {
420 let points = []
421 if (!fullcircle) {
422 points.push(pcenter)
423 }
424 let numsteps = Math.round(resolution * (angle2 - angle1) / 360)
425 if (numsteps < 1) numsteps = 1
426 for (let step = 0; step <= numsteps; step++) {
427 let angle = angle1 + step / numsteps * (angle2 - angle1)
428 if (step === numsteps) angle = angle2 // prevent rounding errors
429 let point = pcenter.plus(Vector2D.fromAngleDegrees(angle).times(radius))
430 if ((!fullcircle) || (step > 0)) {
431 points.push(point)
432 }
433 }
434 let newcag = CAG.fromPointsNoCheck(points)
435 cags.push(newcag)
436 }
437 }
438 let result = new CAG()
439 result = result.union(cags)
440 return result
441 },
442
443 expand: function (radius, resolution) {
444 let result = this.union(this.expandedShell(radius, resolution))
445 return result
446 },
447
448 contract: function (radius, resolution) {
449 let result = this.subtract(this.expandedShell(radius, resolution))
450 return result
451 },
452
453 // extrude the CAG in a certain plane.
454 // Giving just a plane is not enough, multiple different extrusions in the same plane would be possible
455 // by rotating around the plane's origin. An additional right-hand vector should be specified as well,
456 // and this is exactly a OrthoNormalBasis.
457 //
458 // orthonormalbasis: characterizes the plane in which to extrude
459 // depth: thickness of the extruded shape. Extrusion is done upwards from the plane
460 // (unless symmetrical option is set, see below)
461 // options:
462 // {symmetrical: true} // extrude symmetrically in two directions about the plane
463 extrudeInOrthonormalBasis: function (orthonormalbasis, depth, options) {
464 // first extrude in the regular Z plane:
465 if (!(orthonormalbasis instanceof OrthoNormalBasis)) {
466 throw new Error('extrudeInPlane: the first parameter should be a OrthoNormalBasis')
467 }
468 let extruded = this.extrude({
469 offset: [0, 0, depth]
470 })
471 if (parseOptionAsBool(options, 'symmetrical', false)) {
472 extruded = extruded.translate([0, 0, -depth / 2])
473 }
474 let matrix = orthonormalbasis.getInverseProjectionMatrix()
475 extruded = extruded.transform(matrix)
476 return extruded
477 },
478
479 // Extrude in a standard cartesian plane, specified by two axis identifiers. Each identifier can be
480 // one of ["X","Y","Z","-X","-Y","-Z"]
481 // The 2d x axis will map to the first given 3D axis, the 2d y axis will map to the second.
482 // See OrthoNormalBasis.GetCartesian for details.
483 // options:
484 // {symmetrical: true} // extrude symmetrically in two directions about the plane
485 extrudeInPlane: function (axis1, axis2, depth, options) {
486 return this.extrudeInOrthonormalBasis(OrthoNormalBasis.GetCartesian(axis1, axis2), depth, options)
487 },
488
489 // extruded=cag.extrude({offset: [0,0,10], twistangle: 360, twiststeps: 100});
490 // linear extrusion of 2D shape, with optional twist
491 // The 2d shape is placed in in z=0 plane and extruded into direction <offset> (a Vector3D)
492 // The final face is rotated <twistangle> degrees. Rotation is done around the origin of the 2d shape (i.e. x=0, y=0)
493 // twiststeps determines the resolution of the twist (should be >= 1)
494 // returns a CSG object
495 extrude: function (options) {
496 const CSG = require('./CSG') // FIXME: circular dependencies CAG=>CSG=>CAG
497 if (this.sides.length === 0) {
498 // empty!
499 return new CSG()
500 }
501 let offsetVector = parseOptionAs3DVector(options, 'offset', [0, 0, 1])
502 let twistangle = parseOptionAsFloat(options, 'twistangle', 0)
503 let twiststeps = parseOptionAsInt(options, 'twiststeps', defaultResolution3D)
504 if (offsetVector.z === 0) {
505 throw new Error('offset cannot be orthogonal to Z axis')
506 }
507 if (twistangle === 0 || twiststeps < 1) {
508 twiststeps = 1
509 }
510 let normalVector = Vector3D.Create(0, 1, 0)
511
512 let polygons = []
513 // bottom and top
514 polygons = polygons.concat(this._toPlanePolygons({
515 translation: [0, 0, 0],
516 normalVector: normalVector,
517 flipped: !(offsetVector.z < 0)}
518 ))
519 polygons = polygons.concat(this._toPlanePolygons({
520 translation: offsetVector,
521 normalVector: normalVector.rotateZ(twistangle),
522 flipped: offsetVector.z < 0}))
523 // walls
524 for (let i = 0; i < twiststeps; i++) {
525 let c1 = new Connector(offsetVector.times(i / twiststeps), [0, 0, offsetVector.z],
526 normalVector.rotateZ(i * twistangle / twiststeps))
527 let c2 = new Connector(offsetVector.times((i + 1) / twiststeps), [0, 0, offsetVector.z],
528 normalVector.rotateZ((i + 1) * twistangle / twiststeps))
529 polygons = polygons.concat(this._toWallPolygons({toConnector1: c1, toConnector2: c2}))
530 }
531
532 return CSG.fromPolygons(polygons)
533 },
534
535 /** Extrude to into a 3D solid by rotating the origin around the Y axis.
536 * (and turning everything into XY plane)
537 * @param {Object} options - options for construction
538 * @param {Number} [options.angle=360] - angle of rotation
539 * @param {Number} [options.resolution=defaultResolution3D] - number of polygons per 360 degree revolution
540 * @returns {CSG} new 3D solid
541 */
542 rotateExtrude: function (options) { // FIXME options should be optional
543 const CSG = require('./CSG') // FIXME: circular dependencies CAG=>CSG=>CAG
544 let alpha = parseOptionAsFloat(options, 'angle', 360)
545 let resolution = parseOptionAsInt(options, 'resolution', defaultResolution3D)
546
547 alpha = alpha > 360 ? alpha % 360 : alpha
548 let origin = [0, 0, 0]
549 let axisV = Vector3D.Create(0, 1, 0)
550 let normalV = [0, 0, 1]
551 let polygons = []
552 // planes only needed if alpha > 0
553 let connS = new Connector(origin, axisV, normalV)
554 if (alpha > 0 && alpha < 360) {
555 // we need to rotate negative to satisfy wall function condition of
556 // building in the direction of axis vector
557 let connE = new Connector(origin, axisV.rotateZ(-alpha), normalV)
558 polygons = polygons.concat(
559 this._toPlanePolygons({toConnector: connS, flipped: true}))
560 polygons = polygons.concat(
561 this._toPlanePolygons({toConnector: connE}))
562 }
563 let connT1 = connS
564 let connT2
565 let step = alpha / resolution
566 for (let a = step; a <= alpha + EPS; a += step) { // FIXME Should this be angelEPS?
567 connT2 = new Connector(origin, axisV.rotateZ(-a), normalV)
568 polygons = polygons.concat(this._toWallPolygons(
569 {toConnector1: connT1, toConnector2: connT2}))
570 connT1 = connT2
571 }
572 return CSG.fromPolygons(polygons).reTesselated()
573 },
574
575 // check if we are a valid CAG (for debugging)
576 // NOTE(bebbi) uneven side count doesn't work because rounding with EPS isn't taken into account
577 check: function () {
578 let errors = []
579 if (this.isSelfIntersecting(true)) {
580 errors.push('Self intersects')
581 }
582 let pointcount = {}
583 this.sides.map(function (side) {
584 function mappoint (p) {
585 let tag = p.x + ' ' + p.y
586 if (!(tag in pointcount)) pointcount[tag] = 0
587 pointcount[tag] ++
588 }
589 mappoint(side.vertex0.pos)
590 mappoint(side.vertex1.pos)
591 })
592 for (let tag in pointcount) {
593 let count = pointcount[tag]
594 if (count & 1) {
595 errors.push('Uneven number of sides (' + count + ') for point ' + tag)
596 }
597 }
598 let area = this.area()
599 if (area < areaEPS) {
600 errors.push('Area is ' + area)
601 }
602 if (errors.length > 0) {
603 let ertxt = ''
604 errors.map(function (err) {
605 ertxt += err + '\n'
606 })
607 throw new Error(ertxt)
608 }
609 },
610
611 canonicalized: function () {
612 if (this.isCanonicalized) {
613 return this
614 } else {
615 let factory = new FuzzyCAGFactory()
616 let result = CAGFromCAGFuzzyFactory(factory, this)
617 result.isCanonicalized = true
618 return result
619 }
620 },
621
622 /** Convert to compact binary form.
623 * See CAG.fromCompactBinary.
624 * @return {CompactBinary}
625 */
626 toCompactBinary: function () {
627 let cag = this.canonicalized()
628 let numsides = cag.sides.length
629 let vertexmap = {}
630 let vertices = []
631 let numvertices = 0
632 let sideVertexIndices = new Uint32Array(2 * numsides)
633 let sidevertexindicesindex = 0
634 cag.sides.map(function (side) {
635 [side.vertex0, side.vertex1].map(function (v) {
636 let vertextag = v.getTag()
637 let vertexindex
638 if (!(vertextag in vertexmap)) {
639 vertexindex = numvertices++
640 vertexmap[vertextag] = vertexindex
641 vertices.push(v)
642 } else {
643 vertexindex = vertexmap[vertextag]
644 }
645 sideVertexIndices[sidevertexindicesindex++] = vertexindex
646 })
647 })
648 let vertexData = new Float64Array(numvertices * 2)
649 let verticesArrayIndex = 0
650 vertices.map(function (v) {
651 let pos = v.pos
652 vertexData[verticesArrayIndex++] = pos._x
653 vertexData[verticesArrayIndex++] = pos._y
654 })
655 let result = {
656 'class': 'CAG',
657 sideVertexIndices: sideVertexIndices,
658 vertexData: vertexData
659 }
660 return result
661 },
662
663 getOutlinePaths: function () {
664 let cag = this.canonicalized()
665 let sideTagToSideMap = {}
666 let startVertexTagToSideTagMap = {}
667 cag.sides.map(function (side) {
668 let sidetag = side.getTag()
669 sideTagToSideMap[sidetag] = side
670 let startvertextag = side.vertex0.getTag()
671 if (!(startvertextag in startVertexTagToSideTagMap)) {
672 startVertexTagToSideTagMap[startvertextag] = []
673 }
674 startVertexTagToSideTagMap[startvertextag].push(sidetag)
675 })
676 let paths = []
677 while (true) {
678 let startsidetag = null
679 for (let aVertexTag in startVertexTagToSideTagMap) {
680 let sidesForThisVertex = startVertexTagToSideTagMap[aVertexTag]
681 startsidetag = sidesForThisVertex[0]
682 sidesForThisVertex.splice(0, 1)
683 if (sidesForThisVertex.length === 0) {
684 delete startVertexTagToSideTagMap[aVertexTag]
685 }
686 break
687 }
688 if (startsidetag === null) break // we've had all sides
689 let connectedVertexPoints = []
690 let sidetag = startsidetag
691 let thisside = sideTagToSideMap[sidetag]
692 let startvertextag = thisside.vertex0.getTag()
693 while (true) {
694 connectedVertexPoints.push(thisside.vertex0.pos)
695 let nextvertextag = thisside.vertex1.getTag()
696 if (nextvertextag === startvertextag) break // we've closed the polygon
697 if (!(nextvertextag in startVertexTagToSideTagMap)) {
698 throw new Error('Area is not closed!')
699 }
700 let nextpossiblesidetags = startVertexTagToSideTagMap[nextvertextag]
701 let nextsideindex = -1
702 if (nextpossiblesidetags.length === 1) {
703 nextsideindex = 0
704 } else {
705 // more than one side starting at the same vertex. This means we have
706 // two shapes touching at the same corner
707 let bestangle = null
708 let thisangle = thisside.direction().angleDegrees()
709 for (let sideindex = 0; sideindex < nextpossiblesidetags.length; sideindex++) {
710 let nextpossiblesidetag = nextpossiblesidetags[sideindex]
711 let possibleside = sideTagToSideMap[nextpossiblesidetag]
712 let angle = possibleside.direction().angleDegrees()
713 let angledif = angle - thisangle
714 if (angledif < -180) angledif += 360
715 if (angledif >= 180) angledif -= 360
716 if ((nextsideindex < 0) || (angledif > bestangle)) {
717 nextsideindex = sideindex
718 bestangle = angledif
719 }
720 }
721 }
722 let nextsidetag = nextpossiblesidetags[nextsideindex]
723 nextpossiblesidetags.splice(nextsideindex, 1)
724 if (nextpossiblesidetags.length === 0) {
725 delete startVertexTagToSideTagMap[nextvertextag]
726 }
727 thisside = sideTagToSideMap[nextsidetag]
728 } // inner loop
729 // due to the logic of CAG.fromPoints()
730 // move the first point to the last
731 if (connectedVertexPoints.length > 0) {
732 connectedVertexPoints.push(connectedVertexPoints.shift())
733 }
734 let path = new Path2D(connectedVertexPoints, true)
735 paths.push(path)
736 } // outer loop
737 return paths
738 },
739
740 /*
741 cag = cag.overCutInsideCorners(cutterradius);
742
743 Using a CNC router it's impossible to cut out a true sharp inside corner. The inside corner
744 will be rounded due to the radius of the cutter. This function compensates for this by creating
745 an extra cutout at each inner corner so that the actual cut out shape will be at least as large
746 as needed.
747 */
748 overCutInsideCorners: function (cutterradius) {
749 let cag = this.canonicalized()
750 // for each vertex determine the 'incoming' side and 'outgoing' side:
751 let pointmap = {} // tag => {pos: coord, from: [], to: []}
752 cag.sides.map(function (side) {
753 if (!(side.vertex0.getTag() in pointmap)) {
754 pointmap[side.vertex0.getTag()] = {
755 pos: side.vertex0.pos,
756 from: [],
757 to: []
758 }
759 }
760 pointmap[side.vertex0.getTag()].to.push(side.vertex1.pos)
761 if (!(side.vertex1.getTag() in pointmap)) {
762 pointmap[side.vertex1.getTag()] = {
763 pos: side.vertex1.pos,
764 from: [],
765 to: []
766 }
767 }
768 pointmap[side.vertex1.getTag()].from.push(side.vertex0.pos)
769 })
770 // overcut all sharp corners:
771 let cutouts = []
772 for (let pointtag in pointmap) {
773 let pointobj = pointmap[pointtag]
774 if ((pointobj.from.length === 1) && (pointobj.to.length === 1)) {
775 // ok, 1 incoming side and 1 outgoing side:
776 let fromcoord = pointobj.from[0]
777 let pointcoord = pointobj.pos
778 let tocoord = pointobj.to[0]
779 let v1 = pointcoord.minus(fromcoord).unit()
780 let v2 = tocoord.minus(pointcoord).unit()
781 let crossproduct = v1.cross(v2)
782 let isInnerCorner = (crossproduct < 0.001)
783 if (isInnerCorner) {
784 // yes it's a sharp corner:
785 let alpha = v2.angleRadians() - v1.angleRadians() + Math.PI
786 if (alpha < 0) {
787 alpha += 2 * Math.PI
788 } else if (alpha >= 2 * Math.PI) {
789 alpha -= 2 * Math.PI
790 }
791 let midvector = v2.minus(v1).unit()
792 let circlesegmentangle = 30 / 180 * Math.PI // resolution of the circle: segments of 30 degrees
793 // we need to increase the radius slightly so that our imperfect circle will contain a perfect circle of cutterradius
794 let radiuscorrected = cutterradius / Math.cos(circlesegmentangle / 2)
795 let circlecenter = pointcoord.plus(midvector.times(radiuscorrected))
796 // we don't need to create a full circle; a pie is enough. Find the angles for the pie:
797 let startangle = alpha + midvector.angleRadians()
798 let deltaangle = 2 * (Math.PI - alpha)
799 let numsteps = 2 * Math.ceil(deltaangle / circlesegmentangle / 2) // should be even
800 // build the pie:
801 let points = [circlecenter]
802 for (let i = 0; i <= numsteps; i++) {
803 let angle = startangle + i / numsteps * deltaangle
804 let p = Vector2D.fromAngleRadians(angle).times(radiuscorrected).plus(circlecenter)
805 points.push(p)
806 }
807 cutouts.push(CAG.fromPoints(points))
808 }
809 }
810 }
811 let result = cag.subtract(cutouts)
812 return result
813 }
814}
815
816module.exports = CAG