2025-08-15 10:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* Predicate registry and evaluator contracts for the Imhotep logic engine.
|
|
|
|
|
*
|
|
|
|
|
* Predicates are the atomic propositions over which first-order logic
|
|
|
|
|
* formulas are built. Each predicate declares its name, arity, domain
|
|
|
|
|
* signature, and required geometry facts.
|
|
|
|
|
*
|
|
|
|
|
* Invariant: a predicate evaluator is a pure function from a geometry
|
|
|
|
|
* world and a tuple of bound subject ids to a PredicateTruth result.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import type { DiagnosticCode } from 'imhotep-core'
|
|
|
|
|
import type {
|
|
|
|
|
GeometryWorld,
|
|
|
|
|
ClauseResult,
|
|
|
|
|
ClauseStatus,
|
|
|
|
|
TruthValue,
|
|
|
|
|
} from './registry.js';
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Predicate Truth
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export type PredicateTruth = 'true' | 'false' | 'indeterminate';
|
|
|
|
|
|
|
|
|
|
export interface PredicateResult {
|
|
|
|
|
truth: PredicateTruth;
|
|
|
|
|
metrics?: Record<string, number>;
|
|
|
|
|
witness?: {
|
|
|
|
|
subjectIds: number[];
|
|
|
|
|
};
|
|
|
|
|
diagnostics?: Array<{
|
|
|
|
|
code: DiagnosticCode;
|
|
|
|
|
severity: 'error' | 'warning' | 'info';
|
|
|
|
|
message: string;
|
|
|
|
|
}>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Predicate Descriptor
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export interface PredicateDescriptor {
|
|
|
|
|
name: string;
|
|
|
|
|
arity: number;
|
|
|
|
|
domains: string[];
|
|
|
|
|
requiredFacts: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Predicate Evaluator
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export interface PredicateEvaluator {
|
|
|
|
|
descriptor: PredicateDescriptor;
|
|
|
|
|
evaluateTuple(
|
|
|
|
|
world: GeometryWorld,
|
|
|
|
|
tuple: number[],
|
|
|
|
|
options?: Record<string, unknown>,
|
|
|
|
|
): PredicateResult;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Built-in Predicates
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export const BUILTIN_PREDICATES: PredicateDescriptor[] = [
|
|
|
|
|
{ name: 'width', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
|
|
|
|
{ name: 'height', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
|
|
|
|
{ name: 'above', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'below', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'leftOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'rightOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'inside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'contains', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'overlaps', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'alignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'centeredWithin', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'atLeast', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
|
|
|
|
{ name: 'atMost', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
|
|
|
|
{ name: 'between', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
|
|
|
|
{ name: 'clippedBy', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.clipChain', 'reference.clipChain'] },
|
|
|
|
|
{ name: 'inStackingContext', arity: 1, domains: ['element'], requiredFacts: ['topology.stackingContextOf'] },
|
|
|
|
|
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
// Spatial alias predicates
|
|
|
|
|
{ name: 'beside', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'nextTo', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'adjacent', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'touching', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'near', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'under', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'within', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Predicate Registry
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
export class PredicateRegistry {
|
|
|
|
|
private registry = new Map<string, PredicateEvaluator>();
|
|
|
|
|
|
|
|
|
|
register(evaluator: PredicateEvaluator): void {
|
|
|
|
|
this.registry.set(evaluator.descriptor.name, evaluator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get(name: string): PredicateEvaluator | undefined {
|
|
|
|
|
return this.registry.get(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
list(): string[] {
|
|
|
|
|
return Array.from(this.registry.keys());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clear(): void {
|
|
|
|
|
this.registry.clear();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDescriptor(name: string): PredicateDescriptor | undefined {
|
|
|
|
|
return this.registry.get(name)?.descriptor;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getRequiredFacts(name: string): string[] {
|
|
|
|
|
return this.getDescriptor(name)?.requiredFacts ?? [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Global default instance for backward compatibility.
|
|
|
|
|
export const globalPredicateRegistry = new PredicateRegistry();
|
|
|
|
|
|
|
|
|
|
export function registerPredicate(evaluator: PredicateEvaluator): void {
|
|
|
|
|
globalPredicateRegistry.register(evaluator);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPredicateEvaluator(name: string): PredicateEvaluator | undefined {
|
|
|
|
|
return globalPredicateRegistry.get(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function listRegisteredPredicates(): string[] {
|
|
|
|
|
return globalPredicateRegistry.list();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function clearPredicateRegistry(): void {
|
|
|
|
|
globalPredicateRegistry.clear();
|
|
|
|
|
defaultPredicatesRegistered = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPredicateDescriptor(name: string): PredicateDescriptor | undefined {
|
|
|
|
|
return globalPredicateRegistry.getDescriptor(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getRequiredFactsForPredicate(name: string): string[] {
|
|
|
|
|
return globalPredicateRegistry.getRequiredFacts(name);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Built-in Predicate Evaluators
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function getBorderRect(world: GeometryWorld, subjectId: number) {
|
|
|
|
|
const { boxes } = world;
|
|
|
|
|
// Build a subjectId -> boxIndex map on first use for O(1) lookups.
|
|
|
|
|
// This avoids O(n) linear scans on every predicate evaluation.
|
|
|
|
|
let index = (world as any).__boxIndex as Map<number, number> | undefined;
|
|
|
|
|
if (!index) {
|
|
|
|
|
index = new Map<number, number>();
|
|
|
|
|
for (let i = 0; i < boxes.subjectId.length; i++) {
|
|
|
|
|
index.set(boxes.subjectId[i], i);
|
|
|
|
|
}
|
|
|
|
|
(world as any).__boxIndex = index;
|
|
|
|
|
}
|
|
|
|
|
const i = index.get(subjectId);
|
|
|
|
|
if (i === undefined) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
left: boxes.borderLeft[i],
|
|
|
|
|
top: boxes.borderTop[i],
|
|
|
|
|
right: boxes.borderRight[i],
|
|
|
|
|
bottom: boxes.borderBottom[i],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makePredicateResult(
|
|
|
|
|
truth: PredicateTruth,
|
|
|
|
|
metrics?: Record<string, number>,
|
|
|
|
|
witness?: number[],
|
|
|
|
|
diagnostics?: PredicateResult['diagnostics'],
|
|
|
|
|
): PredicateResult {
|
|
|
|
|
return {
|
|
|
|
|
truth,
|
|
|
|
|
metrics,
|
|
|
|
|
witness: witness ? { subjectIds: witness } : undefined,
|
|
|
|
|
diagnostics,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build a diagnostic for a failing predicate that includes observed values.
|
|
|
|
|
*/
|
|
|
|
|
function makePredicateDiagnostic(
|
|
|
|
|
predicate: string,
|
|
|
|
|
metrics: Record<string, number>,
|
|
|
|
|
subjectIds: number[],
|
|
|
|
|
): PredicateResult['diagnostics'] {
|
|
|
|
|
const entries = Object.entries(metrics)
|
|
|
|
|
.map(([k, v]) => `${k}=${typeof v === 'number' ? v.toFixed(2) : String(v)}`)
|
|
|
|
|
.join(', ');
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
code: 'IMH_PREDICATE_FAILED',
|
|
|
|
|
severity: 'error',
|
|
|
|
|
message: `Predicate "${predicate}" failed for subjects [${subjectIds.join(', ')}]. Observed: { ${entries} }`,
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseLengthOption(raw: unknown): { value: number; unit: string } | null {
|
|
|
|
|
if (typeof raw === 'number') {
|
|
|
|
|
return { value: raw, unit: 'px' };
|
|
|
|
|
}
|
|
|
|
|
if (typeof raw === 'string') {
|
|
|
|
|
const trimmed = raw.trim();
|
|
|
|
|
const m = trimmed.match(/^(-?\d*\.?\d+)\s*([a-z%]+)?$/i);
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
const value = Number.parseFloat(m[1] ?? '');
|
|
|
|
|
if (Number.isNaN(value)) return null;
|
|
|
|
|
return { value, unit: (m[2] ?? 'px').toLowerCase() };
|
|
|
|
|
}
|
|
|
|
|
if (raw && typeof raw === 'object') {
|
|
|
|
|
const obj = raw as Record<string, unknown>;
|
|
|
|
|
if (typeof obj.value === 'number') {
|
|
|
|
|
return {
|
|
|
|
|
value: obj.value,
|
|
|
|
|
unit: typeof obj.unit === 'string' ? obj.unit.toLowerCase() : 'px',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSubjectFontSizePx(world: GeometryWorld, subjectId: number): number {
|
|
|
|
|
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
|
|
|
|
|
const fontSize = styles?.fontSize;
|
|
|
|
|
if (!fontSize) return 16;
|
|
|
|
|
const ids = world.subjects?.ids;
|
|
|
|
|
if (!ids) return 16;
|
|
|
|
|
const idx = ids.indexOf(subjectId);
|
|
|
|
|
if (idx < 0) return 16;
|
|
|
|
|
const fs = Number(fontSize[idx] ?? 16);
|
|
|
|
|
return Number.isFinite(fs) && fs > 0 ? fs : 16;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getRootFontSizePx(world: GeometryWorld): number {
|
|
|
|
|
const styles = (world as any).styles as { fontSize?: ArrayLike<number> } | undefined;
|
|
|
|
|
const fontSize = styles?.fontSize;
|
|
|
|
|
if (!fontSize || fontSize.length === 0) return 16;
|
|
|
|
|
const fs = Number(fontSize[0] ?? 16);
|
|
|
|
|
return Number.isFinite(fs) && fs > 0 ? fs : 16;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSubjectChWidthPx(world: GeometryWorld, subjectId: number): number {
|
|
|
|
|
const styles = (world as any).styles as {
|
|
|
|
|
subjectId?: ArrayLike<number>
|
|
|
|
|
chWidth?: ArrayLike<number>
|
|
|
|
|
} | undefined;
|
|
|
|
|
const chWidth = styles?.chWidth;
|
|
|
|
|
if (!chWidth || chWidth.length === 0) {
|
|
|
|
|
return getSubjectFontSizePx(world, subjectId) * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const styleSubjectIds = styles?.subjectId;
|
|
|
|
|
if (styleSubjectIds && styleSubjectIds.length > 0) {
|
|
|
|
|
for (let i = 0; i < styleSubjectIds.length; i++) {
|
|
|
|
|
if (Number(styleSubjectIds[i]) !== subjectId) continue;
|
|
|
|
|
const w = Number(chWidth[i]);
|
|
|
|
|
if (Number.isFinite(w) && w > 0) return w;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ids = world.subjects?.ids;
|
|
|
|
|
if (ids) {
|
|
|
|
|
const idx = ids.indexOf(subjectId);
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
const w = Number(chWidth[idx]);
|
|
|
|
|
if (Number.isFinite(w) && w > 0) return w;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return getSubjectFontSizePx(world, subjectId) * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveLengthToPx(
|
|
|
|
|
world: GeometryWorld,
|
|
|
|
|
subjectId: number,
|
|
|
|
|
raw: unknown,
|
|
|
|
|
dimension: 'width' | 'height',
|
|
|
|
|
): number | undefined {
|
|
|
|
|
const parsed = parseLengthOption(raw);
|
|
|
|
|
if (!parsed) return undefined;
|
|
|
|
|
const { value, unit } = parsed;
|
|
|
|
|
switch (unit) {
|
|
|
|
|
case 'px':
|
|
|
|
|
return value;
|
|
|
|
|
case 'vw':
|
|
|
|
|
return value * (world.env.viewportWidth / 100);
|
|
|
|
|
case 'vh':
|
|
|
|
|
return value * (world.env.viewportHeight / 100);
|
|
|
|
|
case '%':
|
|
|
|
|
return value * ((dimension === 'height' ? world.env.viewportHeight : world.env.viewportWidth) / 100);
|
|
|
|
|
case 'rem':
|
|
|
|
|
return value * getRootFontSizePx(world);
|
|
|
|
|
case 'em':
|
|
|
|
|
return value * getSubjectFontSizePx(world, subjectId);
|
|
|
|
|
case 'ch':
|
|
|
|
|
return value * getSubjectChWidthPx(world, subjectId);
|
|
|
|
|
default:
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const widthPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[0]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const rect = getBorderRect(world, subjectId);
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const w = rect.right - rect.left;
|
|
|
|
|
return makePredicateResult('true', { width: w, left: rect.left, right: rect.right }, [subjectId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const heightPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[1]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const rect = getBorderRect(world, subjectId);
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const h = rect.bottom - rect.top;
|
|
|
|
|
return makePredicateResult('true', { height: h }, [subjectId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const abovePredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[2]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const gap = rRect.top - sRect.bottom;
|
|
|
|
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
|
|
|
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
|
|
|
|
const pass = gap >= minGap && gap <= maxGap;
|
|
|
|
|
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectBottom: sRect.bottom, refTop: rRect.top };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('above', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const belowPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[3]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const gap = sRect.top - rRect.bottom;
|
|
|
|
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
|
|
|
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
|
|
|
|
const pass = gap >= minGap && gap <= maxGap;
|
|
|
|
|
const metrics = { gap, observedGap: gap, minGap, maxGap };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('below', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const leftOfPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[4]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const gap = rRect.left - sRect.right;
|
|
|
|
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
|
|
|
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
|
|
|
|
const pass = gap >= minGap && gap <= maxGap;
|
|
|
|
|
const metrics = { gap, observedGap: gap, minGap, maxGap, subjectRight: sRect.right, refLeft: rRect.left };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('leftOf', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const rightOfPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[5]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const gap = sRect.left - rRect.right;
|
|
|
|
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
|
|
|
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
|
|
|
|
const pass = gap >= minGap && gap <= maxGap;
|
|
|
|
|
const metrics = { gap, observedGap: gap, minGap, maxGap };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('rightOf', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const insidePredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[6]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const tolerance = (options?.tolerance as number | undefined) ?? 0;
|
|
|
|
|
const epsilon = (options?.epsilon as number | undefined) ?? 0.1;
|
|
|
|
|
const effectiveTolerance = tolerance + epsilon;
|
|
|
|
|
const pass =
|
|
|
|
|
sRect.left >= rRect.left - effectiveTolerance &&
|
|
|
|
|
sRect.top >= rRect.top - effectiveTolerance &&
|
|
|
|
|
sRect.right <= rRect.right + effectiveTolerance &&
|
|
|
|
|
sRect.bottom <= rRect.bottom + effectiveTolerance;
|
|
|
|
|
const metrics = {
|
|
|
|
|
overflowLeft: Math.max(0, rRect.left - sRect.left),
|
|
|
|
|
overflowTop: Math.max(0, rRect.top - sRect.top),
|
|
|
|
|
overflowRight: Math.max(0, sRect.right - rRect.right),
|
|
|
|
|
overflowBottom: Math.max(0, sRect.bottom - rRect.bottom),
|
|
|
|
|
tolerance,
|
|
|
|
|
epsilon,
|
|
|
|
|
effectiveTolerance,
|
|
|
|
|
subjectLeft: sRect.left,
|
|
|
|
|
subjectTop: sRect.top,
|
|
|
|
|
subjectRight: sRect.right,
|
|
|
|
|
subjectBottom: sRect.bottom,
|
|
|
|
|
refLeft: rRect.left,
|
|
|
|
|
refTop: rRect.top,
|
|
|
|
|
refRight: rRect.right,
|
|
|
|
|
refBottom: rRect.bottom,
|
|
|
|
|
};
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('inside', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const containsPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[7]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const tolerance = (options?.tolerance as number | undefined) ?? 0;
|
|
|
|
|
const pass =
|
|
|
|
|
rRect.left >= sRect.left - tolerance &&
|
|
|
|
|
rRect.top >= sRect.top - tolerance &&
|
|
|
|
|
rRect.right <= sRect.right + tolerance &&
|
|
|
|
|
rRect.bottom <= sRect.bottom + tolerance;
|
|
|
|
|
const metrics = {
|
|
|
|
|
tolerance,
|
|
|
|
|
subjectLeft: sRect.left,
|
|
|
|
|
subjectTop: sRect.top,
|
|
|
|
|
subjectRight: sRect.right,
|
|
|
|
|
subjectBottom: sRect.bottom,
|
|
|
|
|
refLeft: rRect.left,
|
|
|
|
|
refTop: rRect.top,
|
|
|
|
|
refRight: rRect.right,
|
|
|
|
|
refBottom: rRect.bottom,
|
|
|
|
|
};
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('contains', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const overlapsPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[8]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const pass =
|
|
|
|
|
sRect.left < rRect.right &&
|
|
|
|
|
sRect.right > rRect.left &&
|
|
|
|
|
sRect.top < rRect.bottom &&
|
|
|
|
|
sRect.bottom > rRect.top;
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', {}, [subjectId, referenceId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const alignedWithPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[9]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const axis = (options?.axis as string | undefined) ?? 'centerY';
|
|
|
|
|
const tolerance = (options?.tolerance as number | undefined) ?? 0;
|
|
|
|
|
let delta = Infinity;
|
|
|
|
|
switch (axis) {
|
|
|
|
|
case 'left':
|
|
|
|
|
delta = Math.abs(sRect.left - rRect.left);
|
|
|
|
|
break;
|
|
|
|
|
case 'right':
|
|
|
|
|
delta = Math.abs(sRect.right - rRect.right);
|
|
|
|
|
break;
|
|
|
|
|
case 'top':
|
|
|
|
|
delta = Math.abs(sRect.top - rRect.top);
|
|
|
|
|
break;
|
|
|
|
|
case 'bottom':
|
|
|
|
|
delta = Math.abs(sRect.bottom - rRect.bottom);
|
|
|
|
|
break;
|
|
|
|
|
case 'centerX':
|
|
|
|
|
delta = Math.abs((sRect.left + sRect.right) / 2 - (rRect.left + rRect.right) / 2);
|
|
|
|
|
break;
|
|
|
|
|
case 'centerY':
|
|
|
|
|
delta = Math.abs((sRect.top + sRect.bottom) / 2 - (rRect.top + rRect.bottom) / 2);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
const pass = delta <= tolerance;
|
|
|
|
|
const metrics: Record<string, number> = { delta, tolerance };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('alignedWith', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const centeredWithinPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[10]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const tolerance = (options?.tolerance as number | undefined) ?? 0;
|
|
|
|
|
const sCx = (sRect.left + sRect.right) / 2;
|
|
|
|
|
const sCy = (sRect.top + sRect.bottom) / 2;
|
|
|
|
|
const rCx = (rRect.left + rRect.right) / 2;
|
|
|
|
|
const rCy = (rRect.top + rRect.bottom) / 2;
|
|
|
|
|
const deltaX = Math.abs(sCx - rCx);
|
|
|
|
|
const deltaY = Math.abs(sCy - rCy);
|
|
|
|
|
const pass = deltaX <= tolerance && deltaY <= tolerance;
|
|
|
|
|
const metrics = { deltaX, deltaY, tolerance };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('centeredWithin', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const atLeastPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[11]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const rect = getBorderRect(world, subjectId);
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const dimension = options?.dimension as string | undefined;
|
|
|
|
|
const min = resolveLengthToPx(world, subjectId, options?.min, dimension === 'height' ? 'height' : 'width');
|
|
|
|
|
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
|
|
|
|
|
if (min === undefined) {
|
|
|
|
|
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
const pass = value >= min;
|
|
|
|
|
const metrics = { value, min, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('atLeast', metrics, [subjectId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const atMostPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[12]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const rect = getBorderRect(world, subjectId);
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const dimension = options?.dimension as string | undefined;
|
|
|
|
|
const max = resolveLengthToPx(world, subjectId, options?.max, dimension === 'height' ? 'height' : 'width');
|
|
|
|
|
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
|
|
|
|
|
if (max === undefined) {
|
|
|
|
|
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
const pass = value <= max;
|
|
|
|
|
const metrics = { value, max, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('atMost', metrics, [subjectId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const betweenPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[13]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const rect = getBorderRect(world, subjectId);
|
|
|
|
|
if (!rect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const dimension = options?.dimension as string | undefined;
|
|
|
|
|
const min = resolveLengthToPx(world, subjectId, options?.min, dimension === 'height' ? 'height' : 'width');
|
|
|
|
|
const max = resolveLengthToPx(world, subjectId, options?.max, dimension === 'height' ? 'height' : 'width');
|
|
|
|
|
const value = dimension === 'height' ? rect.bottom - rect.top : rect.right - rect.left;
|
|
|
|
|
if (min === undefined && max === undefined) {
|
|
|
|
|
return makePredicateResult('true', { value, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
const pass = (min === undefined || value >= min) && (max === undefined || value <= max);
|
|
|
|
|
const metrics = { value, min: min ?? -Infinity, max: max ?? Infinity, left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('between', metrics, [subjectId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const clippedByPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[14]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
// Simplified: check if subject's clipping root is the reference
|
|
|
|
|
const clipRoot = world.topology.clippingRootOf[subjectId - 1] ?? 0;
|
|
|
|
|
const pass = clipRoot === referenceId;
|
2026-05-21 13:52:28 -07:00
|
|
|
// Determine clip kind from the clipping table entry for the reference.
|
|
|
|
|
// Encoding: 1=contain:paint, 2=overflow:hidden/scroll/auto
|
|
|
|
|
const metrics: Record<string, number> = {};
|
|
|
|
|
if (referenceId !== undefined) {
|
|
|
|
|
const { clipping } = world;
|
|
|
|
|
for (let i = 0; i < clipping.subjectId.length; i++) {
|
|
|
|
|
if (clipping.subjectId[i] === referenceId) {
|
|
|
|
|
metrics.clipKind = clipping.clipKind[i] === 5 /* Contain */ ? 1 : 2;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId]);
|
2025-08-15 10:00:00 -07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const inStackingContextPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[15]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sc = world.topology.stackingContextOf[subjectId - 1] ?? 0;
|
|
|
|
|
const pass = sc > 0;
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc }, [subjectId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Spatial Alias Predicate Evaluators
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* beside(subject, opts) = leftOf(subject, opts) OR rightOf(subject, opts)
|
|
|
|
|
* Tries leftOf first, then rightOf. Returns whichever passes, or fails with both measured values.
|
|
|
|
|
*/
|
|
|
|
|
export const besidePredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[17]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const leftResult = leftOfPredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
if (leftResult.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...leftResult.metrics, direction: 'left' } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
const rightResult = rightOfPredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
if (rightResult.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...rightResult.metrics, direction: 'right' } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
const metrics = {
|
|
|
|
|
leftGap: leftResult.metrics?.gap ?? NaN,
|
|
|
|
|
rightGap: rightResult.metrics?.gap ?? NaN,
|
|
|
|
|
};
|
|
|
|
|
return makePredicateResult('false', metrics, tuple, [
|
|
|
|
|
{
|
|
|
|
|
code: 'IMH_RELATION_BESIDE_FAILED',
|
|
|
|
|
severity: 'error',
|
|
|
|
|
message: `Predicate "beside" failed: neither leftOf (gap=${(metrics.leftGap as number).toFixed(2)}) nor rightOf (gap=${(metrics.rightGap as number).toFixed(2)}) satisfied.`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* nextTo(subject, opts) = beside(subject, opts)
|
|
|
|
|
* Synonym for beside — delegates to the beside evaluator.
|
|
|
|
|
*/
|
|
|
|
|
export const nextToPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[18]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const besideResult = besidePredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
if (besideResult.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...besideResult.metrics, alias: 'nextTo' } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
// Rewrite diagnostic to mention nextTo
|
|
|
|
|
const diagnostics = besideResult.diagnostics?.map((d) => ({
|
|
|
|
|
...d,
|
|
|
|
|
code: 'IMH_RELATION_BESIDE_FAILED' as const,
|
|
|
|
|
message: d.message.replace('"beside"', '"nextTo"'),
|
|
|
|
|
}));
|
|
|
|
|
return makePredicateResult('false', besideResult.metrics ?? {}, tuple, diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* adjacent(subject, opts) = leftOr(subject, { maxGap: 0 }) OR rightOf(subject, { maxGap: 0 })
|
|
|
|
|
* OR above(subject, { maxGap: 0 }) OR below(subject, { maxGap: 0 })
|
|
|
|
|
* Checks all four cardinal directions with maxGap: 0. Returns whichever passes, or fails.
|
|
|
|
|
*/
|
|
|
|
|
export const adjacentPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[19]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const adjOptions = { ...options, maxGap: 0 };
|
|
|
|
|
const directions = [
|
|
|
|
|
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, adjOptions) },
|
|
|
|
|
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, adjOptions) },
|
|
|
|
|
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, adjOptions) },
|
|
|
|
|
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, adjOptions) },
|
|
|
|
|
];
|
|
|
|
|
for (const dir of directions) {
|
|
|
|
|
if (dir.result.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const metrics = {
|
|
|
|
|
leftGap: directions[0].result.metrics?.gap ?? NaN,
|
|
|
|
|
rightGap: directions[1].result.metrics?.gap ?? NaN,
|
|
|
|
|
aboveGap: directions[2].result.metrics?.gap ?? NaN,
|
|
|
|
|
belowGap: directions[3].result.metrics?.gap ?? NaN,
|
|
|
|
|
};
|
|
|
|
|
return makePredicateResult('false', metrics, tuple, [
|
|
|
|
|
{
|
|
|
|
|
code: 'IMH_RELATION_ADJACENT_FAILED',
|
|
|
|
|
severity: 'error',
|
|
|
|
|
message: `Predicate "adjacent" failed: no direction satisfied with maxGap=0.`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* touching(subject, opts) = adjacent(subject, opts)
|
|
|
|
|
* Synonym for adjacent — delegates to the adjacent evaluator.
|
|
|
|
|
*/
|
|
|
|
|
export const touchingPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[20]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const adjacentResult = adjacentPredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
if (adjacentResult.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...adjacentResult.metrics, alias: 'touching' } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
const diagnostics = adjacentResult.diagnostics?.map((d) => ({
|
|
|
|
|
...d,
|
|
|
|
|
code: 'IMH_RELATION_ADJACENT_FAILED' as const,
|
|
|
|
|
message: d.message.replace('"adjacent"', '"touching"'),
|
|
|
|
|
}));
|
|
|
|
|
return makePredicateResult('false', adjacentResult.metrics ?? {}, tuple, diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* near(subject, opts) = overlaps(subject) OR inside a proximity radius
|
|
|
|
|
* First checks overlaps. If that fails, checks directional proximity using leftOf/rightOf/above/below
|
|
|
|
|
* with a large maxGap (default 100px, overridable via opts.maxGap).
|
|
|
|
|
*/
|
|
|
|
|
export const nearPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[21]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const overlapsResult = overlapsPredicate.evaluateTuple(world, tuple);
|
|
|
|
|
if (overlapsResult.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...overlapsResult.metrics, mode: 'overlaps' } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
const radius = (options?.maxGap as number | undefined) ?? 100;
|
|
|
|
|
const proximityOptions = { ...options, maxGap: radius };
|
|
|
|
|
const directions = [
|
|
|
|
|
{ name: 'leftOf', result: leftOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
|
|
|
|
|
{ name: 'rightOf', result: rightOfPredicate.evaluateTuple(world, tuple, proximityOptions) },
|
|
|
|
|
{ name: 'above', result: abovePredicate.evaluateTuple(world, tuple, proximityOptions) },
|
|
|
|
|
{ name: 'below', result: belowPredicate.evaluateTuple(world, tuple, proximityOptions) },
|
|
|
|
|
];
|
|
|
|
|
for (const dir of directions) {
|
|
|
|
|
if (dir.result.truth === 'true') {
|
|
|
|
|
return makePredicateResult('true', { ...dir.result.metrics, direction: dir.name, mode: 'proximity', radius } as any, tuple);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const metrics = {
|
|
|
|
|
radius,
|
|
|
|
|
leftGap: directions[0].result.metrics?.gap ?? NaN,
|
|
|
|
|
rightGap: directions[1].result.metrics?.gap ?? NaN,
|
|
|
|
|
aboveGap: directions[2].result.metrics?.gap ?? NaN,
|
|
|
|
|
belowGap: directions[3].result.metrics?.gap ?? NaN,
|
|
|
|
|
};
|
|
|
|
|
return makePredicateResult('false', metrics, tuple, [
|
|
|
|
|
{
|
|
|
|
|
code: 'IMH_RELATION_NEAR_FAILED',
|
|
|
|
|
severity: 'error',
|
|
|
|
|
message: `Predicate "near" failed: elements neither overlap nor are within proximity radius ${radius}.`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* under(subject, opts) = below(subject, opts)
|
|
|
|
|
* Synonym for below — delegates to the below evaluator.
|
|
|
|
|
*/
|
|
|
|
|
export const underPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[22]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return belowPredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* within(subject, opts) = inside(subject, opts)
|
|
|
|
|
* Synonym for inside — delegates to the inside evaluator.
|
|
|
|
|
*/
|
|
|
|
|
export const withinPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[23]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return insidePredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Not-Yet-Implemented Predicates
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function makeNotImplementedPredicate(name: string): PredicateEvaluator {
|
|
|
|
|
return {
|
|
|
|
|
descriptor: {
|
|
|
|
|
name,
|
|
|
|
|
arity: 2,
|
|
|
|
|
domains: ['element', 'element'],
|
|
|
|
|
requiredFacts: ['subject.primaryBox', 'reference.primaryBox'],
|
|
|
|
|
},
|
|
|
|
|
evaluateTuple(_world, tuple) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
const referenceId = tuple[1];
|
|
|
|
|
return makePredicateResult('false', {}, [subjectId, referenceId].filter((id): id is number => id !== undefined), [
|
|
|
|
|
{
|
|
|
|
|
code: 'IMH_FEATURE_NOT_YET_IMPLEMENTED',
|
|
|
|
|
severity: 'error',
|
|
|
|
|
message: `Predicate "${name}" is not yet implemented.`,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const separatedFromPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[16]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const rRect = getBorderRect(world, referenceId);
|
|
|
|
|
if (!sRect || !rRect) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
// separatedFrom = NOT overlaps
|
|
|
|
|
const overlaps =
|
|
|
|
|
sRect.left < rRect.right &&
|
|
|
|
|
sRect.right > rRect.left &&
|
|
|
|
|
sRect.top < rRect.bottom &&
|
|
|
|
|
sRect.bottom > rRect.top;
|
|
|
|
|
const pass = !overlaps;
|
|
|
|
|
const metrics: Record<string, number> = {};
|
|
|
|
|
if (!pass) {
|
|
|
|
|
// Calculate overlap area for diagnostics
|
|
|
|
|
const overlapLeft = Math.max(sRect.left, rRect.left);
|
|
|
|
|
const overlapTop = Math.max(sRect.top, rRect.top);
|
|
|
|
|
const overlapRight = Math.min(sRect.right, rRect.right);
|
|
|
|
|
const overlapBottom = Math.min(sRect.bottom, rRect.bottom);
|
|
|
|
|
metrics.overlapWidth = overlapRight - overlapLeft;
|
|
|
|
|
metrics.overlapHeight = overlapBottom - overlapTop;
|
|
|
|
|
}
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
export const leftAlignedWithPredicate = makeNotImplementedPredicate('leftAlignedWith');
|
|
|
|
|
export const rightAlignedWithPredicate = makeNotImplementedPredicate('rightAlignedWith');
|
|
|
|
|
export const topAlignedWithPredicate = makeNotImplementedPredicate('topAlignedWith');
|
|
|
|
|
export const bottomAlignedWithPredicate = makeNotImplementedPredicate('bottomAlignedWith');
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Register Defaults
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let defaultPredicatesRegistered = false;
|
|
|
|
|
|
|
|
|
|
export function registerDefaultPredicates(): void {
|
|
|
|
|
if (defaultPredicatesRegistered) return;
|
|
|
|
|
defaultPredicatesRegistered = true;
|
|
|
|
|
registerPredicate(widthPredicate);
|
|
|
|
|
registerPredicate(heightPredicate);
|
|
|
|
|
registerPredicate(abovePredicate);
|
|
|
|
|
registerPredicate(belowPredicate);
|
|
|
|
|
registerPredicate(leftOfPredicate);
|
|
|
|
|
registerPredicate(rightOfPredicate);
|
|
|
|
|
registerPredicate(insidePredicate);
|
|
|
|
|
registerPredicate(containsPredicate);
|
|
|
|
|
registerPredicate(overlapsPredicate);
|
|
|
|
|
registerPredicate(alignedWithPredicate);
|
|
|
|
|
registerPredicate(centeredWithinPredicate);
|
|
|
|
|
registerPredicate(atLeastPredicate);
|
|
|
|
|
registerPredicate(atMostPredicate);
|
|
|
|
|
registerPredicate(betweenPredicate);
|
|
|
|
|
registerPredicate(clippedByPredicate);
|
|
|
|
|
registerPredicate(inStackingContextPredicate);
|
|
|
|
|
registerPredicate(separatedFromPredicate);
|
|
|
|
|
registerPredicate(leftAlignedWithPredicate);
|
|
|
|
|
registerPredicate(rightAlignedWithPredicate);
|
|
|
|
|
registerPredicate(topAlignedWithPredicate);
|
|
|
|
|
registerPredicate(bottomAlignedWithPredicate);
|
|
|
|
|
registerPredicate(besidePredicate);
|
|
|
|
|
registerPredicate(nextToPredicate);
|
|
|
|
|
registerPredicate(adjacentPredicate);
|
|
|
|
|
registerPredicate(touchingPredicate);
|
|
|
|
|
registerPredicate(nearPredicate);
|
|
|
|
|
registerPredicate(underPredicate);
|
|
|
|
|
registerPredicate(withinPredicate);
|
|
|
|
|
}
|