import { describe, test, expect } from 'bun:test' import type { Vector } from '@open-pencil/core' import { computeDescendantVisualBounds, computeVisualBounds } from '@open-pencil/scene-graph/geometry' function commandsBlobFromPoints(points: Vector[]): Uint8Array { const blob = new Uint8Array(points.length * 9 + 1) const view = new DataView(blob.buffer) let offset = 0 for (const point of points) { blob[offset] = 1 view.setFloat32(offset + 1, point.x, true) view.setFloat32(offset + 5, point.y, true) offset += 9 } blob[offset] = 0 return blob } describe('computeVisualBounds', () => { const idPos = (id: string) => { const map: Record = { r1: { x: 100, y: 200 }, r2: { x: 300, y: 400 }, rotated: { x: 0, y: 0 } } return map[id] ?? { x: 0, y: 0 } } test('empty iterable returns zero rect', () => { expect(computeVisualBounds([], idPos)).toEqual({ x: 0, y: 0, width: 0, height: 0 }) }) test('single axis-aligned node returns its bounds', () => { const result = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) expect(result).toEqual({ x: 100, y: 200, width: 50, height: 60 }) }) test('multiple nodes returns union', () => { const result = computeVisualBounds( [ { id: 'r1', width: 50, height: 60 }, { id: 'r2', width: 30, height: 30 } ], idPos ) expect(result).toEqual({ x: 100, y: 200, width: 230, height: 230 }) }) test('rotated node has expanded bbox', () => { const noRot = computeVisualBounds( [{ id: 'rotated', width: 100, height: 50, rotation: 0 }], idPos ) const rotated = computeVisualBounds( [{ id: 'rotated', width: 100, height: 50, rotation: 45 }], idPos ) // Rotated bbox should be larger expect(rotated.width).toBeGreaterThan(noRot.width) expect(rotated.height).toBeGreaterThan(noRot.height) }) test('strokes expand bounds', () => { const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60, strokes: [] }], idPos) const withStroke = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 10, visible: true, align: 'OUTSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ] } ], idPos ) // OUTSIDE stroke of weight 10 adds 10 to each side expect(withStroke.width).toBe(noStroke.width + 20) expect(withStroke.height).toBe(noStroke.height + 20) }) test('invisible strokes do not expand bounds', () => { const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const invisibleStroke = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 100, visible: false, align: 'OUTSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ] } ], idPos ) expect(invisibleStroke.width).toBe(noStroke.width) expect(invisibleStroke.height).toBe(noStroke.height) }) test('CENTER stroke expands bounds by half weight', () => { const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const centerStroke = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 10, visible: true, align: 'CENTER' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ] } ], idPos ) // CENTER stroke of weight 10 adds 5 to each side expect(centerStroke.width).toBe(noStroke.width + 10) expect(centerStroke.height).toBe(noStroke.height + 10) }) test('INSIDE stroke does not expand bounds', () => { const noStroke = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const insideStroke = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 10, visible: true, align: 'INSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ] } ], idPos ) expect(insideStroke.width).toBe(noStroke.width) expect(insideStroke.height).toBe(noStroke.height) }) test('DROP_SHADOW expands bounds asymmetrically based on offset', () => { const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const withShadow = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, effects: [ { type: 'DROP_SHADOW' as const, visible: true, radius: 10, spread: 0, offset: { x: 5, y: -3 }, color: { r: 0, g: 0, b: 0, a: 0.5 } } ] } ], idPos ) // blurSpread = radius + spread = 10 + 0 = 10 // left expansion: max(0, blurSpread + max(0, -offset.x)) = max(0, 10 + 0) = 10 // right expansion: max(0, blurSpread + max(0, offset.x)) = max(0, 10 + 5) = 15 // top expansion: max(0, blurSpread + max(0, -offset.y)) = max(0, 10 + 3) = 13 // bottom expansion: max(0, blurSpread + max(0, offset.y)) = max(0, 10 + 0) = 10 expect(withShadow.x).toBe(noEffect.x - 10) expect(withShadow.y).toBe(noEffect.y - 13) expect(withShadow.width).toBe(noEffect.width + 10 + 15) expect(withShadow.height).toBe(noEffect.height + 13 + 10) }) test('LAYER_BLUR expands bounds by radius', () => { const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const withBlur = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, effects: [ { type: 'LAYER_BLUR' as const, visible: true, radius: 20, spread: 0, offset: { x: 0, y: 0 }, color: { r: 0, g: 0, b: 0, a: 1 } } ] } ], idPos ) // blurSpread = 20, offset = (0,0) // All sides expand by 20 expect(withBlur.width).toBe(noEffect.width + 40) expect(withBlur.height).toBe(noEffect.height + 40) }) test('invisible effect does not expand bounds', () => { const noEffect = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const invisibleEffect = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, effects: [ { type: 'DROP_SHADOW' as const, visible: false, radius: 100, spread: 50, offset: { x: 200, y: 200 }, color: { r: 0, g: 0, b: 0, a: 1 } } ] } ], idPos ) expect(invisibleEffect).toEqual(noEffect) }) test('combined OUTSIDE stroke and DROP_SHADOW expands bounds correctly', () => { const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const combined = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 5, visible: true, align: 'OUTSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ], effects: [ { type: 'DROP_SHADOW' as const, visible: true, radius: 10, spread: 0, offset: { x: 3, y: 3 }, color: { r: 0, g: 0, b: 0, a: 0.5 } } ] } ], idPos ) // strokeOverflow: OUTSIDE weight=5 → overflow=5 // effectOverflow: blurSpread=10, offset=(3,3) // left = 10 + max(0, -3) = 10 // right = 10 + max(0, 3) = 13 // top = 10 + max(0, -3) = 10 // bottom = 10 + max(0, 3) = 13 // Total left expansion: 5 (stroke) + 10 (effect) = 15 // Total right expansion: 5 (stroke) + 13 (effect) = 18 // Total top expansion: 5 (stroke) + 10 (effect) = 15 // Total bottom expansion: 5 (stroke) + 13 (effect) = 18 expect(combined.x).toBe(noEffects.x - 15) expect(combined.y).toBe(noEffects.y - 15) expect(combined.width).toBe(noEffects.width + 15 + 18) // 50 + 33 = 83 expect(combined.height).toBe(noEffects.height + 15 + 18) // 60 + 33 = 93 }) test('multiple strokes takes maximum overflow', () => { const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const multiStroke = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, strokes: [ { weight: 2, visible: true, align: 'OUTSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 }, { weight: 8, visible: true, align: 'CENTER' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 }, { weight: 4, visible: true, align: 'INSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ] } ], idPos ) // strokeOverflow takes max: OUTSIDE(2)=2, CENTER(4)=4, INSIDE(0)=0 → max=4 expect(multiStroke.width).toBe(noEffects.width + 8) // 4 per side × 2 expect(multiStroke.height).toBe(noEffects.height + 8) }) test('inside stroke geometry does not expand export bounds', () => { const strokeGeometry = [ { commandsBlob: commandsBlobFromPoints([ { x: -1, y: -1 }, { x: 101, y: 51 } ]) } ] const nodes = { inside: { id: 'inside', type: 'COMPONENT', width: 100, height: 50, visible: true, strokes: [ { weight: 1, visible: true, align: 'INSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ], strokeGeometry, childIds: [] }, outside: { id: 'outside', type: 'FRAME', width: 100, height: 50, visible: true, strokes: [ { weight: 1, visible: true, align: 'OUTSIDE' as const, color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1 } ], strokeGeometry, childIds: [] } } const insideBounds = computeDescendantVisualBounds( ['inside'], (id) => nodes[id as keyof typeof nodes], () => ({ x: 10, y: 20 }) ) const outsideBounds = computeDescendantVisualBounds( ['outside'], (id) => nodes[id as keyof typeof nodes], () => ({ x: 10, y: 20 }) ) expect(insideBounds).toEqual({ minX: 10, minY: 20, maxX: 110, maxY: 70 }) expect(outsideBounds).toEqual({ minX: 9, minY: 19, maxX: 111, maxY: 71 }) }) test('nested clipping stops descendants outside the ancestor clip', () => { const nodes = { root: { id: 'root', type: 'FRAME', width: 100, height: 100, visible: true, clipsContent: true, childIds: ['row'] }, row: { id: 'row', type: 'FRAME', width: 100, height: 50, visible: true, clipsContent: true, childIds: ['cell'] }, cell: { id: 'cell', type: 'FRAME', width: 50, height: 50, visible: true, childIds: [] } } const positions: Record = { root: { x: 0, y: 0 }, row: { x: 0, y: 120 }, cell: { x: 0, y: 120 } } const bounds = computeDescendantVisualBounds( ['root'], (id) => nodes[id as keyof typeof nodes], (id) => positions[id as keyof typeof positions] ) expect(bounds).toEqual({ minX: 0, minY: 0, maxX: 100, maxY: 100 }) }) test('multiple effects accumulate directional overflow', () => { const noEffects = computeVisualBounds([{ id: 'r1', width: 50, height: 60 }], idPos) const multiEffect = computeVisualBounds( [ { id: 'r1', width: 50, height: 60, effects: [ { type: 'DROP_SHADOW' as const, visible: true, radius: 5, spread: 0, offset: { x: 10, y: 0 }, color: { r: 0, g: 0, b: 0, a: 1 } }, { type: 'DROP_SHADOW' as const, visible: true, radius: 3, spread: 0, offset: { x: -10, y: 0 }, color: { r: 0, g: 0, b: 0, a: 1 } } ] } ], idPos ) // Effect 1: blurSpread=5, offset=(10,0) → left=5, right=15, top=5, bottom=5 // Effect 2: blurSpread=3, offset=(-10,0) → left=13, right=3, top=3, bottom=3 // After Math.max: left=max(5,13)=13, right=max(15,3)=15, top=max(5,3)=5, bottom=max(5,3)=5 expect(multiEffect.x).toBe(noEffects.x - 13) expect(multiEffect.width).toBe(noEffects.width + 13 + 15) // 50 + 28 = 78 expect(multiEffect.y).toBe(noEffects.y - 5) expect(multiEffect.height).toBe(noEffects.height + 5 + 5) // 60 + 10 = 70 }) })