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'] },
|
2026-05-22 10:20:06 -07:00
|
|
|
{ name: 'attachedToScrollContainer', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.scrollContainerOf'] },
|
|
|
|
|
{ name: 'escapeClippingChainOf', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.clipChain', 'topology.clippingRootOf'] },
|
2026-05-21 17:10:38 -07:00
|
|
|
{ name: 'inStackingContext', arity: 2, domains: ['element', 'element'], requiredFacts: ['topology.stackingContextOf'] },
|
2025-08-15 10:00:00 -07:00
|
|
|
{ name: 'separatedFrom', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
2026-05-21 20:10:17 -07:00
|
|
|
{ name: 'aspectRatio', arity: 1, domains: ['element'], requiredFacts: ['subject.primaryBox'] },
|
2025-08-15 10:00:00 -07:00
|
|
|
// 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'] },
|
2026-05-22 10:20:06 -07:00
|
|
|
{ name: 'leftAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'rightAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'topAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'bottomAlignedWith', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'intersects', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'touches', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
|
|
|
|
{ name: 'hasGap', arity: 2, domains: ['element', 'element'], requiredFacts: ['subject.primaryBox', 'reference.primaryBox'] },
|
2025-08-15 10:00:00 -07:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 ?? [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 12:05:43 -07:00
|
|
|
/** @deprecated Use explicit PredicateRegistry injection via LogicEngineOptions.predicateRegistry. */
|
2025-08-15 10:00:00 -07:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-22 12:00:20 -07:00
|
|
|
const boxIndexCache = new WeakMap<GeometryWorld, Map<number, number>>()
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
function getBorderRect(world: GeometryWorld, subjectId: number) {
|
|
|
|
|
const { boxes } = world;
|
2026-05-22 12:00:20 -07:00
|
|
|
let index = boxIndexCache.get(world);
|
2025-08-15 10:00:00 -07:00
|
|
|
if (!index) {
|
|
|
|
|
index = new Map<number, number>();
|
|
|
|
|
for (let i = 0; i < boxes.subjectId.length; i++) {
|
|
|
|
|
index.set(boxes.subjectId[i], i);
|
|
|
|
|
}
|
2026-05-22 12:00:20 -07:00
|
|
|
boxIndexCache.set(world, index);
|
2025-08-15 10:00:00 -07:00
|
|
|
}
|
|
|
|
|
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],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 10:20:06 -07:00
|
|
|
function getSubjectIndex(world: GeometryWorld, subjectId: number): number {
|
|
|
|
|
return world.subjects.ids.indexOf(subjectId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTopologyValueBySubject(world: GeometryWorld, subjectId: number, values: number[]): number {
|
|
|
|
|
const idx = getSubjectIndex(world, subjectId);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (idx < 0 || idx >= values.length) return -1;
|
|
|
|
|
const v = values[idx];
|
|
|
|
|
// NaN or negative values indicate unset topology; treat as missing.
|
|
|
|
|
return (v === undefined || Number.isNaN(v) || v < 0) ? -1 : v;
|
2026-05-22 10:20:06 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getSubjectDomNodeId(world: GeometryWorld, subjectId: number): number {
|
|
|
|
|
const idx = getSubjectIndex(world, subjectId);
|
|
|
|
|
return idx >= 0 ? world.subjects.domNodeId[idx] ?? 0 : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isDomAncestor(world: GeometryWorld, ancestorSubjectId: number, descendantSubjectId: number): boolean | undefined {
|
|
|
|
|
const ancestorNodeId = getSubjectDomNodeId(world, ancestorSubjectId);
|
|
|
|
|
let current = getSubjectDomNodeId(world, descendantSubjectId);
|
|
|
|
|
if (ancestorNodeId <= 0 || current <= 0 || world.dom.nodeId.length === 0) return undefined;
|
|
|
|
|
|
|
|
|
|
while (current > 0) {
|
|
|
|
|
const pos = world.dom.nodeId.indexOf(current);
|
|
|
|
|
if (pos < 0) return undefined;
|
|
|
|
|
const parent = world.dom.parentNodeId[pos] ?? 0;
|
|
|
|
|
if (parent === ancestorNodeId) return true;
|
|
|
|
|
current = parent;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getClipRectForSubject(world: GeometryWorld, subjectId: number) {
|
|
|
|
|
const clipping = world.clipping;
|
|
|
|
|
for (let i = 0; i < clipping.subjectId.length; i++) {
|
|
|
|
|
if (clipping.subjectId[i] !== subjectId) continue;
|
|
|
|
|
return {
|
|
|
|
|
left: clipping.clipLeft[i],
|
|
|
|
|
top: clipping.clipTop[i],
|
|
|
|
|
right: clipping.clipRight[i],
|
|
|
|
|
bottom: clipping.clipBottom[i],
|
|
|
|
|
kind: clipping.clipKind[i] ?? 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rectOverlapMetrics(sRect: ReturnType<typeof getBorderRect> & {}, rRect: ReturnType<typeof getBorderRect> & {}) {
|
|
|
|
|
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);
|
|
|
|
|
const overlapWidth = Math.max(0, overlapRight - overlapLeft);
|
|
|
|
|
const overlapHeight = Math.max(0, overlapBottom - overlapTop);
|
|
|
|
|
const gapX = Math.max(rRect.left - sRect.right, sRect.left - rRect.right, 0);
|
|
|
|
|
const gapY = Math.max(rRect.top - sRect.bottom, sRect.top - rRect.bottom, 0);
|
|
|
|
|
return {
|
|
|
|
|
overlapWidth,
|
|
|
|
|
overlapHeight,
|
|
|
|
|
overlapArea: overlapWidth * overlapHeight,
|
|
|
|
|
gapX,
|
|
|
|
|
gapY,
|
|
|
|
|
gap: Math.hypot(gapX, gapY),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
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 {
|
2026-05-22 14:38:37 -07:00
|
|
|
const fontSize = world.styles?.fontSize;
|
2025-08-15 10:00:00 -07:00
|
|
|
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 {
|
2026-05-22 14:38:37 -07:00
|
|
|
const fontSize = world.styles?.fontSize;
|
2025-08-15 10:00:00 -07:00
|
|
|
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 {
|
2026-05-22 14:38:37 -07:00
|
|
|
const subjectIdArr = world.styles?.subjectId;
|
|
|
|
|
const chWidthArr = world.styles?.chWidth;
|
|
|
|
|
if (!chWidthArr || chWidthArr.length === 0) {
|
2025-08-15 10:00:00 -07:00
|
|
|
return getSubjectFontSizePx(world, subjectId) * 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 14:38:37 -07:00
|
|
|
if (subjectIdArr && subjectIdArr.length > 0) {
|
|
|
|
|
for (let i = 0; i < subjectIdArr.length; i++) {
|
|
|
|
|
if (Number(subjectIdArr[i]) !== subjectId) continue;
|
|
|
|
|
const w = Number(chWidthArr[i]);
|
2025-08-15 10:00:00 -07:00
|
|
|
if (Number.isFinite(w) && w > 0) return w;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ids = world.subjects?.ids;
|
|
|
|
|
if (ids) {
|
|
|
|
|
const idx = ids.indexOf(subjectId);
|
|
|
|
|
if (idx >= 0) {
|
2026-05-22 14:38:37 -07:00
|
|
|
const w = Number(chWidthArr[idx]);
|
2025-08-15 10:00:00 -07:00
|
|
|
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;
|
2026-05-21 14:11:47 -07:00
|
|
|
const metrics: Record<string, number> = {
|
2025-08-15 10:00:00 -07:00
|
|
|
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,
|
|
|
|
|
};
|
2026-05-21 14:11:47 -07:00
|
|
|
const hasOverflow = metrics.overflowLeft > 0 || metrics.overflowTop > 0
|
|
|
|
|
|| metrics.overflowRight > 0 || metrics.overflowBottom > 0;
|
|
|
|
|
if (hasOverflow && world.clipping?.subjectId) {
|
|
|
|
|
for (let i = 0; i < world.clipping.subjectId.length; i++) {
|
|
|
|
|
if (world.clipping.subjectId[i] === referenceId) {
|
|
|
|
|
metrics.hasClippedOverflow = 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-15 10:00:00 -07:00
|
|
|
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;
|
2026-05-21 14:11:47 -07:00
|
|
|
const metrics: Record<string, number> = {
|
2025-08-15 10:00:00 -07:00
|
|
|
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,
|
|
|
|
|
};
|
2026-05-21 14:11:47 -07:00
|
|
|
const overflowLeft = Math.max(0, sRect.left - rRect.left);
|
|
|
|
|
const overflowTop = Math.max(0, sRect.top - rRect.top);
|
|
|
|
|
const overflowRight = Math.max(0, rRect.right - sRect.right);
|
|
|
|
|
const overflowBottom = Math.max(0, rRect.bottom - sRect.bottom);
|
|
|
|
|
if (overflowLeft > 0 || overflowTop > 0 || overflowRight > 0 || overflowBottom > 0) {
|
|
|
|
|
if (world.clipping?.subjectId) {
|
|
|
|
|
for (let i = 0; i < world.clipping.subjectId.length; i++) {
|
|
|
|
|
if (world.clipping.subjectId[i] === subjectId) {
|
|
|
|
|
metrics.hasClippedOverflow = 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-15 10:00:00 -07:00
|
|
|
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
|
2026-05-22 10:20:06 -07:00
|
|
|
const clipRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (clipRoot < 0) return makePredicateResult('indeterminate');
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[17]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
2026-05-22 10:20:06 -07:00
|
|
|
const sc = getTopologyValueBySubject(world, subjectId, world.topology.stackingContextOf);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (sc < 0) return makePredicateResult('indeterminate');
|
2026-05-21 17:10:38 -07:00
|
|
|
const subjectHasSC = sc > 0;
|
|
|
|
|
|
|
|
|
|
if (tuple.length >= 2 && tuple[1] !== undefined && tuple[1] !== 0) {
|
|
|
|
|
const referenceId = tuple[1];
|
2026-05-22 10:20:06 -07:00
|
|
|
const refSC = getTopologyValueBySubject(world, referenceId, world.topology.stackingContextOf);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (refSC < 0) return makePredicateResult('indeterminate');
|
2026-05-21 17:10:38 -07:00
|
|
|
const pass = subjectHasSC && refSC > 0 && sc === refSC;
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc, referenceStackingContext: refSC }, [subjectId, referenceId]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pass = subjectHasSC;
|
2025-08-15 10:00:00 -07:00
|
|
|
return makePredicateResult(pass ? 'true' : 'false', { stackingContext: sc }, [subjectId]);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-21 20:10:17 -07:00
|
|
|
export const attachedToScrollContainerPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[15]!,
|
|
|
|
|
evaluateTuple(world, tuple) {
|
2026-05-22 10:20:06 -07:00
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
2026-05-21 20:10:17 -07:00
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
2026-05-22 10:20:06 -07:00
|
|
|
const scrollContainer = getTopologyValueBySubject(world, subjectId, world.topology.scrollContainerOf);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (scrollContainer < 0) return makePredicateResult('indeterminate');
|
2026-05-22 10:20:06 -07:00
|
|
|
const pass = scrollContainer > 0 && scrollContainer === referenceId;
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', { scrollContainer, referenceId }, [subjectId]);
|
2026-05-21 20:10:17 -07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const escapeClippingChainOfPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[16]!,
|
2026-05-22 10:20:06 -07:00
|
|
|
evaluateTuple(world, tuple) {
|
|
|
|
|
const [subjectId, referenceId] = tuple;
|
|
|
|
|
if (subjectId === undefined || referenceId === undefined) {
|
2026-05-21 20:10:17 -07:00
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
2026-05-22 10:20:06 -07:00
|
|
|
const sRect = getBorderRect(world, subjectId);
|
|
|
|
|
const clipRect = getClipRectForSubject(world, referenceId);
|
|
|
|
|
if (!sRect || !clipRect) {
|
|
|
|
|
return makePredicateResult('indeterminate', { hasClipRect: clipRect ? 1 : 0 }, [subjectId, referenceId]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ancestry = isDomAncestor(world, referenceId, subjectId);
|
|
|
|
|
const overflowLeft = Math.max(0, clipRect.left - sRect.left);
|
|
|
|
|
const overflowTop = Math.max(0, clipRect.top - sRect.top);
|
|
|
|
|
const overflowRight = Math.max(0, sRect.right - clipRect.right);
|
|
|
|
|
const overflowBottom = Math.max(0, sRect.bottom - clipRect.bottom);
|
|
|
|
|
const overflow = overflowLeft + overflowTop + overflowRight + overflowBottom;
|
|
|
|
|
const clippingRoot = getTopologyValueBySubject(world, subjectId, world.topology.clippingRootOf);
|
2026-05-22 13:55:11 -07:00
|
|
|
if (clippingRoot < 0) return makePredicateResult('indeterminate', { hasClippingRoot: 0 }, [subjectId, referenceId]);
|
2026-05-22 10:20:06 -07:00
|
|
|
const clippedByReference = clippingRoot === referenceId;
|
|
|
|
|
const pass = overflow > 0 && !clippedByReference && ancestry !== false;
|
|
|
|
|
const metrics: Record<string, number> = {
|
|
|
|
|
overflowLeft,
|
|
|
|
|
overflowTop,
|
|
|
|
|
overflowRight,
|
|
|
|
|
overflowBottom,
|
|
|
|
|
overflow,
|
|
|
|
|
clipKind: clipRect.kind,
|
|
|
|
|
clippingRoot,
|
|
|
|
|
clippedByReference: clippedByReference ? 1 : 0,
|
|
|
|
|
referenceIsAncestor: ancestry === undefined ? -1 : ancestry ? 1 : 0,
|
|
|
|
|
};
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('escapeClippingChainOf', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
2026-05-21 20:10:17 -07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const aspectRatioPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[19]!,
|
|
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
const subjectId = tuple[0];
|
|
|
|
|
if (subjectId === undefined) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const boxId = world.subjects.primaryBoxId[subjectId - 1];
|
|
|
|
|
if (boxId === undefined || boxId < 0) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const w = world.boxes.contentRight[boxId] - world.boxes.contentLeft[boxId];
|
|
|
|
|
const h = world.boxes.contentBottom[boxId] - world.boxes.contentTop[boxId];
|
|
|
|
|
if (h <= 0) {
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
}
|
|
|
|
|
const ratio = w / h;
|
|
|
|
|
const min = (options as any)?.min as number | undefined;
|
|
|
|
|
const max = (options as any)?.max as number | undefined;
|
|
|
|
|
if (min !== undefined && ratio < min) {
|
|
|
|
|
return makePredicateResult('false', { aspectRatio: ratio, min }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
if (max !== undefined && ratio > max) {
|
|
|
|
|
return makePredicateResult('false', { aspectRatio: ratio, max }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
if (min !== undefined || max !== undefined) {
|
|
|
|
|
return makePredicateResult('true', { aspectRatio: ratio, ...(min !== undefined ? { min } : {}), ...(max !== undefined ? { max } : {}) }, [subjectId]);
|
|
|
|
|
}
|
|
|
|
|
return makePredicateResult('indeterminate');
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[20]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[21]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[22]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[23]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[24]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[25]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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 = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[26]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return insidePredicate.evaluateTuple(world, tuple, options);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const separatedFromPredicate: PredicateEvaluator = {
|
2026-05-21 20:10:17 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[18]!,
|
2025-08-15 10:00:00 -07:00
|
|
|
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]);
|
|
|
|
|
},
|
|
|
|
|
};
|
2026-05-21 14:36:02 -07:00
|
|
|
export const leftAlignedWithPredicate: PredicateEvaluator = {
|
2026-05-22 10:20:06 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[27]!,
|
2026-05-21 14:36:02 -07:00
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'left' });
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const rightAlignedWithPredicate: PredicateEvaluator = {
|
2026-05-22 10:20:06 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[28]!,
|
2026-05-21 14:36:02 -07:00
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'right' });
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const topAlignedWithPredicate: PredicateEvaluator = {
|
2026-05-22 10:20:06 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[29]!,
|
2026-05-21 14:36:02 -07:00
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'top' });
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const bottomAlignedWithPredicate: PredicateEvaluator = {
|
2026-05-22 10:20:06 -07:00
|
|
|
descriptor: BUILTIN_PREDICATES[30]!,
|
2026-05-21 14:36:02 -07:00
|
|
|
evaluateTuple(world, tuple, options) {
|
|
|
|
|
return alignedWithPredicate.evaluateTuple(world, tuple, { ...options as Record<string, unknown>, axis: 'bottom' });
|
|
|
|
|
},
|
|
|
|
|
};
|
2025-08-15 10:00:00 -07:00
|
|
|
|
2026-05-22 10:20:06 -07:00
|
|
|
export const intersectsPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[31]!,
|
|
|
|
|
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 metrics = rectOverlapMetrics(sRect, rRect);
|
|
|
|
|
const pass = sRect.left <= rRect.right && sRect.right >= rRect.left
|
|
|
|
|
&& sRect.top <= rRect.bottom && sRect.bottom >= rRect.top;
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('intersects', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const touchesPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[32]!,
|
|
|
|
|
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 metrics = rectOverlapMetrics(sRect, rRect);
|
|
|
|
|
const intersects = sRect.left <= rRect.right && sRect.right >= rRect.left
|
|
|
|
|
&& sRect.top <= rRect.bottom && sRect.bottom >= rRect.top;
|
|
|
|
|
const overlapsArea = metrics.overlapArea > 0;
|
|
|
|
|
const pass = intersects && !overlapsArea;
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('touches', metrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', metrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const hasGapPredicate: PredicateEvaluator = {
|
|
|
|
|
descriptor: BUILTIN_PREDICATES[33]!,
|
|
|
|
|
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 metrics = rectOverlapMetrics(sRect, rRect);
|
|
|
|
|
const minGap = (options?.minGap as number | undefined) ?? 0;
|
|
|
|
|
const maxGap = (options?.maxGap as number | undefined) ?? Infinity;
|
|
|
|
|
const separated = metrics.gap > 0;
|
|
|
|
|
const pass = separated && metrics.gap >= minGap && metrics.gap <= maxGap;
|
|
|
|
|
const resultMetrics = { ...metrics, minGap, maxGap };
|
|
|
|
|
const diagnostics = pass ? undefined : makePredicateDiagnostic('hasGap', resultMetrics, [subjectId, referenceId]);
|
|
|
|
|
return makePredicateResult(pass ? 'true' : 'false', resultMetrics, [subjectId, referenceId], diagnostics);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-15 10:00:00 -07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Register Defaults
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-05-22 13:52:09 -07:00
|
|
|
/** Sentinel registered to detect if defaults were already installed. */
|
|
|
|
|
const DEFAULT_SENTINEL = '__imhotep_defaults_registered__'
|
2025-08-15 10:00:00 -07:00
|
|
|
|
|
|
|
|
export function registerDefaultPredicates(): void {
|
2026-05-22 13:52:09 -07:00
|
|
|
if (globalPredicateRegistry.get(DEFAULT_SENTINEL)) return
|
|
|
|
|
// Register sentinel first so partial failures don't cause infinite loops.
|
|
|
|
|
globalPredicateRegistry.register({
|
|
|
|
|
descriptor: { name: DEFAULT_SENTINEL, arity: 0, domains: [], requiredFacts: [] },
|
|
|
|
|
evaluateTuple: () => ({ truth: 'indeterminate' }),
|
|
|
|
|
})
|
2025-08-15 10:00:00 -07:00
|
|
|
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);
|
2026-05-21 20:10:17 -07:00
|
|
|
registerPredicate(attachedToScrollContainerPredicate);
|
|
|
|
|
registerPredicate(escapeClippingChainOfPredicate);
|
|
|
|
|
registerPredicate(aspectRatioPredicate);
|
2025-08-15 10:00:00 -07:00
|
|
|
registerPredicate(inStackingContextPredicate);
|
|
|
|
|
registerPredicate(separatedFromPredicate);
|
|
|
|
|
registerPredicate(leftAlignedWithPredicate);
|
|
|
|
|
registerPredicate(rightAlignedWithPredicate);
|
|
|
|
|
registerPredicate(topAlignedWithPredicate);
|
|
|
|
|
registerPredicate(bottomAlignedWithPredicate);
|
2026-05-22 10:20:06 -07:00
|
|
|
registerPredicate(intersectsPredicate);
|
|
|
|
|
registerPredicate(touchesPredicate);
|
|
|
|
|
registerPredicate(hasGapPredicate);
|
2025-08-15 10:00:00 -07:00
|
|
|
registerPredicate(besidePredicate);
|
|
|
|
|
registerPredicate(nextToPredicate);
|
|
|
|
|
registerPredicate(adjacentPredicate);
|
|
|
|
|
registerPredicate(touchingPredicate);
|
|
|
|
|
registerPredicate(nearPredicate);
|
|
|
|
|
registerPredicate(underPredicate);
|
|
|
|
|
registerPredicate(withinPredicate);
|
|
|
|
|
}
|