openpencil/tests/engine/geometry/clip-polygon.test.ts
Joseph Cumines ad22513bc7 fix(tools): address CodeRabbit review on analyze_overlaps
- Export OverlapScope, AnalyzeOverlapsSummary, and OverlapIntersection
  from the analyze barrel so consumers don't deep-import.
- Expand the local rectangle by stroke overflow before transforming
  through the world matrix, so stroked rotated nodes get their true
  rotated footprint instead of an underestimated canvas-space expansion.
- Add clipPolygon to geometry and preserve the clipped polygon through
  the full clip chain in computeNodeBounds, so a later outer clip can't
  reintroduce corners an inner clip already removed.
- Cap returned overlaps to an empty list when limit is 0 or negative;
  summary totals still reflect the complete result set.
- Trim whitespace from scope and severity inputs so " major " resolves.
- Add geometry and overlap tests covering each fix.
2026-06-19 11:38:38 +10:00

108 lines
3.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, test } from 'bun:test'
import type { Vector } from '@open-pencil/core'
import { clipBoundsToPolygon, clipPolygon } from '#core/geometry'
describe('clipPolygon', () => {
test('returns the subject unchanged when the clip has fewer than 3 corners', () => {
const subject: Vector[] = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
]
expect(clipPolygon(subject, [])).toBe(subject)
expect(
clipPolygon(subject, [
{ x: 0, y: 0 },
{ x: 1, y: 1 }
])
).toBe(subject)
})
test('returns null when the subject is fully outside the clip', () => {
const subject: Vector[] = [
{ x: 100, y: 100 },
{ x: 110, y: 100 },
{ x: 110, y: 110 },
{ x: 100, y: 110 }
]
const clip: Vector[] = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 10, y: 10 },
{ x: 0, y: 10 }
]
expect(clipPolygon(subject, clip)).toBeNull()
})
test('preserves the clipped polygon across a chain so a later clip excludes corners the inner clip removed', () => {
// Subject: the full [0,100]² square.
const subject: Vector[] = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
{ x: 0, y: 100 }
]
// Inner clip D1: the diamond inscribed in the square (corners at the edge
// midpoints). It removes the four square corners, including (0,0).
const d1: Vector[] = [
{ x: 50, y: 0 },
{ x: 100, y: 50 },
{ x: 50, y: 100 },
{ x: 0, y: 50 }
]
// Outer clip D2: a small square [0,30]² covering the (0,0) corner that D1
// already removed. If the chain collapsed D1's result to its AABB ([0,100]²,
// which re-adds the corner) before applying D2, the corner would survive.
// Preserving the polygon keeps it excluded: the only surviving region is the
// triangle where the diamond's x+y=50 edge meets [0,30]², i.e. AABB
// [20,30]×[20,30].
const d2: Vector[] = [
{ x: 0, y: 0 },
{ x: 30, y: 0 },
{ x: 30, y: 30 },
{ x: 0, y: 30 }
]
const afterD1 = clipPolygon(subject, d1)
expect(afterD1).not.toBeNull()
const clipped = afterD1 ? clipPolygon(afterD1, d2) : null
expect(clipped).not.toBeNull()
expect(clipped?.length).toBeGreaterThanOrEqual(3)
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const p of clipped ?? []) {
if (p.x < minX) minX = p.x
if (p.y < minY) minY = p.y
if (p.x > maxX) maxX = p.x
if (p.y > maxY) maxY = p.y
}
// The corner (0,0) must stay excluded: the result does not reach below 20.
expect(minX).toBeGreaterThanOrEqual(20)
expect(minY).toBeGreaterThanOrEqual(20)
expect(maxX).toBeLessThanOrEqual(30)
expect(maxY).toBeLessThanOrEqual(30)
})
test('clipBoundsToPolygon agrees with clipPolygon for a single axis-aligned clip', () => {
const bounds = { minX: 0, minY: 0, maxX: 100, maxY: 100 }
const clip: Vector[] = [
{ x: 25, y: 25 },
{ x: 75, y: 25 },
{ x: 75, y: 75 },
{ x: 25, y: 75 }
]
const aabb = clipBoundsToPolygon(bounds, clip)
expect(aabb).toEqual({ minX: 25, minY: 25, maxX: 75, maxY: 75 })
})
})