/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Unified filter rule taxonomy. * * Ported from the Tauri-side `filter.rs` engine and consumed by the * in-memory path-B runtime evaluator (`filter-evaluate.ts`). The * discriminated-union shape lets the chip UI serialise any rule as a * JSON object with a `"kind"` discriminator, mirroring serde's tagged * enum encoding. We use `kind` rather than `type` because `type` * collides with the IFC `type` attribute name on element rows. */ // ── Operator enums ──────────────────────────────────────────────────────────── /** Set-membership: storey, ifcType, predefinedType. */ export type SetOp = 'in' | 'notIn'; /** String comparisons (Name rule). */ export type StringOp = 'eq' | 'ne' | 'contains' | 'notContains' | 'startsWith'; /** Numeric comparisons (Quantity rule). */ export type NumericOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'; /** Mixed string+numeric+presence ops for Property values. */ export type ValueOp = | 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'notContains' | 'isSet' | 'isNotSet'; /** Classification value+presence ops. A classification is matched against * its code / name string, so this is the StringOp comparison subset plus * presence — numeric ops don't apply. */ export type ClassificationOp = 'eq' | 'ne' | 'contains' | 'notContains' | 'isSet' | 'isNotSet'; /** Top-level rule combinator. */ export type Combinator = 'AND' | 'OR'; // ── Rule discriminated union ────────────────────────────────────────────────── export interface StoreyRule { kind: 'storey'; values: string[]; op: SetOp; } export interface IfcTypeRule { kind: 'ifcType'; values: string[]; op: SetOp; } export interface PredefinedTypeRule { kind: 'predefinedType'; values: string[]; op: SetOp; } export interface NameRule { kind: 'name'; op: StringOp; value: string; } export interface PropertyRule { kind: 'property'; setName: string; propertyName: string; op: ValueOp; /** Raw user input. Numeric ops parse as f64; isSet/isNotSet ignore. */ value: string; } export interface QuantityRule { kind: 'quantity'; setName: string; quantityName: string; op: NumericOp; value: number; } /** Match against an element's material name(s) — top-level material, * layer / constituent / profile names, and list members. Multi-valued: * the evaluator matches if ANY name satisfies a positive op, or NONE * violates a negative op (ne / notContains). */ export interface MaterialRule { kind: 'material'; op: StringOp; value: string; } /** Match against an element's classification references (e.g. Uniclass, * OmniClass). `system` optionally scopes to one classification system; * the value matches a reference's code (identification) OR name. */ export interface ClassificationRule { kind: 'classification'; /** Optional system scope (e.g. "Uniclass 2015"). Empty = any system. */ system?: string; op: ClassificationOp; /** Matched against identification (code) OR name. Ignored for isSet/isNotSet. */ value: string; } /** Match against an element's elevation in metres — derived from the * elevation of the building storey the element belongs to. */ export interface ElevationRule { kind: 'elevation'; op: NumericOp; /** Threshold in metres. */ value: number; } export type FilterRule = | StoreyRule | IfcTypeRule | PredefinedTypeRule | NameRule | PropertyRule | QuantityRule | MaterialRule | ClassificationRule | ElevationRule; // ── Pure op helpers (ported verbatim from filter.rs) ────────────────────────── /** Lower-case a candidate that may be undefined at runtime (e.g. an untyped * entity's `getTypeName`) — calling `.toLowerCase()` on that crashed filtering * by type/name (#1195). Coercing nullish to '' is a no-op for real strings. */ function lower(s: string | null | undefined): string { return (s ?? '').toLowerCase(); } export function setOpMatches(op: SetOp, candidate: string, values: readonly string[]): boolean { const c = lower(candidate); const hit = values.some((v) => lower(v) === c); return op === 'in' ? hit : !hit; } export function stringOpMatches(op: StringOp, candidate: string, value: string): boolean { const a = lower(candidate); const b = lower(value); switch (op) { case 'eq': return a === b; case 'ne': return a !== b; case 'contains': return a.includes(b); case 'notContains': return !a.includes(b); case 'startsWith': return a.startsWith(b); } } /** * Match a StringOp against a *set* of candidate strings — used for * multi-valued dimensions (an element's material names, a classification's * code+name pair). Positive ops (eq / contains / startsWith) match if ANY * candidate satisfies them; negative ops (ne / notContains) match only if * NO candidate violates them. An empty candidate set never matches: an * element with no materials/classifications shouldn't satisfy a filter on * that dimension (including the negative ops). */ export function matchStringAnyNone( op: StringOp, candidates: readonly string[], value: string, ): boolean { if (candidates.length === 0) return false; switch (op) { case 'eq': case 'contains': case 'startsWith': return candidates.some((c) => stringOpMatches(op, c, value)); case 'ne': return candidates.every((c) => stringOpMatches('eq', c, value) === false); case 'notContains': return candidates.every((c) => stringOpMatches('contains', c, value) === false); } } export function numericOpMatches(op: NumericOp, candidate: number, value: number): boolean { // The Rust side uses 1e-9 as the epsilon for eq/ne. Match it here for // IDS-style parity — IFC quantities are stored as IFC4 IfcReal so the // tolerance is large enough to absorb f32→f64 rounding from the parser. const EPS = 1e-9; switch (op) { case 'eq': return Math.abs(candidate - value) < EPS; case 'ne': return Math.abs(candidate - value) >= EPS; case 'gt': return candidate > value; case 'gte': return candidate >= value; case 'lt': return candidate < value; case 'lte': return candidate <= value; } } /** * Evaluate a Property ValueOp against the candidate's raw stringified * value. `isSet`/`isNotSet` are presence checks and the property layer * (filter-evaluate.ts) decides them before calling here — but we still * accept them so the function is total. */ export function valueOpMatches(op: ValueOp, psetVal: string, ruleVal: string): boolean { switch (op) { case 'isSet': return (psetVal ?? '').length > 0; case 'isNotSet': return (psetVal ?? '').length === 0; case 'eq': return lower(psetVal) === lower(ruleVal); case 'ne': return lower(psetVal) !== lower(ruleVal); case 'contains': return lower(psetVal).includes(lower(ruleVal)); case 'notContains': return !lower(psetVal).includes(lower(ruleVal)); case 'gt': case 'gte': case 'lt': case 'lte': { const cv = Number.parseFloat(psetVal); const rv = Number.parseFloat(ruleVal); if (!Number.isFinite(cv) || !Number.isFinite(rv)) return false; return numericOpMatches(op, cv, rv); } } } // ── Combinator helpers ──────────────────────────────────────────────────────── /** Combine an array of per-rule booleans according to AND/OR semantics. */ export function combineRuleResults(combinator: Combinator, results: readonly boolean[]): boolean { if (results.length === 0) return false; return combinator === 'AND' ? results.every((r) => r) : results.some((r) => r); } // ── Convenience constructors ────────────────────────────────────────────────── // // The chip UI builds rules via `set*` slice actions (see searchSlice.ts); // these helpers exist primarily for tests and for code paths that synthesize // rules from a different representation (URL state, presets). export const Rule = { storey: (values: string[], op: SetOp = 'in'): StoreyRule => ({ kind: 'storey', values, op }), ifcType: (values: string[], op: SetOp = 'in'): IfcTypeRule => ({ kind: 'ifcType', values, op }), predefinedType: (values: string[], op: SetOp = 'in'): PredefinedTypeRule => ({ kind: 'predefinedType', values, op }), name: (op: StringOp, value: string): NameRule => ({ kind: 'name', op, value }), property: (setName: string, propertyName: string, op: ValueOp, value: string): PropertyRule => ({ kind: 'property', setName, propertyName, op, value }), quantity: (setName: string, quantityName: string, op: NumericOp, value: number): QuantityRule => ({ kind: 'quantity', setName, quantityName, op, value }), material: (op: StringOp, value: string): MaterialRule => ({ kind: 'material', op, value }), classification: (system: string, op: ClassificationOp, value: string): ClassificationRule => ({ kind: 'classification', system: system || undefined, op, value }), elevation: (op: NumericOp, value: number): ElevationRule => ({ kind: 'elevation', op, value }), } as const; // ── JSON guards ────────────────────────────────────────────────────────────── export function isFilterRule(value: unknown): value is FilterRule { if (typeof value !== 'object' || value === null) return false; const kind = (value as { kind?: unknown }).kind; return ( kind === 'storey' || kind === 'ifcType' || kind === 'predefinedType' || kind === 'name' || kind === 'property' || kind === 'quantity' || kind === 'material' || kind === 'classification' || kind === 'elevation' ); } export function parseFilterRules(raw: unknown): FilterRule[] { if (!Array.isArray(raw)) return []; return raw.filter(isFilterRule); }