diff --git a/src/ifcx-core/geometry/attribute-table.ts b/src/ifcx-core/geometry/attribute-table.ts new file mode 100644 index 00000000..9aad14d1 --- /dev/null +++ b/src/ifcx-core/geometry/attribute-table.ts @@ -0,0 +1,38 @@ +// NDJSON attribute table: one JSON object per line, accessed by line index + +export class AttributeTable { + private lines: string[]; + public readonly filename: string; + + constructor(filename: string, ndjsonContent: string) { + this.filename = filename; + // Split by newline, filter out empty trailing lines + this.lines = ndjsonContent.split("\n").filter((line, i, arr) => { + return line.length > 0 || i < arr.length - 1; + }); + } + + get length(): number { + return this.lines.length; + } + + readRaw(index: number): string { + if (index < 0 || index >= this.lines.length) { + throw new Error(`Component index ${index} out of range [0, ${this.lines.length}) in table "${this.filename}"`); + } + return this.lines[index]; + } + + read(index: number): T { + return JSON.parse(this.readRaw(index)) as T; + } + + static fromEntries(filename: string, entries: unknown[]): AttributeTable { + const ndjson = entries.map(e => JSON.stringify(e)).join("\n"); + return new AttributeTable(filename, ndjson); + } + + toNDJSON(): string { + return this.lines.join("\n"); + } +} diff --git a/src/ifcx-core/geometry/geometry-tiers.ts b/src/ifcx-core/geometry/geometry-tiers.ts new file mode 100644 index 00000000..a720ce6a --- /dev/null +++ b/src/ifcx-core/geometry/geometry-tiers.ts @@ -0,0 +1,62 @@ +// Tier-M display mesh + the tier routing keys — the minimal geometry core. +// A Tier-M mesh is a plain JSON triangle mesh (points + triangle indices) that +// maps to both UsdGeomMesh and glTF and is readable without a USD/glTF runtime. +// The procedural (P) and Brep (B) catalogs are separate, proposed independently. + +// ============================================================================= +// Tier M — Display mesh +// ============================================================================= + +export type MeshSourceTier = "procedural" | "brep"; + +/** + * Maps a contiguous slice of `faceVertexIndices` back to the source face that + * produced it. Present when the mesh was derived from a Brep — lets a consumer + * apply per-face data authored on the source face node. + */ +export interface MeshFaceGroup { + /** Start index in faceVertexIndices (multiple of 3) */ + start: number; + /** Number of indices in this group (multiple of 3) */ + count: number; + /** Position of the face in the source faces[] array */ + faceIndex: number; + /** Stable name of the source face node (e.g. "Face_3"). */ + faceName?: string; +} + +export interface DisplayMesh { + /** Vertex positions, one [x, y, z] per entry. */ + points: number[][]; + /** Triangle list: every three consecutive entries index `points` to form one + * triangle (face counts are implicit — this tier is triangles only). */ + faceVertexIndices: number[]; + normals?: number[][]; + uvs?: number[][]; + /** Higher tier this mesh was derived from, if any. Absent means authored. */ + derivedFrom?: MeshSourceTier; + /** Tessellation tolerance in source units */ + tolerance?: number; + /** Opaque content hash of the source-tier record, for staleness/cache + * validation. The hashing scheme is proposed separately. */ + sourceHash?: string; + /** Triangle-range → source-face mapping */ + faceGroups?: MeshFaceGroup[]; +} + +// ============================================================================= +// Tier identifiers and table mapping +// ============================================================================= + +// The tier taxonomy is the routing contract: each tier maps to a table name so +// a consumer can route by tier. Payload schemas for procedural/brep/external +// are out of scope for this module; their keys are reserved here so the routing +// contract is complete. `external` is reserved for opaque interop references. +export type GeometryTier = "procedural" | "mesh" | "brep" | "external"; + +export const TIER_TABLE_NAMES: Record = { + procedural: "ifcx.geom.proc", + mesh: "ifcx.geom.mesh", + brep: "ifcx.geom.brep", + external: "ifcx.geom.ext", +}; diff --git a/src/ifcx-core/geometry/tier-resolver.ts b/src/ifcx-core/geometry/tier-resolver.ts new file mode 100644 index 00000000..99e0b643 --- /dev/null +++ b/src/ifcx-core/geometry/tier-resolver.ts @@ -0,0 +1,70 @@ +// Tier-aware resolver that loads only requested geometry tiers from attribute +// tables. Enforces the core constraint: a consumer that requests only Tier M +// never reads any other tier's table. + +import { AttributeTable } from "./attribute-table"; +import { DisplayMesh, GeometryTier, TIER_TABLE_NAMES } from "./geometry-tiers"; + +export interface AttributeTableProvider { + getTable(filename: string): AttributeTable | null; +} + +export class TierResolver { + private provider: AttributeTableProvider; + private allowedTiers: Set; + private _accessLog: Set = new Set(); + + constructor(provider: AttributeTableProvider, requestedTiers: GeometryTier[]) { + this.provider = provider; + this.allowedTiers = new Set(requestedTiers); + } + + /** Table names the resolver read, in access order. */ + get accessLog(): ReadonlySet { + return this._accessLog; + } + + private getTableForTier(tier: GeometryTier): AttributeTable | null { + if (!this.allowedTiers.has(tier)) { + return null; + } + const tableName = TIER_TABLE_NAMES[tier]; + const table = this.provider.getTable(tableName); + if (table) this._accessLog.add(tableName); + return table; + } + + resolveDisplayMesh(componentIndex: number): DisplayMesh | null { + const table = this.getTableForTier("mesh"); + if (!table) return null; + return table.read(componentIndex); + } + + /** + * Resolve a row from the tier table named by typeID, gated by the requested + * tiers: returns null if typeID is not a known tier, or its tier was not + * requested. So a mesh-only resolver never reads another tier's table. + */ + resolveByRef(typeID: string, componentIndex: number): unknown { + for (const [tier, tableName] of Object.entries(TIER_TABLE_NAMES)) { + if (typeID === tableName) { + const table = this.getTableForTier(tier as GeometryTier); + return table ? table.read(componentIndex) : null; + } + } + return null; + } +} + +export class InMemoryTableProvider implements AttributeTableProvider { + private tables: Map = new Map(); + + addTable(table: AttributeTable): this { + this.tables.set(table.filename, table); + return this; + } + + getTable(filename: string): AttributeTable | null { + return this.tables.get(filename) ?? null; + } +} diff --git a/src/test/mesh-core-test.ts b/src/test/mesh-core-test.ts new file mode 100644 index 00000000..fd676a8b --- /dev/null +++ b/src/test/mesh-core-test.ts @@ -0,0 +1,97 @@ +import { describe, it } from "./util/cappucino"; +import { expect } from "chai"; + +import { AttributeTable } from "../ifcx-core/geometry/attribute-table"; +import { InMemoryTableProvider, TierResolver } from "../ifcx-core/geometry/tier-resolver"; +import { DisplayMesh, TIER_TABLE_NAMES } from "../ifcx-core/geometry/geometry-tiers"; + +// ── NDJSON attribute table ── + +describe("attribute table", () => { + it("reads entries by index", () => { + const ndjson = '{"a":1}\n{"a":2}\n{"a":3}'; + const table = new AttributeTable("test", ndjson); + expect(table.length).to.equal(3); + expect(table.read<{ a: number }>(0).a).to.equal(1); + expect(table.read<{ a: number }>(2).a).to.equal(3); + }); + + it("throws on out-of-range index", () => { + const table = new AttributeTable("test", '{"a":1}'); + expect(() => table.read(5)).to.throw(); + }); + + it("round-trips from entries", () => { + const entries = [{ x: 1 }, { x: 2 }]; + const table = AttributeTable.fromEntries("test", entries); + expect(table.length).to.equal(2); + expect(table.read<{ x: number }>(1).x).to.equal(2); + expect(table.toNDJSON()).to.equal('{"x":1}\n{"x":2}'); + }); +}); + +// ── Tier resolver — selective loading ── +// The procedural rows are opaque on purpose: this module ships only the mesh +// type + the tier routing contract, so the guarantee under test is which tables +// are touched, not the payload shape. + +describe("tier resolver", () => { + function makeMeshTable(): AttributeTable { + const meshes: DisplayMesh[] = [ + { points: [[0, 0, 0], [1, 0, 0], [1, 1, 0]], faceVertexIndices: [0, 1, 2] }, + { points: [[0, 0, 0], [2, 0, 0], [2, 2, 0]], faceVertexIndices: [0, 1, 2], derivedFrom: "procedural", tolerance: 0.001 }, + ]; + return AttributeTable.fromEntries(TIER_TABLE_NAMES.mesh, meshes); + } + + function makeProcTable(): AttributeTable { + return AttributeTable.fromEntries(TIER_TABLE_NAMES.procedural, [{ opaque: 1 }]); + } + + function makeProvider(): InMemoryTableProvider { + return new InMemoryTableProvider().addTable(makeMeshTable()).addTable(makeProcTable()); + } + + it("resolves display mesh when mesh tier is requested", () => { + const provider = makeProvider(); + const resolver = new TierResolver(provider, ["mesh"]); + const mesh = resolver.resolveDisplayMesh(0); + expect(mesh).to.not.be.null; + expect(mesh!.points.length).to.equal(3); + expect(mesh!.faceVertexIndices).to.deep.equal([0, 1, 2]); + }); + + it("a mesh-only consumer never reads the procedural table", () => { + const provider = makeProvider(); + const resolver = new TierResolver(provider, ["mesh"]); + + resolver.resolveDisplayMesh(0); + resolver.resolveDisplayMesh(1); + // Even an explicit request for a non-allowed tier is refused, not read. + expect(resolver.resolveByRef(TIER_TABLE_NAMES.procedural, 0)).to.be.null; + + expect(resolver.accessLog.has(TIER_TABLE_NAMES.mesh)).to.be.true; + expect(resolver.accessLog.has(TIER_TABLE_NAMES.procedural)).to.be.false; + }); + + it("analysis config loads only procedural, not mesh", () => { + const provider = makeProvider(); + const resolver = new TierResolver(provider, ["procedural"]); + + expect(resolver.resolveByRef(TIER_TABLE_NAMES.procedural, 0)).to.not.be.null; + expect(resolver.resolveDisplayMesh(0)).to.be.null; + + expect(resolver.accessLog.has(TIER_TABLE_NAMES.procedural)).to.be.true; + expect(resolver.accessLog.has(TIER_TABLE_NAMES.mesh)).to.be.false; + }); + + it("preserves mesh derivation metadata", () => { + const provider = new InMemoryTableProvider().addTable(makeMeshTable()); + const resolver = new TierResolver(provider, ["mesh"]); + + expect(resolver.resolveDisplayMesh(0)!.derivedFrom).to.be.undefined; + const mesh1 = resolver.resolveDisplayMesh(1); + expect(mesh1!.derivedFrom).to.equal("procedural"); + expect(mesh1!.tolerance).to.equal(0.001); + }); +}); diff --git a/src/test/test.ts b/src/test/test.ts index a56158e1..810d500c 100644 --- a/src/test/test.ts +++ b/src/test/test.ts @@ -5,11 +5,13 @@ import * as ExampleFileTest from "./example-file-test" import * as SchemaTest from "./schema-test" import * as WorkflowsTest from "./workflows-test" import * as LayerStackTest from "./layer-stack-test" +import * as MeshCoreTest from "./mesh-core-test" ComposeAlphaTest ExampleFileTest SchemaTest WorkflowsTest LayerStackTest +MeshCoreTest test(); \ No newline at end of file