@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
484 lines (407 loc) • 12.3 kB
text/typescript
import { Mat } from '../Mat'
import { Vec, VecLike } from '../Vec'
import { Edge2d } from './Edge2d'
import { Geometry2dFilters } from './Geometry2d'
import { Group2d } from './Group2d'
import { Rectangle2d } from './Rectangle2d'
describe('TransformedGeometry2d', () => {
const rect = new Rectangle2d({ width: 100, height: 50, isFilled: true }).transform(
Mat.Translate(50, 100).scale(2, 2)
)
test('getVertices', () => {
expect(rect.getVertices(Geometry2dFilters.INCLUDE_ALL)).toMatchObject([
{ x: 50, y: 100, z: 1 },
{ x: 250, y: 100, z: 1 },
{ x: 250, y: 200, z: 1 },
{ x: 50, y: 200, z: 1 },
])
})
test('nearestPoint', () => {
expectApproxMatch(rect.nearestPoint(new Vec(100, 300)), { x: 100, y: 200 })
})
test('hitTestPoint', () => {
// basic case - no margin / scaling:
expect(rect.hitTestPoint(new Vec(0, 0), 0, true)).toBe(false)
expect(rect.hitTestPoint(new Vec(50, 100), 0, true)).toBe(true)
expect(rect.hitTestPoint(new Vec(49, 100), 0, true)).toBe(false)
expect(rect.hitTestPoint(new Vec(100, 150), 0, true)).toBe(true)
// with margin:
// move away 8 px and test with 10px margin:
expect(rect.hitTestPoint(new Vec(42, 100), 10, true)).toBe(true)
// move away 12 px and test with 10px margin:
expect(rect.hitTestPoint(new Vec(38, 100), 10, true)).toBe(false)
})
})
describe('excludeFromShapeBounds', () => {
test('simple geometry with excludeFromShapeBounds flag', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
excludeFromShapeBounds: true,
})
// The bounds should still be calculated normally for simple geometry
const bounds = rect.bounds
expect(bounds.width).toBe(100)
expect(bounds.height).toBe(50)
expect(bounds.x).toBe(0)
expect(bounds.y).toBe(0)
})
test('group with excluded child geometry', () => {
const mainRect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
})
const excludedRect = new Rectangle2d({
width: 200,
height: 100,
isFilled: true,
excludeFromShapeBounds: true,
})
const group = new Group2d({
children: [mainRect, excludedRect],
})
// The bounds should only include the non-excluded rectangle
const bounds = group.bounds
expect(bounds.width).toBe(100) // Only the main rectangle width
expect(bounds.height).toBe(50) // Only the main rectangle height
expect(bounds.x).toBe(0)
expect(bounds.y).toBe(0)
})
test('group with multiple excluded children', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const rect2 = new Rectangle2d({
width: 100,
height: 30,
isFilled: true,
})
const excludedRect1 = new Rectangle2d({
width: 200,
height: 200,
isFilled: true,
excludeFromShapeBounds: true,
})
const excludedRect2 = new Rectangle2d({
width: 300,
height: 300,
isFilled: true,
excludeFromShapeBounds: true,
})
const group = new Group2d({
children: [rect1, excludedRect1, rect2, excludedRect2],
})
// The bounds should include both non-excluded rectangles
const bounds = group.bounds
expect(bounds.width).toBe(100) // Width of rect2 (larger of the two)
expect(bounds.height).toBe(50) // Height of rect1 (larger of the two)
expect(bounds.x).toBe(0)
expect(bounds.y).toBe(0)
})
test('group with all children excluded', () => {
const excludedRect1 = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
excludeFromShapeBounds: true,
})
const excludedRect2 = new Rectangle2d({
width: 200,
height: 100,
isFilled: true,
excludeFromShapeBounds: true,
})
const group = new Group2d({
children: [excludedRect1, excludedRect2],
})
// The bounds should be empty when all children are excluded
const bounds = group.bounds
expect(bounds.width).toBe(0)
expect(bounds.height).toBe(0)
expect(bounds.x).toBe(0)
expect(bounds.y).toBe(0)
})
test('nested groups with excluded geometry', () => {
const innerRect = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const excludedRect = new Rectangle2d({
width: 200,
height: 200,
isFilled: true,
excludeFromShapeBounds: true,
})
const innerGroup = new Group2d({
children: [innerRect, excludedRect],
})
const outerRect = new Rectangle2d({
width: 100,
height: 30,
isFilled: true,
})
const outerGroup = new Group2d({
children: [innerGroup, outerRect],
})
// The bounds should include both the inner group (without excluded rect) and outer rect
const bounds = outerGroup.bounds
expect(bounds.width).toBe(100) // Width of outerRect (larger)
expect(bounds.height).toBe(50) // Height of innerRect (larger)
expect(bounds.x).toBe(0)
expect(bounds.y).toBe(0)
})
test('bounds calculation with transformed geometry', () => {
const rect = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
}).transform(Mat.Translate(100, 100))
const excludedRect = new Rectangle2d({
width: 200,
height: 200,
isFilled: true,
excludeFromShapeBounds: true,
}).transform(Mat.Translate(50, 50))
const group = new Group2d({
children: [rect, excludedRect],
})
// The bounds should only include the non-excluded rectangle
const bounds = group.bounds
// Verify that the excluded rectangle doesn't affect the bounds
// The bounds should be smaller than if the excluded rect was included
expect(bounds.width).toBeLessThan(200) // Should not include the excluded rect's width
expect(bounds.height).toBeLessThan(200) // Should not include the excluded rect's height
// The bounds should not be empty
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
})
})
describe('getBoundsVertices', () => {
test('basic geometry returns vertices when not excluded from bounds', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
})
const boundsVertices = rect.getBoundsVertices()
const vertices = rect.getVertices()
expect(boundsVertices).toEqual(vertices)
expect(boundsVertices.length).toBe(4)
expect(boundsVertices).toMatchObject([
{ x: 0, y: 0, z: 1 },
{ x: 100, y: 0, z: 1 },
{ x: 100, y: 50, z: 1 },
{ x: 0, y: 50, z: 1 },
])
})
test('geometry excluded from shape bounds returns empty array', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
excludeFromShapeBounds: true,
})
const boundsVertices = rect.getBoundsVertices()
expect(boundsVertices).toEqual([])
})
test('cached boundsVertices property', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
})
// Access the cached property multiple times
const boundsVertices1 = rect.boundsVertices
const boundsVertices2 = rect.boundsVertices
// Should return the same reference (cached)
expect(boundsVertices1).toBe(boundsVertices2)
expect(boundsVertices1.length).toBe(4)
})
})
describe('TransformedGeometry2d getBoundsVertices', () => {
test('transforms bounds vertices correctly', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
})
const transformed = rect.transform(Mat.Translate(50, 100).scale(2, 2))
const boundsVertices = transformed.getBoundsVertices()
expect(boundsVertices).toMatchObject([
{ x: 50, y: 100, z: 1 },
{ x: 250, y: 100, z: 1 },
{ x: 250, y: 200, z: 1 },
{ x: 50, y: 200, z: 1 },
])
})
test('transforms empty bounds vertices for excluded geometry', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
excludeFromShapeBounds: true,
})
const transformed = rect.transform(Mat.Translate(50, 100))
const boundsVertices = transformed.getBoundsVertices()
expect(boundsVertices).toEqual([])
})
test('nested transform preserves bounds vertices behavior', () => {
const rect = new Rectangle2d({
width: 100,
height: 50,
isFilled: true,
})
const transformed1 = rect.transform(Mat.Translate(10, 20))
const transformed2 = transformed1.transform(Mat.Scale(2, 2))
const boundsVertices = transformed2.getBoundsVertices()
expect(boundsVertices).toMatchObject([
{ x: 20, y: 40, z: 1 },
{ x: 220, y: 40, z: 1 },
{ x: 220, y: 140, z: 1 },
{ x: 20, y: 140, z: 1 },
])
})
})
describe('Group2d getBoundsVertices', () => {
test('flattens children bounds vertices', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const rect2 = new Rectangle2d({
width: 30,
height: 30,
isFilled: true,
}).transform(Mat.Translate(60, 60))
const group = new Group2d({
children: [rect1, rect2],
})
const boundsVertices = group.getBoundsVertices()
// Should include all vertices from both rectangles
expect(boundsVertices.length).toBe(8) // 4 vertices from each rectangle
// Check that we have vertices from both rectangles
expect(boundsVertices).toEqual(
expect.arrayContaining([
expect.objectContaining({ x: 0, y: 0 }), // rect1 vertices
expect.objectContaining({ x: 50, y: 0 }),
expect.objectContaining({ x: 50, y: 50 }),
expect.objectContaining({ x: 0, y: 50 }),
expect.objectContaining({ x: 60, y: 60 }), // rect2 vertices
expect.objectContaining({ x: 90, y: 60 }),
expect.objectContaining({ x: 90, y: 90 }),
expect.objectContaining({ x: 60, y: 90 }),
])
)
})
test('excludes children marked as excluded from bounds', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const rect2 = new Rectangle2d({
width: 100,
height: 100,
isFilled: true,
excludeFromShapeBounds: true,
})
const group = new Group2d({
children: [rect1, rect2],
})
const boundsVertices = group.getBoundsVertices()
// Should only include vertices from rect1, not rect2
expect(boundsVertices.length).toBe(4) // Only rect1's 4 vertices
expect(boundsVertices).toMatchObject([
{ x: 0, y: 0, z: 1 },
{ x: 50, y: 0, z: 1 },
{ x: 50, y: 50, z: 1 },
{ x: 0, y: 50, z: 1 },
])
})
test('returns empty array when group itself is excluded from bounds', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const rect2 = new Rectangle2d({
width: 30,
height: 30,
isFilled: true,
})
const group = new Group2d({
children: [rect1, rect2],
excludeFromShapeBounds: true,
})
const boundsVertices = group.getBoundsVertices()
expect(boundsVertices).toEqual([])
})
test('handles nested groups correctly', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
})
const rect2 = new Rectangle2d({
width: 30,
height: 30,
isFilled: true,
})
const innerGroup = new Group2d({
children: [rect2],
})
const outerGroup = new Group2d({
children: [rect1, innerGroup],
})
const boundsVertices = outerGroup.getBoundsVertices()
// Should include vertices from both rectangles
expect(boundsVertices.length).toBe(8) // 4 vertices from each rectangle
})
test('handles all children excluded from bounds', () => {
const rect1 = new Rectangle2d({
width: 50,
height: 50,
isFilled: true,
excludeFromShapeBounds: true,
})
const rect2 = new Rectangle2d({
width: 30,
height: 30,
isFilled: true,
excludeFromShapeBounds: true,
})
const group = new Group2d({
children: [rect1, rect2],
})
const boundsVertices = group.getBoundsVertices()
expect(boundsVertices).toEqual([])
})
})
describe('interpolateAlongEdge', () => {
it('returns vertex when segment has zero length', () => {
const edge = new Edge2d({ start: new Vec(5, 5), end: new Vec(5, 5) })
const result = edge.interpolateAlongEdge(0.5)
expect(result.x).toBe(5)
expect(result.y).toBe(5)
expect(Number.isFinite(result.x)).toBe(true)
expect(Number.isFinite(result.y)).toBe(true)
})
})
describe('uninterpolateAlongEdge', () => {
it('returns 0 when geometry has zero length', () => {
const edge = new Edge2d({ start: new Vec(5, 5), end: new Vec(5, 5) })
const result = edge.uninterpolateAlongEdge(new Vec(5, 5))
expect(result).toBe(0)
expect(Number.isFinite(result)).toBe(true)
})
})
function expectApproxMatch(a: VecLike, b: VecLike) {
expect(a.x).toBeCloseTo(b.x, 0.0001)
expect(a.y).toBeCloseTo(b.y, 0.0001)
}