Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/ifcx-core/geometry/attribute-table.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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");
}
}
62 changes: 62 additions & 0 deletions src/ifcx-core/geometry/geometry-tiers.ts
Original file line number Diff line number Diff line change
@@ -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<GeometryTier, string> = {
procedural: "ifcx.geom.proc",
mesh: "ifcx.geom.mesh",
brep: "ifcx.geom.brep",
external: "ifcx.geom.ext",
};
70 changes: 70 additions & 0 deletions src/ifcx-core/geometry/tier-resolver.ts
Original file line number Diff line number Diff line change
@@ -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<GeometryTier>;
private _accessLog: Set<string> = 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<string> {
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<DisplayMesh>(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<string, AttributeTable> = 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;
}
}
97 changes: 97 additions & 0 deletions src/test/mesh-core-test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions src/test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();