diff --git a/packages/modeling/src/geometries/geom2/reverse.js b/packages/modeling/src/geometries/geom2/reverse.js index 51704f939..8d9a9b8b0 100644 --- a/packages/modeling/src/geometries/geom2/reverse.js +++ b/packages/modeling/src/geometries/geom2/reverse.js @@ -1,4 +1,5 @@ -import { clone } from './clone.js' +import { create } from './create.js' +import { toOutlines } from './toOutlines.js' /** * Reverses the given geometry so that the outline points are flipped in the opposite order. @@ -11,7 +12,9 @@ import { clone } from './clone.js' * let newGeometry = reverse(geometry) */ export const reverse = (geometry) => { - const reversed = clone(geometry) - reversed.outlines = reversed.outlines.map((outline) => outline.slice().reverse()) + const outlines = toOutlines(geometry) + .map((outline) => outline.slice().reverse()) + const reversed = create(outlines) + if (geometry.color) reversed.color = geometry.color return reversed } diff --git a/packages/modeling/src/geometries/geom2/reverse.test.js b/packages/modeling/src/geometries/geom2/reverse.test.js index ab45f8761..ef4432d6a 100644 --- a/packages/modeling/src/geometries/geom2/reverse.test.js +++ b/packages/modeling/src/geometries/geom2/reverse.test.js @@ -1,5 +1,7 @@ import test from 'ava' +import { colorize } from '../../colors/index.js' + import { create, reverse, toPoints } from './index.js' import { comparePoints, compareVectors } from '../../../test/helpers/index.js' @@ -29,3 +31,10 @@ test('reverse: does not modify input geometry', (t) => { t.true(comparePoints(toPoints(geometry), forward)) t.true(comparePoints(toPoints(another), backward)) }) + +test('reverse: preserves color', (t) => { + const points = [[0, 0], [1, 0], [0, 1]] + const geometry = colorize([1, 0, 0], create([points])) + const reversed = reverse(geometry) + t.deepEqual(reversed.color, [1, 0, 0, 1]) +}) diff --git a/packages/modeling/src/operations/extrusions/extrudeLinear.test.js b/packages/modeling/src/operations/extrusions/extrudeLinear.test.js index 1958d0239..1ac0afa81 100644 --- a/packages/modeling/src/operations/extrusions/extrudeLinear.test.js +++ b/packages/modeling/src/operations/extrusions/extrudeLinear.test.js @@ -4,6 +4,8 @@ import { comparePolygonsAsPoints } from '../../../test/helpers/index.js' import { TAU } from '../../maths/constants.js' +import { colorize } from '../../colors/index.js' + import { geom2, geom3, path2 } from '../../geometries/index.js' import { measureVolume } from '../../measurements/index.js' @@ -37,6 +39,17 @@ test('extrudeLinear (defaults)', (t) => { t.true(comparePolygonsAsPoints(pts, exp)) }) +test('extrudeLinear: preserves color', (t) => { + const redSquare = colorize([1, 0, 0], square()) + const extruded = extrudeLinear({ }, redSquare) + t.deepEqual(extruded.color, [1, 0, 0, 1]) + + // one red, one blue + const out = extrudeLinear({ }, [redSquare, square()]) + t.deepEqual(out[0].color, [1, 0, 0, 1]) + t.is(out[1].color, undefined) +}) + test('extrudeLinear (no twist)', (t) => { const geometry2 = square({ size: 10 }) diff --git a/packages/modeling/src/operations/extrusions/extrudeLinearGeom2.js b/packages/modeling/src/operations/extrusions/extrudeLinearGeom2.js index 06083e3dd..fa3590f3e 100644 --- a/packages/modeling/src/operations/extrusions/extrudeLinearGeom2.js +++ b/packages/modeling/src/operations/extrusions/extrudeLinearGeom2.js @@ -53,5 +53,7 @@ export const extrudeLinearGeom2 = (options, geometry) => { repair, callback: createTwist } - return extrudeFromSlices(options, baseSlice) + const output = extrudeFromSlices(options, baseSlice) + if (geometry.color) output.color = geometry.color + return output } diff --git a/packages/modeling/src/operations/extrusions/extrudeLinearPath2.js b/packages/modeling/src/operations/extrusions/extrudeLinearPath2.js index 969356e5e..d648a4b3d 100644 --- a/packages/modeling/src/operations/extrusions/extrudeLinearPath2.js +++ b/packages/modeling/src/operations/extrusions/extrudeLinearPath2.js @@ -18,5 +18,6 @@ export const extrudeLinearPath2 = (options, geometry) => { // Convert path2 to geom2 const points = path2.toPoints(geometry) const geometry2 = geom2.create([points]) + if (geometry.color) geometry2.color = geometry.color return extrudeLinearGeom2(options, geometry2) } diff --git a/packages/modeling/src/operations/extrusions/extrudeRotate.js b/packages/modeling/src/operations/extrusions/extrudeRotate.js index 5a2508872..c5bbcb986 100644 --- a/packages/modeling/src/operations/extrusions/extrudeRotate.js +++ b/packages/modeling/src/operations/extrusions/extrudeRotate.js @@ -58,7 +58,8 @@ export const extrudeRotate = (options, geometry) => { // convert geometry to an array of sides, easier to deal with let shapeSides = geom2.toSides(geometry) - if (shapeSides.length === 0) throw new Error('the given geometry cannot be empty') + if (shapeSides.length === 0) return geometry + let sliceGeometry = geometry // determine if the extrusion can be computed in the first place // ie all the points have to be either x > 0 or x < 0 @@ -87,8 +88,8 @@ export const extrudeRotate = (options, geometry) => { return [point0, point1] }) // recreate the geometry from the (-) capped points - geometry = geom2.reverse(geom2.fromSides(shapeSides)) - geometry = mirrorX(geometry) + sliceGeometry = geom2.reverse(geom2.fromSides(shapeSides)) + sliceGeometry = mirrorX(sliceGeometry) } else if (pointsWithPositiveX.length >= pointsWithNegativeX.length) { shapeSides = shapeSides.map((side) => { let point0 = side[0] @@ -98,13 +99,13 @@ export const extrudeRotate = (options, geometry) => { return [point0, point1] }) // recreate the geometry from the (+) capped points - geometry = geom2.fromSides(shapeSides) + sliceGeometry = geom2.fromSides(shapeSides) } } const rotationPerSlice = totalRotation / segments const isCapped = Math.abs(totalRotation) < TAU - let baseSlice = slice.fromGeom2(geometry) + let baseSlice = slice.fromGeom2(sliceGeometry) baseSlice = slice.reverse(baseSlice) const matrix = mat4.create() @@ -126,5 +127,7 @@ export const extrudeRotate = (options, geometry) => { close: !isCapped, callback: createSlice } - return extrudeFromSlices(options, baseSlice) + const output = extrudeFromSlices(options, baseSlice) + if (geometry.color) output.color = geometry.color + return output } diff --git a/packages/modeling/src/operations/extrusions/extrudeRotate.test.js b/packages/modeling/src/operations/extrusions/extrudeRotate.test.js index 2fd48d456..f9c7a1412 100644 --- a/packages/modeling/src/operations/extrusions/extrudeRotate.test.js +++ b/packages/modeling/src/operations/extrusions/extrudeRotate.test.js @@ -4,10 +4,14 @@ import { comparePoints, comparePolygonsAsPoints } from '../../../test/helpers/in import { TAU } from '../../maths/constants.js' +import { colorize } from '../../colors/index.js' + import { geom2, geom3 } from '../../geometries/index.js' import { measureVolume } from '../../measurements/index.js' +import { square } from '../../primitives/index.js' + import { extrudeRotate } from './index.js' test('extrudeRotate: (defaults) extruding of a geom2 produces an expected geom3', (t) => { @@ -20,6 +24,12 @@ test('extrudeRotate: (defaults) extruding of a geom2 produces an expected geom3' t.is(pts.length, 96) }) +test('extrudeRotate: preserves color', (t) => { + const red = colorize([1, 0, 0], square()) + const extruded = extrudeRotate({ }, red) + t.deepEqual(extruded.color, [1, 0, 0, 1]) +}) + test('extrudeRotate: (angle) extruding of a geom2 produces an expected geom3', (t) => { const geometry2 = geom2.create([[[10, 8], [10, -8], [26, -8], [26, 8]]]) diff --git a/packages/modeling/src/operations/extrusions/project.js b/packages/modeling/src/operations/extrusions/project.js index 22f445864..9c40ec7fd 100644 --- a/packages/modeling/src/operations/extrusions/project.js +++ b/packages/modeling/src/operations/extrusions/project.js @@ -56,7 +56,9 @@ const projectGeom3 = (options, geometry) => { return geom2.create([cloned]) }) - return unionGeom2(projGeoms) + const output = unionGeom2(projGeoms) + if (geometry.color) output.color = geometry.color + return output } /** diff --git a/packages/modeling/src/operations/extrusions/project.test.js b/packages/modeling/src/operations/extrusions/project.test.js index 53915328f..8333ec308 100644 --- a/packages/modeling/src/operations/extrusions/project.test.js +++ b/packages/modeling/src/operations/extrusions/project.test.js @@ -2,6 +2,8 @@ import test from 'ava' import { comparePoints } from '../../../test/helpers/index.js' +import { colorize } from '../../colors/index.js' + import { geom2, geom3 } from '../../geometries/index.js' import { measureArea } from '../../measurements/index.js' @@ -139,3 +141,16 @@ test('project torus (martinez issue #155)', (t) => { t.notThrows(() => geom2.validate(result)) t.is(measureArea(result), 21.15545050788201) }) + +test('project: preserves color', (t) => { + const redCube = colorize([1, 0, 0], cube()) + const result = project({ }, redCube) + t.deepEqual(result.color, [1, 0, 0, 1]) +}) + +test('project: empty geometry', (t) => { + const obj = geom3.create() + const result = project({ }, obj) + t.notThrows(() => geom2.validate(result)) + t.is(measureArea(result), 0) +}) diff --git a/packages/modeling/src/operations/hulls/hull.test.js b/packages/modeling/src/operations/hulls/hull.test.js index c151a0e86..9b6a939ff 100644 --- a/packages/modeling/src/operations/hulls/hull.test.js +++ b/packages/modeling/src/operations/hulls/hull.test.js @@ -1,9 +1,8 @@ import test from 'ava' import { geom2, geom3, path2 } from '../../geometries/index.js' - +import { measureArea } from '../../measurements/measureArea.js' import { sphere, cuboid, ellipsoid } from '../../primitives/index.js' - import { center } from '../transforms/index.js' import { hull } from './index.js' @@ -15,15 +14,15 @@ test('hull (single, geom2)', (t) => { let obs = hull(geometry) let pts = geom2.toPoints(obs) - - t.notThrows(() => geom2.validate(geometry)) + t.notThrows(() => geom2.validate(obs)) + t.is(measureArea(obs), 0) t.is(pts.length, 0) geometry = geom2.create([[[5, 5], [-5, 5], [-5, -5], [5, -5]]]) obs = hull(geometry) pts = geom2.toPoints(obs) - - t.notThrows(() => geom2.validate(geometry)) + t.notThrows(() => geom2.validate(obs)) + t.is(measureArea(obs), 100) t.is(pts.length, 4) // convex C shape @@ -41,8 +40,7 @@ test('hull (single, geom2)', (t) => { ]]) obs = hull(geometry) pts = geom2.toPoints(obs) - - t.notThrows(() => geom2.validate(geometry)) + t.notThrows(() => geom2.validate(obs)) t.is(pts.length, 7) }) diff --git a/packages/modeling/src/operations/hulls/hullChain.js b/packages/modeling/src/operations/hulls/hullChain.js index ba1240020..80d679b6c 100644 --- a/packages/modeling/src/operations/hulls/hullChain.js +++ b/packages/modeling/src/operations/hulls/hullChain.js @@ -31,9 +31,11 @@ import { hull } from './hull.js' */ export const hullChain = (...geometries) => { geometries = flatten(geometries) - if (geometries.length < 2) throw new Error('wrong number of arguments') - const hulls = [] + + if (geometries.length === 0) throw new Error('wrong number of arguments') + if (geometries.length === 1) hulls.push(geometries[0]) + for (let i = 1; i < geometries.length; i++) { hulls.push(hull(geometries[i - 1], geometries[i])) } diff --git a/packages/modeling/src/operations/hulls/hullChain.test.js b/packages/modeling/src/operations/hulls/hullChain.test.js index b72e2055f..206d915d4 100644 --- a/packages/modeling/src/operations/hulls/hullChain.test.js +++ b/packages/modeling/src/operations/hulls/hullChain.test.js @@ -1,9 +1,18 @@ import test from 'ava' import { geom2, geom3 } from '../../geometries/index.js' +import { measureArea } from '../../measurements/measureArea.js' +import { square } from '../../primitives/square.js' import { hullChain } from './index.js' +test('hullChain: hullChain single geometry', (t) => { + const result = hullChain([ square({ size: 1 }) ]) + t.notThrows(() => geom2.validate(result)) + t.is(measureArea(result), 1) + t.is(geom2.toPoints(result).length, 4) +}) + test('hullChain (two, geom2)', (t) => { const geometry1 = geom2.create([[[6, 6], [3, 6], [3, 3], [6, 3]]]) const geometry2 = geom2.create([[[-6, -6], [-9, -6], [-9, -9], [-6, -9]]]) diff --git a/packages/modeling/src/operations/offsets/offsetFromPoints.js b/packages/modeling/src/operations/offsets/offsetFromPoints.js index 3d0a1b2d2..03826b918 100644 --- a/packages/modeling/src/operations/offsets/offsetFromPoints.js +++ b/packages/modeling/src/operations/offsets/offsetFromPoints.js @@ -27,6 +27,7 @@ export const offsetFromPoints = (options, points) => { let { delta, corners, closed, segments } = Object.assign({ }, defaults, options) if (Math.abs(delta) < EPS) return points + if (points.length < 2) return points let rotation = options.closed ? area(points) : 1.0 // + counter clockwise, - clockwise if (rotation === 0) rotation = 1.0 diff --git a/packages/modeling/src/operations/offsets/offsetFromPoints.test.js b/packages/modeling/src/operations/offsets/offsetFromPoints.test.js index 12f996747..88b364f26 100644 --- a/packages/modeling/src/operations/offsets/offsetFromPoints.test.js +++ b/packages/modeling/src/operations/offsets/offsetFromPoints.test.js @@ -7,6 +7,16 @@ import { offsetFromPoints } from './index.js' import { comparePoints } from '../../../test/helpers/index.js' +test('offset: offset empty points', (t) => { + const offsetPoints = offsetFromPoints({ }, []) + t.is(offsetPoints.length, 0) +}) + +test('offset: offset single point', (t) => { + const offsetPoints = offsetFromPoints({ corners: 'round' }, [[2, 2]]) + t.is(offsetPoints.length, 1) +}) + test('offset: offsetting a straight line produces expected geometry', (t) => { const points = [[0, 0], [0, 10]] diff --git a/packages/modeling/src/operations/offsets/offsetGeom2.js b/packages/modeling/src/operations/offsets/offsetGeom2.js index 141ae1b52..95ab845c2 100644 --- a/packages/modeling/src/operations/offsets/offsetGeom2.js +++ b/packages/modeling/src/operations/offsets/offsetGeom2.js @@ -46,7 +46,10 @@ export const offsetGeom2 = (options, geometry) => { } return offsetFromPoints(options, outline) }) + // TODO: union outlines that expanded into each other // create a composite geometry from the new outlines - return geom2.create(newOutlines) + const output = geom2.create(newOutlines) + if (geometry.color) output.color = geometry.color + return output } diff --git a/packages/modeling/src/operations/offsets/offsetGeom2.test.js b/packages/modeling/src/operations/offsets/offsetGeom2.test.js index 87bd550b5..9c47f4022 100644 --- a/packages/modeling/src/operations/offsets/offsetGeom2.test.js +++ b/packages/modeling/src/operations/offsets/offsetGeom2.test.js @@ -1,9 +1,8 @@ import test from 'ava' +import { colorize } from '../../colors/index.js' import { geom2 } from '../../geometries/index.js' - import { measureArea } from '../../measurements/index.js' - import { roundedRectangle, square } from '../../primitives/index.js' import { offset } from './index.js' @@ -12,12 +11,10 @@ import { comparePoints } from '../../../test/helpers/index.js' test('offset: offset an empty geom2', (t) => { const empty = geom2.create() - const obs = offset({ delta: 1 }, empty) - const pts = geom2.toPoints(obs) - const exp = [] - t.notThrows(() => geom2.validate(obs)) - t.is(measureArea(obs), 0) - t.true(comparePoints(pts, exp)) + const result = offset({ delta: 1 }, empty) + t.notThrows(() => geom2.validate(result)) + t.is(measureArea(result), 0) + t.is(geom2.toPoints(result).length, 0) }) test('offset: offset option validation', (t) => { @@ -38,6 +35,12 @@ test('offset: offset option validation', (t) => { t.throws(() => offset({ corners: 'fluffy' }, empty), { message: 'corners must be "edge", "chamfer", or "round"' }) }) +test('offset: offset geom2 preserves color', (t) => { + const geometry = colorize([1, 0, 0], square({ })) + const result = offset({ }, geometry) + t.deepEqual(result.color, [1, 0, 0, 1]) +}) + test('offset: offset of a geom2 produces expected changes to points', (t) => { const geometry = square({ size: 16 }) diff --git a/packages/modeling/src/operations/offsets/offsetGeom3.js b/packages/modeling/src/operations/offsets/offsetGeom3.js index 194cc37bc..b8f53d4b3 100644 --- a/packages/modeling/src/operations/offsets/offsetGeom3.js +++ b/packages/modeling/src/operations/offsets/offsetGeom3.js @@ -25,10 +25,9 @@ export const offsetGeom3 = (options, geometry) => { throw new Error('corners must be "round" for 3D geometries') } - const polygons = geom3.toPolygons(geometry) - if (polygons.length === 0) throw new Error('the given geometry cannot be empty') - options = { delta, corners, segments } const expanded = offsetShell(options, geometry) - return union(geometry, expanded) + const output = union(geometry, expanded) + if (geometry.color) output.color = geometry.color + return output } diff --git a/packages/modeling/src/operations/offsets/offsetGeom3.test.js b/packages/modeling/src/operations/offsets/offsetGeom3.test.js index 68b3b08b8..34470d151 100644 --- a/packages/modeling/src/operations/offsets/offsetGeom3.test.js +++ b/packages/modeling/src/operations/offsets/offsetGeom3.test.js @@ -2,12 +2,28 @@ import test from 'ava' import { comparePoints } from '../../../test/helpers/index.js' +import { colorize } from '../../colors/index.js' import { geom3, poly3 } from '../../geometries/index.js' - -import { sphere } from '../../primitives/index.js' +import { measureVolume } from '../../measurements/index.js' +import { cube, sphere } from '../../primitives/index.js' import { offset } from './index.js' +test('offset: offset empty geom3', (t) => { + const geometry = geom3.create() + const result = offset({ }, geometry) + t.notThrows(() => geom3.validate(result)) + t.is(measureVolume(result), 0) + t.is(geom3.toPolygons(result).length, 0) + t.is(geom3.toPoints(result).length, 0) +}) + +test('offset: offset geom3 preserves color', (t) => { + const geometry = colorize([1, 0, 0], cube({ })) + const result = offset({ }, geometry) + t.deepEqual(result.color, [1, 0, 0, 1]) +}) + test('offset: offset of a geom3 produces expected changes to polygons', (t) => { const polygonsAsPoints = [ [[-5, -5, -5], [-5, -5, 15], [-5, 15, 15], [-5, 15, -5]], diff --git a/packages/modeling/src/operations/offsets/offsetPath2.js b/packages/modeling/src/operations/offsets/offsetPath2.js index bfba5a2ef..f4148ed11 100644 --- a/packages/modeling/src/operations/offsets/offsetPath2.js +++ b/packages/modeling/src/operations/offsets/offsetPath2.js @@ -4,11 +4,13 @@ import * as vec2 from '../../maths/vec2/index.js' import * as geom2 from '../../geometries/geom2/index.js' import * as path2 from '../../geometries/path2/index.js' +import { circle } from '../../primitives/circle.js' import { offsetFromPoints } from './offsetFromPoints.js' -const createGeometryFromClosedOffsets = (paths) => { +const createGeometryFromClosedPath = (paths) => { let { external, internal } = paths + if (external.length < 2) return geom2.create() if (area(external) < 0) { external = external.reverse() } else { @@ -17,8 +19,10 @@ const createGeometryFromClosedOffsets = (paths) => { return geom2.create([external, internal]) } -const createGeometryFromExpandedOpenPath = (paths, segments, corners, delta) => { +const createGeometryFromOpenPath = (paths, segments, corners, delta) => { const { points, external, internal } = paths + if (points.length === 0) return geom2.create() + if (points.length === 1) return circle({ center: points[0], radius: delta }) const capSegments = Math.floor(segments / 2) // rotation is 180 degrees const e2iCap = [] const i2eCap = [] @@ -74,17 +78,16 @@ export const offsetPath2 = (options, geometry) => { const closed = geometry.isClosed const points = path2.toPoints(geometry) - if (points.length === 0) throw new Error('the given geometry cannot be empty') const paths = { - points: points, + points, external: offsetFromPoints({ delta, corners, segments, closed }, points), internal: offsetFromPoints({ delta: -delta, corners, segments, closed }, points) } - if (geometry.isClosed) { - return createGeometryFromClosedOffsets(paths) - } else { - return createGeometryFromExpandedOpenPath(paths, segments, corners, delta) - } + const output = geometry.isClosed ? + createGeometryFromClosedPath(paths) : + createGeometryFromOpenPath(paths, segments, corners, delta) + if (geometry.color) output.color = geometry.color + return output } diff --git a/packages/modeling/src/operations/offsets/offsetPath2.test.js b/packages/modeling/src/operations/offsets/offsetPath2.test.js index 8048f48b3..8f23605c7 100644 --- a/packages/modeling/src/operations/offsets/offsetPath2.test.js +++ b/packages/modeling/src/operations/offsets/offsetPath2.test.js @@ -2,14 +2,44 @@ import test from 'ava' import { comparePoints, nearlyEqual } from '../../../test/helpers/index.js' -import { geom2, geom3, path2 } from '../../geometries/index.js' -import { measureBoundingBox } from '../../measurements/index.js' +import { colorize } from '../../colors/index.js' +import { geom2, path2 } from '../../geometries/index.js' +import { measureArea, measureBoundingBox } from '../../measurements/index.js' import { area } from '../../maths/utils/index.js' import { TAU } from '../../maths/constants.js' -import { sphere, square } from '../../primitives/index.js' import { offset } from './index.js' +test('offset: offset empty path2', (t) => { + const geometry = path2.create() + const result = offset({ corners: 'round' }, geometry) + t.notThrows(() => geom2.validate(result)) + t.is(measureArea(result), 0) + t.is(geom2.toPoints(result).length, 0) +}) + +test('offset: offset empty path2 closed', (t) => { + const geometry = path2.fromPoints({ closed: true }, []) + const result = offset({ }, geometry) + t.notThrows(() => geom2.validate(result)) + t.is(measureArea(result), 0) + t.is(geom2.toPoints(result).length, 0) +}) + +test('offset: offset single point path2 round', (t) => { + const geometry = path2.create([[2, 2]]) + const result = offset({ delta: 1, corners: 'round', segments: 16 }, geometry) + t.notThrows(() => geom2.validate(result)) + nearlyEqual(t, measureArea(result), 3.12, 0.01) + t.is(geom2.toPoints(result).length, 32) +}) + +test('offset: offset path2 preserves color', (t) => { + const geometry = colorize([1, 0, 0], path2.create()) + const result = offset({ }, geometry) + t.deepEqual(result.color, [1, 0, 0, 1]) +}) + test('offset: edge-expanding a straight line produces rectangle', (t) => { const points = [[0, 0], [0, 10]] const linePath2 = path2.fromPoints({ closed: false }, points) diff --git a/packages/modeling/src/operations/offsets/offsetShell.js b/packages/modeling/src/operations/offsets/offsetShell.js index a0a3fb437..f9b568ad1 100644 --- a/packages/modeling/src/operations/offsets/offsetShell.js +++ b/packages/modeling/src/operations/offsets/offsetShell.js @@ -83,7 +83,7 @@ export const offsetShell = (options, geometry) => { // - add the plane to the unique vertex map // - add the plane to the unique edge map const polygons = geom3.toPolygons(geometry) - polygons.forEach((polygon, index) => { + polygons.forEach((polygon) => { const extrudeVector = vec3.scale(vec3.create(), poly3.plane(polygon), 2 * delta) const translatedPolygon = poly3.transform(mat4.fromTranslation(mat4.create(), vec3.scale(vec3.create(), extrudeVector, -0.5)), polygon) const extrudedFace = extrudePolygon(extrudeVector, translatedPolygon)