diff --git a/ROADMAP.md b/ROADMAP.md index 78cb668..5fea36e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,6 +82,7 @@ - [x] Boyer–Moore fast substring search - [x] Suffix array construction utilities - [x] Longest common subsequence (LCS) enhancements and diff helpers + - [x] Aho–Corasick multi-pattern automaton **Data pipelines & utilities** - [x] Flatten/unflatten helpers for nested structures - [x] Pagination utilities for client-side paging diff --git a/docs/index.d.ts b/docs/index.d.ts index 1b8f2b0..b1e0edd 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -65,6 +65,9 @@ export const examples: { readonly aabbIntersection: 'examples/sat.ts'; readonly satCollision: 'examples/sat.ts'; readonly circleRayIntersection: 'examples/sat.ts'; + readonly circleCollision: 'examples/circle.ts'; + readonly circleAabbCollision: 'examples/circle.ts'; + readonly circleSegmentIntersection: 'examples/circle.ts'; readonly sweptAABB: 'examples/sweptAabb.ts'; }; readonly search: { @@ -79,6 +82,7 @@ export const examples: { readonly buildSuffixArray: 'examples/search.ts'; readonly longestCommonSubsequence: 'examples/search.ts'; readonly diffStrings: 'examples/search.ts'; + readonly createAhoCorasick: 'examples/search.ts'; }; readonly data: { readonly diff: 'examples/jsonDiff.ts'; @@ -188,6 +192,34 @@ export type ExampleName = keyof Exa // 🎮 PATHFINDING & NAVIGATION // ============================================================================ +// ============================================================================ +// 🎯 SPATIAL & COLLISION – Circle Helpers +// ============================================================================ + +/** + * Circle vs circle overlap test. + * Use for: simple collision checks, proximity triggers. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleCollision(a: Circle, b: Circle): boolean; + +/** + * Circle vs axis-aligned rectangle (AABB) intersection. + * Use for: tile collisions, UI hit-tests, broad-phase pruning. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleAabbCollision(circle: Circle, rect: Rect): boolean; + +/** + * Circle vs line segment intersection test. + * Use for: ray/segment hits, visibility checks, bullet tests. + * Performance: O(1). + * Import: spatial/circleCollision.ts + */ +export function circleSegmentIntersection(circle: Circle, a: Point, b: Point): boolean; + /** * A* pathfinding algorithm for grid-based navigation. * Use for: game movement, robotics, maze solving. @@ -2664,6 +2696,21 @@ export interface DiffOp { export function longestCommonSubsequence(options: LCSOptions): LCSResult; export function diffStrings(options: LCSOptions): DiffOp[]; +/** + * Aho–Corasick multi-pattern automaton. + * Use for: scanning texts for many patterns efficiently with overlaps. + * Performance: O(n + m + z) where n=text length, m=total pattern length, z=matches. + * Import: search/ahoCorasick.ts + */ +export interface AhoBuildOptions { + patterns: ReadonlyArray; + caseSensitive?: boolean; +} +export interface AhoAutomaton { + search(text: string): Record; +} +export function createAhoCorasick(options: AhoBuildOptions): AhoAutomaton; + // ============================================================================ // 📊 DATA TOOLS // ============================================================================ diff --git a/docs/list.md b/docs/list.md index e15c396..7d72cd0 100644 --- a/docs/list.md +++ b/docs/list.md @@ -129,6 +129,7 @@ Maximum Flow (Ford-Fulkerson) - Network flow Rabin-Karp - Multiple pattern matching Boyer-Moore - Fast single pattern search +Aho–Corasick - Multi-pattern automaton Longest Common Subsequence - Diff algorithms Suffix Array - Advanced pattern matching diff --git a/examples/circle.ts b/examples/circle.ts new file mode 100644 index 0000000..1fc224c --- /dev/null +++ b/examples/circle.ts @@ -0,0 +1,24 @@ +import { + circleCollision, + circleAabbCollision, + circleSegmentIntersection, +} from '../src/index.js'; + +// Circle vs circle +console.log( + 'circleCollision', + circleCollision({ x: 0, y: 0, radius: 2 }, { x: 3, y: 0, radius: 2 }) +); + +// Circle vs AABB +console.log( + 'circleAabbCollision', + circleAabbCollision({ x: 5, y: 5, radius: 2 }, { x: 6, y: 6, width: 4, height: 4 }) +); + +// Circle vs segment +console.log( + 'circleSegmentIntersection', + circleSegmentIntersection({ x: 0, y: 0, radius: 1 }, { x: -2, y: 0 }, { x: 2, y: 0 }) +); + diff --git a/examples/search.ts b/examples/search.ts index 34e92ec..fc38fc0 100644 --- a/examples/search.ts +++ b/examples/search.ts @@ -10,6 +10,7 @@ import { buildSuffixArray, longestCommonSubsequence, diffStrings, + createAhoCorasick, } from '../src/index.js'; const items = ['alpha', 'beta', 'delta', 'epsilon', 'gamma']; @@ -43,3 +44,6 @@ console.log('LCS of dynamic/programming:', lcs); const diff = diffStrings({ a: 'kitten', b: 'sitting' }); console.log('Diff between kitten and sitting:', diff); + +const automaton = createAhoCorasick({ patterns: ['abra', 'cad'] }); +console.log('Aho–Corasick matches in abracadabra:', automaton.search('abracadabra')); diff --git a/package.json b/package.json index 91d8a1d..7bf057b 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ { "name": "bundle", "path": "dist/index.js", - "limit": "40 KB" + "limit": "41 KB" } ] } diff --git a/src/index.ts b/src/index.ts index f4fe9d2..9d142bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,9 @@ export const examples = { aabbIntersection: 'examples/sat.ts', satCollision: 'examples/sat.ts', circleRayIntersection: 'examples/sat.ts', + circleCollision: 'examples/circle.ts', + circleAabbCollision: 'examples/circle.ts', + circleSegmentIntersection: 'examples/circle.ts', sweptAABB: 'examples/sweptAabb.ts', }, search: { @@ -75,6 +78,7 @@ export const examples = { buildSuffixArray: 'examples/search.ts', longestCommonSubsequence: 'examples/search.ts', diffStrings: 'examples/search.ts', + createAhoCorasick: 'examples/search.ts', }, data: { diff: 'examples/jsonDiff.ts', @@ -405,6 +409,24 @@ export { satCollision } from './spatial/sat.js'; * Example file: examples/sat.ts */ export { circleRayIntersection } from './spatial/circleRay.js'; +/** + * Fast circle-circle overlap test. + * + * Example file: examples/circle.ts + */ +export { circleCollision } from './spatial/circleCollision.js'; +/** + * Circle vs. AABB intersection test. + * + * Example file: examples/circle.ts + */ +export { circleAabbCollision } from './spatial/circleCollision.js'; +/** + * Circle vs. line segment intersection test. + * + * Example file: examples/circle.ts + */ +export { circleSegmentIntersection } from './spatial/circleCollision.js'; /** * Continuous swept AABB collision detection for moving boxes. @@ -924,6 +946,11 @@ export type { LCSOptions, LCSResult, DiffOp } from './search/lcs.js'; */ export { levenshteinDistance } from './search/levenshtein.js'; +/** + * Aho–Corasick multi-pattern automaton. + */ +export { createAhoCorasick } from './search/ahoCorasick.js'; + // ============================================================================ // 📊 DATA PROCESSING // ============================================================================ diff --git a/src/search/ahoCorasick.ts b/src/search/ahoCorasick.ts new file mode 100644 index 0000000..0c4794f --- /dev/null +++ b/src/search/ahoCorasick.ts @@ -0,0 +1,106 @@ +export interface AhoBuildOptions { + patterns: ReadonlyArray; + caseSensitive?: boolean; +} + +export interface AhoAutomaton { + search(text: string): Record; +} + +interface Node { + next: Map; + fail: number; + out: number[]; // indices into originalPatterns +} + +export function createAhoCorasick(options: AhoBuildOptions): AhoAutomaton { + validateOptions(options); + const caseSensitive = options.caseSensitive ?? true; + const originalPatterns = options.patterns.slice(); + const normalizedPatterns = caseSensitive + ? originalPatterns + : originalPatterns.map((p) => p.toLowerCase()); + + const nodes: Node[] = [{ next: new Map(), fail: 0, out: [] }]; + + // Build trie + normalizedPatterns.forEach((pattern, idx) => { + if (pattern.length === 0) return; + let state = 0; + for (const ch of pattern) { + let to = nodes[state].next.get(ch); + if (to === undefined) { + to = nodes.length; + nodes[state].next.set(ch, to); + nodes.push({ next: new Map(), fail: 0, out: [] }); + } + state = to; + } + nodes[state].out.push(idx); + }); + + // Build fail links via BFS + const queue: number[] = []; + for (const [, to] of nodes[0].next.entries()) { + nodes[to].fail = 0; + queue.push(to); + } + while (queue.length > 0) { + const v = queue.shift()!; + for (const [ch, to] of nodes[v].next.entries()) { + queue.push(to); + let f = nodes[v].fail; + while (f !== 0 && !nodes[f].next.has(ch)) { + f = nodes[f].fail; + } + if (nodes[f].next.has(ch)) { + f = nodes[f].next.get(ch)!; + } + nodes[to].fail = f; + nodes[to].out.push(...nodes[f].out); + } + } + + function search(text: string): Record { + const t = caseSensitive ? text : text.toLowerCase(); + const results: Record = {}; + // Handle empty patterns returning all positions + for (let i = 0; i < originalPatterns.length; i += 1) { + if (normalizedPatterns[i].length === 0) { + results[originalPatterns[i]] = Array.from({ length: text.length + 1 }, (_, p) => p); + } + } + + let state = 0; + for (let i = 0; i < t.length; i += 1) { + const ch = t[i]; + while (state !== 0 && !nodes[state].next.has(ch)) { + state = nodes[state].fail; + } + if (nodes[state].next.has(ch)) { + state = nodes[state].next.get(ch)!; + } + if (nodes[state].out.length > 0) { + for (const patIdx of nodes[state].out) { + const pat = originalPatterns[patIdx]; + const len = normalizedPatterns[patIdx].length; + const pos = i - len + 1; + if (!results[pat]) results[pat] = []; + results[pat].push(pos); + } + } + } + for (const pat of originalPatterns) { + if (!results[pat]) results[pat] = []; + } + return results; + } + + return { search }; +} + +function validateOptions(options: AhoBuildOptions): void { + if (!Array.isArray(options.patterns) || options.patterns.length === 0) { + throw new Error('patterns must contain at least one pattern.'); + } +} diff --git a/src/spatial/circleCollision.ts b/src/spatial/circleCollision.ts new file mode 100644 index 0000000..00e7af6 --- /dev/null +++ b/src/spatial/circleCollision.ts @@ -0,0 +1,55 @@ +import type { Circle, Point, Rect, Vector2D } from '../types.js'; + +/** + * Tests whether two circles overlap or touch. + * Useful for: simple collision tests, triggers, proximity checks. + */ +export function circleCollision(a: Circle, b: Circle): boolean { + const dx = a.x + 0 - b.x; + const dy = a.y + 0 - b.y; + const r = a.radius + b.radius; + return dx * dx + dy * dy <= r * r; +} + +/** + * Tests whether a circle intersects an axis-aligned rectangle (AABB). + * Useful for: broad-phase checks against tiles, UI hit areas. + */ +export function circleAabbCollision(circle: Circle, rect: Rect): boolean { + const closestX = clamp(circle.x, rect.x, rect.x + rect.width); + const closestY = clamp(circle.y, rect.y, rect.y + rect.height); + const dx = circle.x - closestX; + const dy = circle.y - closestY; + return dx * dx + dy * dy <= circle.radius * circle.radius; +} + +/** + * Tests whether a line segment intersects a circle. + * Useful for: hit scans, visibility checks, bullet vs. circle tests. + */ +export function circleSegmentIntersection(circle: Circle, a: Point, b: Point): boolean { + const ab: Vector2D = { x: b.x - a.x, y: b.y - a.y }; + const ac: Vector2D = { x: circle.x - a.x, y: circle.y - a.y }; + + const abLenSq = ab.x * ab.x + ab.y * ab.y; + if (abLenSq === 0) { + // Segment degenerates to point + const dx = a.x - circle.x; + const dy = a.y - circle.y; + return dx * dx + dy * dy <= circle.radius * circle.radius; + } + + // Project AC onto AB to find closest point on segment to circle center + const t = clamp((ac.x * ab.x + ac.y * ab.y) / abLenSq, 0, 1); + const closest: Point = { x: a.x + ab.x * t, y: a.y + ab.y * t }; + const dx = closest.x - circle.x; + const dy = closest.y - circle.y; + return dx * dx + dy * dy <= circle.radius * circle.radius; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export const __internals = { clamp }; + diff --git a/tests/ahoCorasick.test.ts b/tests/ahoCorasick.test.ts new file mode 100644 index 0000000..1254616 --- /dev/null +++ b/tests/ahoCorasick.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { createAhoCorasick } from '../src/index.js'; + +describe('createAhoCorasick', () => { + it('finds overlapping multi-pattern matches', () => { + const ac = createAhoCorasick({ patterns: ['ab', 'bc', 'abc'] }); + const res = ac.search('ababc'); + expect(res['ab']).toEqual([0, 2]); + expect(res['bc']).toEqual([3]); + expect(res['abc']).toEqual([2]); + }); + + it('supports case-insensitive matching and empty patterns', () => { + const ac = createAhoCorasick({ patterns: ['He', 'eL', ''], caseSensitive: false }); + const res = ac.search('HeLlo'); + expect(res['He']).toEqual([0]); + expect(res['eL']).toEqual([1]); + expect(res['']).toEqual([0, 1, 2, 3, 4, 5]); + }); +}); + diff --git a/tests/circleCollision.test.ts b/tests/circleCollision.test.ts new file mode 100644 index 0000000..d79d30b --- /dev/null +++ b/tests/circleCollision.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest'; +import { + circleCollision, + circleAabbCollision, + circleSegmentIntersection, +} from '../src/index.js'; + +describe('circleCollision helpers', () => { + it('detects overlapping and tangent circles', () => { + // Overlapping + expect( + circleCollision({ x: 0, y: 0, radius: 2 }, { x: 3, y: 0, radius: 2 }) + ).toBe(true); + // Tangent + expect( + circleCollision({ x: 0, y: 0, radius: 2 }, { x: 4, y: 0, radius: 2 }) + ).toBe(true); + // Separated + expect( + circleCollision({ x: 0, y: 0, radius: 1 }, { x: 3, y: 3, radius: 1 }) + ).toBe(false); + }); + + it('detects circle vs AABB intersection', () => { + // Circle inside rect + expect( + circleAabbCollision( + { x: 5, y: 5, radius: 2 }, + { x: 0, y: 0, width: 10, height: 10 } + ) + ).toBe(true); + // Touching edge + expect( + circleAabbCollision( + { x: 0, y: 0, radius: 2 }, + { x: 2, y: -1, width: 2, height: 2 } + ) + ).toBe(true); + // No overlap + expect( + circleAabbCollision( + { x: -10, y: -10, radius: 1 }, + { x: 0, y: 0, width: 2, height: 2 } + ) + ).toBe(false); + }); + + it('detects circle vs segment intersection', () => { + // Segment passes through circle + expect( + circleSegmentIntersection( + { x: 0, y: 0, radius: 1 }, + { x: -2, y: 0 }, + { x: 2, y: 0 } + ) + ).toBe(true); + // Segment tangent to circle + expect( + circleSegmentIntersection( + { x: 0, y: 0, radius: 1 }, + { x: -2, y: 1 }, + { x: 2, y: 1 } + ) + ).toBe(true); + // Far away segment + expect( + circleSegmentIntersection( + { x: 0, y: 0, radius: 1 }, + { x: 5, y: 5 }, + { x: 6, y: 6 } + ) + ).toBe(false); + }); +}); + diff --git a/tests/index.test.ts b/tests/index.test.ts index ec636a3..dcf120e 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -137,6 +137,7 @@ describe('package entry point', () => { | 'buildSuffixArray' | 'longestCommonSubsequence' | 'diffStrings' + | 'createAhoCorasick' >(); expectTypeOf>().toEqualTypeOf<