From 2cc0a2648465cacde340b1510557ea5004393b2d Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Sun, 11 Oct 2026 21:20:33 -0400 Subject: [PATCH 1/7] Add declarative spatial rules and yaw-aware spatial geometry Spatial geometry now honours rotationZ. A shared oriented-box module (shared/spatial-geometry.ts, mirrored in frontend/src/services/spatial) builds each placed element's rotated footprint extruded over its vertical interval. The JS/OCL spatial helpers keep their names and signatures but use separating-axis overlap tests and exact convex-polygon clearance, so rotated elements no longer get axis-aligned answers. Metamodels gain spatialRules: noOverlap, minClearance, maxDistance, within, and adjacentTo over placed instances of a source and a target metaclass (subclasses included), in plan or volume mode. A pure evaluator (shared/spatial-rules.ts plus the frontend mirror) runs in browser validation, which marks both elements of an offending pair in 2D and 3D, and in the backend validation engine used by the headless pipeline. A parity test keeps the two copies identical and checks that they report the same issues. Rules are persisted in a new metamodels.spatialRules column, validated on create and update, carried through checkpoints and metamodel evolution (a rule on a removed class blocks the evolution), removed with a deleted class, and exported to Ecore as the EPackage annotation spatialdsl/spatialRules. The metamodel editor gains a Spatial Rules section with an add/edit dialog. The Smart Warehouse example declares a FixedEquipment supertype and four rules that its bundled model satisfies, and its unbounded controller references now use '*'. --- README.md | 1 + .../migration.sql | 2 + backend/prisma/schema.prisma | 3 + .../metamodel-evolution.service.test.ts | 47 +++ .../services/metamodel.service.test.ts | 81 +++- .../services/pipeline-engines.test.ts | 64 +++ .../project-checkpoint.service.test.ts | 58 +++ .../services/sharing.service.test.ts | 2 + .../services/sirius-interop.service.test.ts | 24 ++ .../services/spatial-rules-parity.test.ts | 155 +++++++ .../lifecycle/metamodel-evolution.service.ts | 17 + .../lifecycle/project-checkpoint.service.ts | 7 + backend/src/services/metamodel.service.ts | 28 +- .../pipeline/headless-pipeline.service.ts | 1 + .../pipeline/model-validation.engine.ts | 18 +- .../src/services/sirius-interop.service.ts | 28 ++ docs/README.md | 26 +- docs/reference/api.md | 3 + docs/reference/data-model.md | 1 + docs/user-guide/metamodels.md | 9 + docs/user-guide/spatial-rules.md | 168 ++++++++ .../components/SpatialRulesEditor.test.tsx | 113 +++++ .../services/ecore-xmi-interop.test.ts | 64 +++ .../services/spatial-helpers.test.ts | 110 ++++- .../__tests__/services/spatial-rules.test.ts | 365 ++++++++++++++++ .../services/view-validation.test.ts | 17 + .../metamodel/SpatialRulesEditor.tsx | 391 ++++++++++++++++++ .../components/sidebar/MetamodelSidebar.tsx | 7 + .../data/smart-warehouse-metamodel.json | 77 +++- frontend/src/models/types.ts | 25 ++ .../services/constraint/spatial.helpers.ts | 122 ++---- .../diagram/view-validation.service.ts | 15 +- .../src/services/metamodel/ecore.service.ts | 78 ++++ .../metamodel/metaclass-crud.service.ts | 5 + .../metamodel/metamodel-api-sync.service.ts | 2 + .../services/metamodel/metamodel.service.ts | 6 +- .../model/model-validation.service.ts | 17 + frontend/src/services/spatial/index.ts | 2 + .../src/services/spatial/spatial-geometry.ts | 241 +++++++++++ .../src/services/spatial/spatial-rules.ts | 311 ++++++++++++++ shared/spatial-geometry.ts | 241 +++++++++++ shared/spatial-rules.ts | 311 ++++++++++++++ shared/types/index.ts | 30 ++ 43 files changed, 3166 insertions(+), 127 deletions(-) create mode 100644 backend/prisma/migrations/20261011000000_add_metamodel_spatial_rules/migration.sql create mode 100644 backend/src/__tests__/services/spatial-rules-parity.test.ts create mode 100644 docs/user-guide/spatial-rules.md create mode 100644 frontend/src/__tests__/components/SpatialRulesEditor.test.tsx create mode 100644 frontend/src/__tests__/services/spatial-rules.test.ts create mode 100644 frontend/src/components/metamodel/SpatialRulesEditor.tsx create mode 100644 frontend/src/services/spatial/spatial-geometry.ts create mode 100644 frontend/src/services/spatial/spatial-rules.ts create mode 100644 shared/spatial-geometry.ts create mode 100644 shared/spatial-rules.ts diff --git a/README.md b/README.md index d147525..891fb22 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ A full-stack model-driven engineering platform for designing domain-specific lan You can: - Define metamodels (classes, attributes, references, constraints) +- Declare spatial rules (no overlap, minimum clearance, maximum distance, within, adjacent to) as part of the language, checked in the editor and the headless pipeline - Import and export common EMF Ecore metamodels and XMI instance models - Create models that conform to those metamodels - Define Sirius-style viewpoints and diagram representation descriptions diff --git a/backend/prisma/migrations/20261011000000_add_metamodel_spatial_rules/migration.sql b/backend/prisma/migrations/20261011000000_add_metamodel_spatial_rules/migration.sql new file mode 100644 index 0000000..c471e59 --- /dev/null +++ b/backend/prisma/migrations/20261011000000_add_metamodel_spatial_rules/migration.sql @@ -0,0 +1,2 @@ +-- Declarative spatial rules are part of the metamodel (language) definition. +ALTER TABLE "metamodels" ADD COLUMN "spatialRules" JSONB NOT NULL DEFAULT '[]'; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b44e325..1e94811 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -313,6 +313,9 @@ model Metamodel { // Global constraints (OCL and JavaScript) constraints Json @default("[]") + // Declarative spatial rules over placed instances (SpatialRule[]) + spatialRules Json @default("[]") + // Foreign key to meta-metamodel package conformsToId String conformsTo EPackage @relation(fields: [conformsToId], references: [id]) diff --git a/backend/src/__tests__/services/metamodel-evolution.service.test.ts b/backend/src/__tests__/services/metamodel-evolution.service.test.ts index 2667524..5db0057 100644 --- a/backend/src/__tests__/services/metamodel-evolution.service.test.ts +++ b/backend/src/__tests__/services/metamodel-evolution.service.test.ts @@ -167,6 +167,53 @@ describe('MetamodelEvolutionService', () => { expect(allowed.blockers).toEqual([]); }); + it('blocks removing a class that a spatial rule still references', async () => { + const stationClass = { ...currentMetamodel.classes[0], id: 'station-class', name: 'Station', attributes: [] }; + const rule = { + id: 'rule-1', + name: 'Robots near stations', + kind: 'maxDistance' as const, + sourceClassId: 'robot-class', + targetClassId: 'station-class', + distanceMm: 5000, + severity: 'error' as const, + }; + prismaMock.metamodel.findFirst.mockResolvedValue({ + ...metamodelRow, + classes: [...currentMetamodel.classes, stationClass], + spatialRules: [rule], + } as any); + + const report = await metamodelEvolutionService.preview(projectId, metamodelId, { + ...currentMetamodel, + spatialRules: [rule], + }); + + expect(report.changes).toEqual([expect.objectContaining({ kind: 'class-removed', classId: 'station-class', breaking: true })]); + expect(report.blockers).toContain( + 'Spatial rule Robots near stations references removed class Station; update or delete the rule before applying this evolution' + ); + + const withoutRule = await metamodelEvolutionService.preview(projectId, metamodelId, currentMetamodel); + expect(withoutRule.blockers).toEqual([]); + }); + + it('keeps the fingerprint of rule-free metamodels and changes it when rules change', async () => { + const plain = await metamodelEvolutionService.preview(projectId, metamodelId, currentMetamodel); + const emptyRules = await metamodelEvolutionService.preview(projectId, metamodelId, { ...currentMetamodel, spatialRules: [] }); + const withRule = await metamodelEvolutionService.preview(projectId, metamodelId, { + ...currentMetamodel, + spatialRules: [{ + id: 'rule-1', name: 'Robots apart', kind: 'noOverlap', sourceClassId: 'robot-class', + targetClassId: 'robot-class', severity: 'error', + }], + }); + + expect(emptyRules.targetHash).toBe(plain.targetHash); + expect(plain.sourceHash).toBe(plain.targetHash); + expect(withRule.targetHash).not.toBe(plain.targetHash); + }); + it('rejects a stale expected source hash before creating a recovery checkpoint', async () => { const checkpointSpy = jest.spyOn(projectCheckpointService, 'create'); diff --git a/backend/src/__tests__/services/metamodel.service.test.ts b/backend/src/__tests__/services/metamodel.service.test.ts index 1040145..a6a2b54 100644 --- a/backend/src/__tests__/services/metamodel.service.test.ts +++ b/backend/src/__tests__/services/metamodel.service.test.ts @@ -31,6 +31,7 @@ const mockMetamodelRow = { classes: [], enums: [], constraints: [], + spatialRules: [], conformsToId: 'pkg-uuid-1', userId: 'user-uuid-1', projectId: null, @@ -213,6 +214,49 @@ describe('MetamodelService', () => { expect(prismaMock.metamodel.create).not.toHaveBeenCalled(); }); + it('persists valid spatial rules and returns them', async () => { + prismaMock.ePackage.findFirst.mockResolvedValue({ + id: 'pkg-uuid-1', name: 'Ecore', nsURI: 'http://ecore', nsPrefix: 'ecore', classes: [], + userId: 'user-uuid-1', projectId: null, createdAt: new Date(), updatedAt: new Date(), + }); + const classes = [{ id: 'rack', name: 'Rack', eClass: '', abstract: false, superTypes: [], attributes: [], references: [] }]; + const spatialRules = [{ + id: 'rule-1', name: 'Racks apart', kind: 'minClearance' as const, sourceClassId: 'rack', + targetClassId: 'rack', distanceMm: 1500, severity: 'error' as const, + }]; + prismaMock.metamodel.create.mockResolvedValue({ ...mockMetamodelRow, classes, spatialRules } as any); + + const result = await metamodelService.create( + { id: 'mm-uuid-1', name: 'TestMetamodel', uri: 'http://example.com/mm', prefix: 'tm', conformsTo: 'pkg-uuid-1', classes, spatialRules }, + 'user-uuid-1', + 'DSL_DESIGNER' + ); + + expect(prismaMock.metamodel.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ spatialRules }), + }); + expect(result.spatialRules).toEqual(spatialRules); + }); + + it('rejects spatial rules that miss a distance or reference unknown classes', async () => { + prismaMock.ePackage.findFirst.mockResolvedValue({ + id: 'pkg-uuid-1', name: 'Ecore', nsURI: 'http://ecore', nsPrefix: 'ecore', classes: [], + userId: 'user-uuid-1', projectId: null, createdAt: new Date(), updatedAt: new Date(), + }); + const classes = [{ id: 'rack', name: 'Rack', eClass: '', abstract: false, superTypes: [], attributes: [], references: [] }]; + const base = { id: 'mm-uuid-1', name: 'TestMetamodel', uri: 'http://example.com/mm', prefix: 'tm', conformsTo: 'pkg-uuid-1', classes }; + + await expect(metamodelService.create({ + ...base, + spatialRules: [{ id: 'rule-1', name: 'Gap', kind: 'minClearance', sourceClassId: 'rack', targetClassId: 'rack', severity: 'error' }], + }, 'user-uuid-1', 'DSL_DESIGNER')).rejects.toThrow('spatialRules[0].distanceMm is required for minClearance'); + await expect(metamodelService.create({ + ...base, + spatialRules: [{ id: 'rule-1', name: 'Apart', kind: 'noOverlap', sourceClassId: 'rack', targetClassId: 'ghost', severity: 'error' }], + }, 'user-uuid-1', 'DSL_DESIGNER')).rejects.toThrow('spatialRules[0].targetClassId references unknown metaclass ghost'); + expect(prismaMock.metamodel.create).not.toHaveBeenCalled(); + }); + it('throws 403 for MODELER role', async () => { await expect( metamodelService.create( @@ -291,6 +335,30 @@ describe('MetamodelService', () => { expect(result.name).toBe('Updated Metamodel'); }); + it('validates updated spatial rules against the stored classes', async () => { + sharingServiceMock.checkAccess.mockResolvedValue({ hasAccess: true, isOwner: true }); + prismaMock.metamodel.findUnique.mockResolvedValue({ + ...mockMetamodelRow, + classes: [{ id: 'rack', name: 'Rack', abstract: false, superTypes: [], attributes: [], references: [] }], + } as any); + const spatialRules = [{ + id: 'rule-1', name: 'Racks apart', kind: 'noOverlap' as const, sourceClassId: 'rack', + targetClassId: 'rack', severity: 'error' as const, + }]; + prismaMock.metamodel.update.mockResolvedValue({ ...mockMetamodelRow, spatialRules } as any); + + const result = await metamodelService.update('mm-uuid-1', { spatialRules }, 'user-uuid-1', 'DSL_DESIGNER'); + expect(prismaMock.metamodel.update).toHaveBeenCalledWith({ + where: { id: 'mm-uuid-1' }, + data: { spatialRules }, + }); + expect(result.spatialRules).toEqual(spatialRules); + + await expect(metamodelService.update('mm-uuid-1', { + spatialRules: [{ ...spatialRules[0], sourceClassId: 'conveyor' }], + }, 'user-uuid-1', 'DSL_DESIGNER')).rejects.toThrow('spatialRules[0].sourceClassId references unknown metaclass conveyor'); + }); + it('rejects a metamodel update whose default elevation is outside its bounds', async () => { sharingServiceMock.checkAccess.mockResolvedValue({ hasAccess: true, @@ -462,22 +530,29 @@ describe('MetamodelService', () => { hasAccess: true, isOwner: true, }); + const keptRule = { id: 'rule-2', name: 'Kept', kind: 'noOverlap', sourceClassId: 'cls-2', targetClassId: 'cls-2', severity: 'error' }; prismaMock.metamodel.findFirst.mockResolvedValue({ ...mockMetamodelRow, - classes: [{ id: 'cls-1', name: 'ClassToDelete' }], + classes: [{ id: 'cls-1', name: 'ClassToDelete' }, { id: 'cls-2', name: 'Kept' }], + spatialRules: [ + { id: 'rule-1', name: 'Dropped', kind: 'noOverlap', sourceClassId: 'cls-1', targetClassId: 'cls-2', severity: 'error' }, + keptRule, + ], }); prismaMock.metamodel.update.mockResolvedValue({ ...mockMetamodelRow, - classes: [], + classes: [{ id: 'cls-2', name: 'Kept' }], + spatialRules: [keptRule], }); const result = await metamodelService.deleteClass('mm-uuid-1', 'cls-1', 'user-uuid-1', 'DSL_DESIGNER'); expect(prismaMock.metamodel.update).toHaveBeenCalledWith( expect.objectContaining({ - data: { classes: [] }, + data: { classes: [{ id: 'cls-2', name: 'Kept' }], spatialRules: [keptRule] }, }) ); + expect(result.spatialRules).toEqual([keptRule]); }); }); }); diff --git a/backend/src/__tests__/services/pipeline-engines.test.ts b/backend/src/__tests__/services/pipeline-engines.test.ts index 8c0a584..3e99031 100644 --- a/backend/src/__tests__/services/pipeline-engines.test.ts +++ b/backend/src/__tests__/services/pipeline-engines.test.ts @@ -5,6 +5,7 @@ import { codeGenerationEngine } from '../../services/pipeline/code-generation.en import { modelValidationEngine } from '../../services/pipeline/model-validation.engine'; import { testExecutionEngine } from '../../services/pipeline/test-execution.engine'; import { transformationEngine } from '../../services/pipeline/transformation.engine'; +import { evaluateSpatialRules } from '../../../../shared/spatial-rules'; const projectId = 'project-uuid-1'; @@ -92,6 +93,69 @@ describe('headless pipeline engines', () => { ])); }); + it('reports metamodel spatial rules with the same issues as the shared evaluator', () => { + const withRules: Metamodel = { + ...metamodel, + spatialRules: [{ + id: 'robots-apart', + name: 'Robots apart', + kind: 'noOverlap', + sourceClassId: 'robot-class', + targetClassId: 'robot-class', + severity: 'error', + }, { + id: 'robot-spacing', + name: 'Robot spacing', + kind: 'minClearance', + sourceClassId: 'robot-class', + targetClassId: 'robot-class', + distanceMm: 500, + mode: 'plan', + severity: 'warning', + }], + }; + const fleet = model(); + fleet.elements[0].presentation = { + position3D: { x: 0, y: 0, z: 0 }, + size3D: { widthMm: 2000, heightMm: 500, depthMm: 1000 }, + }; + fleet.elements.push({ + id: 'robot-2', + modelElementId: 'robot-class', + style: { name: 'Beta' }, + references: {}, + presentation: { + position3D: { x: 0, y: 800, z: 0 }, + size3D: { widthMm: 2000, heightMm: 500, depthMm: 1000 }, + rotationZ: 90, + }, + }); + + const result = modelValidationEngine.validate(fleet, withRules); + + expect(result.valid).toBe(false); + expect(result.issues).toEqual(evaluateSpatialRules(withRules, fleet)); + expect(result.issues).toEqual([ + expect.objectContaining({ + severity: 'error', + message: 'Robots apart: Alpha overlaps Beta.', + elementId: 'robot-1', + relatedElementIds: ['robot-2'], + constraintId: 'robots-apart', + }), + expect.objectContaining({ + severity: 'warning', + message: 'Robot spacing: Alpha is 0 mm from Beta; at least 500 mm is required.', + constraintId: 'robot-spacing', + }), + ]); + + const beta = fleet.elements.find(element => element.id === 'robot-2')!; + beta.presentation!.rotationZ = 0; + beta.presentation!.position3D = { x: 0, y: 1000, z: 0 }; + expect(modelValidationEngine.validate(fleet, withRules)).toEqual({ valid: true, issues: [] }); + }); + it('generates deterministic files with model/spatial context and helper support', () => { const templates = [{ id: 'template-1', diff --git a/backend/src/__tests__/services/project-checkpoint.service.test.ts b/backend/src/__tests__/services/project-checkpoint.service.test.ts index f094339..845be33 100644 --- a/backend/src/__tests__/services/project-checkpoint.service.test.ts +++ b/backend/src/__tests__/services/project-checkpoint.service.test.ts @@ -141,6 +141,64 @@ describe('ProjectCheckpointService', () => { }); }); + it('round-trips metamodel spatial rules through checkpoint and restore', async () => { + const spatialRules = [{ + id: 'rule-1', name: 'Racks apart', kind: 'minClearance', sourceClassId: 'rack', + targetClassId: 'rack', distanceMm: 1500, mode: 'plan', severity: 'error', + }]; + const packageRow = { + id: 'package-1', name: 'Ecore', nsURI: 'urn:ecore', nsPrefix: 'ecore', classes: [], userId, projectId, + }; + let metamodelRow: any = { + id: 'metamodel-1', name: 'Warehouse DSL', description: null, uri: 'urn:warehouse', prefix: 'wh', + eClass: null, classes: [{ id: 'rack', name: 'Rack' }], enums: [], constraints: [], spatialRules, + conformsToId: 'package-1', userId, projectId, + }; + prismaMock.ePackage.findMany.mockResolvedValue([packageRow] as any); + prismaMock.metamodel.findMany.mockImplementation((async () => [metamodelRow]) as any); + + const manifest = await projectCheckpointService.buildManifest(projectId); + expect(manifest.artifacts.find(item => item.id === 'metamodel-1')?.data.spatialRules).toEqual(spatialRules); + + prismaMock.projectCheckpoint.findFirst.mockResolvedValue({ + id: 'checkpoint-1', projectId, sequence: 1, tag: null, message: null, manifest, + contentHash: manifest.contentHash, createdById: userId, createdAt: new Date('2026-07-23T12:00:00.000Z'), + } as any); + (prismaMock.$transaction as any).mockImplementation(async (operation: Function) => operation(prismaMock)); + (prismaMock.metamodel.upsert as any).mockImplementation(async ({ create }: any) => { + // The restored row is what the next snapshot reads back. + metamodelRow = { ...create }; + return create; + }); + metamodelRow = { ...metamodelRow, spatialRules: [] }; + + const restored = await projectCheckpointService.restore(projectId, 'checkpoint-1', manifest.contentHash); + + expect(prismaMock.metamodel.upsert).toHaveBeenCalledWith(expect.objectContaining({ + create: expect.objectContaining({ id: 'metamodel-1', spatialRules }), + })); + expect(metamodelRow.spatialRules).toEqual(spatialRules); + expect(restored.contentHash).toBe(manifest.contentHash); + }); + + it('hashes a rule-free metamodel exactly as before spatial rules existed', async () => { + const legacyRow = { + id: 'metamodel-1', name: 'Warehouse DSL', description: null, uri: 'urn:warehouse', prefix: 'wh', + eClass: null, classes: [], enums: [], constraints: [], conformsToId: 'package-1', userId, projectId, + }; + prismaMock.ePackage.findMany.mockResolvedValue([{ + id: 'package-1', name: 'Ecore', nsURI: 'urn:ecore', nsPrefix: 'ecore', classes: [], userId, projectId, + }] as any); + prismaMock.metamodel.findMany.mockResolvedValue([legacyRow] as any); + const legacy = await projectCheckpointService.buildManifest(projectId); + + prismaMock.metamodel.findMany.mockResolvedValue([{ ...legacyRow, spatialRules: [] }] as any); + const current = await projectCheckpointService.buildManifest(projectId); + + expect(current.contentHash).toBe(legacy.contentHash); + expect(current.artifacts.find(item => item.id === 'metamodel-1')?.data.spatialRules).toBeUndefined(); + }); + it('restores a verified checkpoint and confirms the graph root hash', async () => { const manifest = await projectCheckpointService.buildManifest(projectId); prismaMock.projectCheckpoint.findFirst.mockResolvedValue({ diff --git a/backend/src/__tests__/services/sharing.service.test.ts b/backend/src/__tests__/services/sharing.service.test.ts index 641eba7..313c28d 100644 --- a/backend/src/__tests__/services/sharing.service.test.ts +++ b/backend/src/__tests__/services/sharing.service.test.ts @@ -59,6 +59,7 @@ describe('SharingService', () => { classes: [], enums: [], constraints: [], + spatialRules: [], conformsToId: 'pkg-1', userId: 'owner-uuid', projectId: null, @@ -83,6 +84,7 @@ describe('SharingService', () => { classes: [], enums: [], constraints: [], + spatialRules: [], conformsToId: 'pkg-1', userId: 'other-owner', projectId: null, diff --git a/backend/src/__tests__/services/sirius-interop.service.test.ts b/backend/src/__tests__/services/sirius-interop.service.test.ts index 87fa2b3..62cc86a 100644 --- a/backend/src/__tests__/services/sirius-interop.service.test.ts +++ b/backend/src/__tests__/services/sirius-interop.service.test.ts @@ -876,6 +876,30 @@ describe('SiriusInteropService .aird export', () => { expect(edges[0].points).toEqual([{ x: 180, y: 140 }, { x: 480, y: 260 }]); }); + it('carries spatial rules as an EPackage annotation in the exported Ecore', async () => { + modelServiceMock.getById.mockResolvedValue(mockModel); + metamodelServiceMock.getById.mockResolvedValue({ + ...mockMetamodel, + spatialRules: [{ + id: 'rule-1', name: 'Children apart', kind: 'minClearance', sourceClassId: 'cls-child', + targetClassId: 'cls-child', distanceMm: 500, mode: 'plan', severity: 'error', + }], + }); + + const result = await siriusInteropService.exportProject({ metamodelId: 'metamodel-1', modelId: 'model-1' }, 'user-1'); + const zip = await JSZip.loadAsync(result.content, { base64: true }); + const ecore = await zip.file('model/minimal.ecore')!.async('text'); + + expect(ecore).toContain(''); + expect(ecore).toContain( + '
' + ); + expect(result.report.supported).toBe(true); + expect(result.report.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'SPATIALDSL_SPATIAL_RULES_ANNOTATED', severity: 'info' }), + ])); + }); + it('exports a complete Sirius project ZIP with consistent cross-resource references', async () => { modelServiceMock.getById.mockResolvedValue({ ...mockModel, diff --git a/backend/src/__tests__/services/spatial-rules-parity.test.ts b/backend/src/__tests__/services/spatial-rules-parity.test.ts new file mode 100644 index 0000000..46cc778 --- /dev/null +++ b/backend/src/__tests__/services/spatial-rules-parity.test.ts @@ -0,0 +1,155 @@ +import fs from 'fs'; +import path from 'path'; +import * as sharedGeometry from '../../../../shared/spatial-geometry'; +import * as sharedRules from '../../../../shared/spatial-rules'; +import * as frontendGeometry from '../../../../frontend/src/services/spatial/spatial-geometry'; +import * as frontendRules from '../../../../frontend/src/services/spatial/spatial-rules'; +import { MetaClass, Metamodel, Model, ModelElement, SpatialRule, SpatialRuleKind } from '../../../../shared/types'; +import smartWarehouseMetamodel from '../../../../frontend/src/examples/data/smart-warehouse-metamodel.json'; +import smartWarehouseModel from '../../../../frontend/src/examples/data/smart-warehouse-model.json'; + +// The frontend cannot import shared/, so the geometry and rule evaluator are +// mirrored. These tests keep the two copies byte-identical below their +// import blocks and run the same fixtures through both. + +const repoRoot = path.resolve(__dirname, '../../../..'); +const bodyOf = (relativePath: string): string => { + const lines = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').split('\n'); + return lines.slice(lines.findIndex(line => !line.startsWith('import '))).join('\n'); +}; + +const seededRandom = (seed: number) => { + let state = seed >>> 0; + return (): number => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state / 2 ** 32; + }; +}; + +const classes: MetaClass[] = [ + { id: 'equipment', name: 'Equipment', eClass: 'EClass', abstract: true, superTypes: [], attributes: [], references: [] }, + { id: 'conveyor', name: 'Conveyor', eClass: 'EClass', abstract: false, superTypes: ['equipment'], attributes: [], references: [] }, + { id: 'rack', name: 'Rack', eClass: 'EClass', abstract: false, superTypes: ['equipment'], attributes: [], references: [] }, + { + id: 'zone', name: 'Zone', eClass: 'EClass', abstract: false, superTypes: [], attributes: [], references: [], + concreteSyntax: { three_d: { defaultSizeMm: { widthMm: 9000, heightMm: 7000, depthMm: 3000 } } }, + }, + { id: 'sensor', name: 'Sensor', eClass: 'EClass', abstract: false, superTypes: [], attributes: [], references: [] }, +]; + +const rulesFor = (kind: SpatialRuleKind, mode: 'plan' | 'volume', index: number): SpatialRule => ({ + id: `rule-${index}`, + name: `${kind}-${mode}`, + kind, + sourceClassId: kind === 'within' || kind === 'maxDistance' ? 'sensor' : 'equipment', + targetClassId: kind === 'within' ? 'zone' : kind === 'maxDistance' ? 'rack' : 'equipment', + distanceMm: kind === 'noOverlap' ? undefined : kind === 'within' ? 250 : kind === 'maxDistance' ? 6000 : 400, + mode, + severity: index % 3 === 0 ? 'warning' : 'error', +}); + +const kinds: SpatialRuleKind[] = ['noOverlap', 'minClearance', 'maxDistance', 'within', 'adjacentTo']; +const metamodel: Metamodel = { + id: 'mm', name: 'Parity', eClass: 'EPackage', uri: 'http://example.org/parity', prefix: 'p', conformsTo: 'ecore', + classes, + spatialRules: kinds.flatMap((kind, index) => [ + rulesFor(kind, 'plan', index * 2), + rulesFor(kind, 'volume', index * 2 + 1), + ]), +}; + +const randomModel = (seed: number, count: number): Model => { + const random = seededRandom(seed); + const classIds = ['conveyor', 'rack', 'zone', 'sensor']; + const elements: ModelElement[] = Array.from({ length: count }, (_, index) => { + const classId = classIds[index % classIds.length]; + const sized = classId !== 'zone' || random() < 0.5; + return { + id: `e${index}`, + modelElementId: classId, + style: { name: `${classId} ${index}` }, + references: {}, + presentation: { + position3D: { x: Math.round(random() * 20000), y: Math.round(random() * 15000), z: Math.round(random() * 2000) }, + rotationZ: [0, 90, 180, 270, 30, 45, -60][Math.floor(random() * 7)], + ...(sized ? { + size3D: { + widthMm: 300 + Math.round(random() * 3000), + heightMm: 300 + Math.round(random() * 2000), + depthMm: 100 + Math.round(random() * 2000), + }, + } : {}), + }, + }; + }); + return { id: `model-${seed}`, name: 'Random', metamodelId: 'mm', conformsTo: 'mm', elements }; +}; + +describe('spatial geometry and rules parity between shared/ and the frontend mirror', () => { + it.each([ + ['shared/spatial-geometry.ts', 'frontend/src/services/spatial/spatial-geometry.ts'], + ['shared/spatial-rules.ts', 'frontend/src/services/spatial/spatial-rules.ts'], + ])('keeps %s and %s identical below the imports', (shared, frontend) => { + const sharedBody = bodyOf(shared); + expect(sharedBody.length).toBeGreaterThan(1000); + expect(bodyOf(frontend)).toBe(sharedBody); + }); + + it('exports the same functions from both copies', () => { + expect(Object.keys(frontendGeometry).sort()).toEqual(Object.keys(sharedGeometry).sort()); + expect(Object.keys(frontendRules).sort()).toEqual(Object.keys(sharedRules).sort()); + }); + + it.each([1, 2, 3])('computes identical geometry for seeded random element pairs (seed %i)', seed => { + const model = randomModel(seed, 40); + const boxes = model.elements.map(element => ({ + shared: sharedGeometry.elementBox(element, { widthMm: 1000, heightMm: 1000, depthMm: 1000 }), + frontend: frontendGeometry.elementBox(element, { widthMm: 1000, heightMm: 1000, depthMm: 1000 }), + })); + boxes.forEach(({ shared, frontend }) => expect(frontend).toEqual(shared)); + for (let i = 0; i < boxes.length; i += 1) { + for (let j = i + 1; j < boxes.length; j += 1) { + const a = boxes[i].shared!; + const b = boxes[j].shared!; + expect([ + frontendGeometry.planOverlap(a, b), + frontendGeometry.volumeOverlap(a, b), + frontendGeometry.planGap(a, b), + frontendGeometry.volumeGap(a, b), + frontendGeometry.planContains(a, b, 100), + frontendGeometry.volumeCenterDistance(a, b), + ]).toEqual([ + sharedGeometry.planOverlap(a, b), + sharedGeometry.volumeOverlap(a, b), + sharedGeometry.planGap(a, b), + sharedGeometry.volumeGap(a, b), + sharedGeometry.planContains(a, b, 100), + sharedGeometry.volumeCenterDistance(a, b), + ]); + } + } + }); + + it.each([11, 12, 13])('reports identical rule issues for every kind and mode (seed %i)', seed => { + const model = randomModel(seed, 120); + const sharedIssues = sharedRules.evaluateSpatialRules(metamodel, model); + const frontendIssues = frontendRules.evaluateSpatialRules(metamodel as any, model as any); + + expect(frontendIssues).toEqual(sharedIssues); + const firedRules = new Set(sharedIssues.map(issue => issue.constraintId)); + expect(firedRules.size).toBeGreaterThanOrEqual(6); + }); + + it('agrees on the Smart Warehouse example, before and after a live violation', () => { + const exampleMetamodel = smartWarehouseMetamodel as unknown as Metamodel; + const exampleModel = JSON.parse(JSON.stringify(smartWarehouseModel)) as Model; + expect(sharedRules.evaluateSpatialRules(exampleMetamodel, exampleModel)).toEqual([]); + expect(frontendRules.evaluateSpatialRules(exampleMetamodel as any, exampleModel as any)).toEqual([]); + + const rack = exampleModel.elements.find(element => element.style.name === 'Storage Rack #2')!; + rack.presentation!.position3D = { x: 12500, y: 12000, z: 0 }; + const sharedIssues = sharedRules.evaluateSpatialRules(exampleMetamodel, exampleModel); + expect(sharedIssues.length).toBeGreaterThan(0); + expect(frontendRules.evaluateSpatialRules(exampleMetamodel as any, exampleModel as any)).toEqual(sharedIssues); + }); +}); diff --git a/backend/src/services/lifecycle/metamodel-evolution.service.ts b/backend/src/services/lifecycle/metamodel-evolution.service.ts index 96d5364..0a61712 100644 --- a/backend/src/services/lifecycle/metamodel-evolution.service.ts +++ b/backend/src/services/lifecycle/metamodel-evolution.service.ts @@ -14,6 +14,7 @@ import { MetamodelMigrationRule, ModelElement, ProjectArtifactRef, + SpatialRule, } from '../../../../shared/types'; import { validateConcreteSyntaxVerticalPlacement } from '../../../../shared/spatial'; import { contentHash, stableStringify } from './canonical'; @@ -54,6 +55,7 @@ const toMetamodel = (row: any): Metamodel => ({ enums: asArray(row.enums), conformsTo: row.conformsToId, constraints: asArray(row.constraints), + spatialRules: asArray(row.spatialRules), }); const metamodelFingerprint = (metamodel: Metamodel): string => contentHash({ @@ -67,6 +69,8 @@ const metamodelFingerprint = (metamodel: Metamodel): string => contentHash({ classes: metamodel.classes, enums: metamodel.enums || [], constraints: metamodel.constraints || [], + // Omitted when empty so fingerprints of rule-free metamodels are unchanged. + spatialRules: metamodel.spatialRules && metamodel.spatialRules.length > 0 ? metamodel.spatialRules : undefined, }); const matchesAttributeValue = (value: unknown, attribute: Record): boolean => { @@ -369,6 +373,18 @@ class MetamodelEvolutionService { } }); + const nextClassNames = new Map(next.classes.map(metaClass => [metaClass.id, metaClass.name])); + const currentClassNames = new Map(context.current.classes.map(metaClass => [metaClass.id, metaClass.name])); + asArray(next.spatialRules).forEach(rule => { + [rule?.sourceClassId, rule?.targetClassId].forEach(classId => { + if (typeof classId !== 'string' || nextClassNames.has(classId)) return; + const removedName = currentClassNames.get(classId); + blockers.push(removedName + ? `Spatial rule ${rule.name} references removed class ${removedName}; update or delete the rule before applying this evolution` + : `Spatial rule ${rule?.name || rule?.id} references unknown class ${classId}`); + }); + }); + const allElements = context.models.flatMap(model => asArray(model.elements)); for (const change of changes) { const elements = allElements.filter(element => element.modelElementId === change.classId); @@ -621,6 +637,7 @@ class MetamodelEvolutionService { classes: next.classes as any, enums: (next.enums || []) as any, constraints: (next.constraints || []) as any, + spatialRules: (next.spatialRules || []) as any, }, }); diff --git a/backend/src/services/lifecycle/project-checkpoint.service.ts b/backend/src/services/lifecycle/project-checkpoint.service.ts index 445309c..b82fbfe 100644 --- a/backend/src/services/lifecycle/project-checkpoint.service.ts +++ b/backend/src/services/lifecycle/project-checkpoint.service.ts @@ -28,6 +28,9 @@ const nullableJson = (value: unknown): Prisma.InputJsonValue | typeof Prisma.Jso value === null || value === undefined ? Prisma.JsonNull : value as Prisma.InputJsonValue ); +const nonEmptyArray = (value: unknown): unknown[] | undefined => ( + Array.isArray(value) && value.length > 0 ? value : undefined +); const sortRefs = (values: ProjectArtifactRef[]): ProjectArtifactRef[] => ( [...values].sort((left, right) => artifactKey(left).localeCompare(artifactKey(right))) ); @@ -172,6 +175,9 @@ class ProjectCheckpointService { classes: row.classes, enums: row.enums, constraints: row.constraints, + // Omitted when empty so checkpoints taken before spatial rules existed + // keep verifying against the same content hash. + spatialRules: nonEmptyArray(row.spatialRules), conformsToId: row.conformsToId, userId: row.userId, }, requireDependency( @@ -467,6 +473,7 @@ class ProjectCheckpointService { classes: data.classes as any, enums: data.enums as any, constraints: data.constraints as any, + spatialRules: (data.spatialRules ?? []) as any, conformsToId: data.conformsToId, userId: data.userId, projectId, diff --git a/backend/src/services/metamodel.service.ts b/backend/src/services/metamodel.service.ts index bd5d966..5488295 100644 --- a/backend/src/services/metamodel.service.ts +++ b/backend/src/services/metamodel.service.ts @@ -5,6 +5,7 @@ import { MetaClass, MetaEnum, Constraint, + SpatialRule, CreateMetamodelRequest, UpdateMetamodelRequest, UserRole, @@ -13,6 +14,7 @@ import { import { sharingService } from './sharing.service'; import { canPerformOperation } from '../middleware/permissions'; import { validateConcreteSyntaxVerticalPlacement } from '../../../shared/spatial'; +import { validateSpatialRules } from '../../../shared/spatial-rules'; // Extended metamodel type with permission info export interface MetamodelWithPermission extends Metamodel, ResourceWithPermission {} @@ -34,6 +36,14 @@ class MetamodelService { )); } + /** Spatial rules must be well formed and reference classes of the same metamodel. */ + private assertValidSpatialRules(spatialRules: unknown, classes: unknown): void { + const errors = validateSpatialRules(spatialRules, { + classes: Array.isArray(classes) ? classes as MetaClass[] : [], + }); + if (errors.length > 0) throw new ApiError(400, errors[0]); + } + /** * Get all metamodels accessible by a user (owned + shared) */ @@ -153,6 +163,9 @@ class MetamodelService { } this.assertValidClassesVerticalPlacement(data.classes || []); + if (data.spatialRules !== undefined) { + this.assertValidSpatialRules(data.spatialRules, data.classes || []); + } const metamodel = await prisma.metamodel.create({ data: { @@ -165,6 +178,7 @@ class MetamodelService { classes: (data.classes || []) as any, enums: (data.enums || []) as any, constraints: (data.constraints || []) as any, + spatialRules: (data.spatialRules || []) as any, conformsToId: data.conformsTo, userId, projectId, @@ -199,6 +213,12 @@ class MetamodelService { if (data.classes !== undefined) { this.assertValidClassesVerticalPlacement(data.classes); } + if (data.spatialRules !== undefined) { + const classes = data.classes !== undefined + ? data.classes + : (await prisma.metamodel.findUnique({ where: { id }, select: { classes: true } }))?.classes; + this.assertValidSpatialRules(data.spatialRules, classes); + } const metamodel = await prisma.metamodel.update({ where: { id }, @@ -210,6 +230,7 @@ class MetamodelService { ...(data.classes !== undefined && { classes: data.classes as any }), ...(data.enums !== undefined && { enums: data.enums as any }), ...(data.constraints !== undefined && { constraints: data.constraints as any }), + ...(data.spatialRules !== undefined && { spatialRules: data.spatialRules as any }), }, }); @@ -376,10 +397,14 @@ class MetamodelService { } const classes = ((metamodel.classes as unknown as MetaClass[]) || []).filter(c => c.id !== classId); + // Rules over a deleted class can no longer be evaluated. + const spatialRules = ((metamodel.spatialRules as unknown as SpatialRule[]) || []).filter(rule => ( + rule.sourceClassId !== classId && rule.targetClassId !== classId + )); const updated = await prisma.metamodel.update({ where: { id: metamodelId }, - data: { classes: classes as any }, + data: { classes: classes as any, spatialRules: spatialRules as any }, }); return this.mapToMetamodel(updated); @@ -461,6 +486,7 @@ class MetamodelService { classes: (mm.classes as unknown as MetaClass[]) || [], enums: (mm.enums as unknown as MetaEnum[]) || [], constraints: (mm.constraints as unknown as Constraint[]) || [], + spatialRules: (mm.spatialRules as unknown as SpatialRule[]) || [], conformsTo: mm.conformsToId, }; } diff --git a/backend/src/services/pipeline/headless-pipeline.service.ts b/backend/src/services/pipeline/headless-pipeline.service.ts index c7ce273..cab1603 100644 --- a/backend/src/services/pipeline/headless-pipeline.service.ts +++ b/backend/src/services/pipeline/headless-pipeline.service.ts @@ -46,6 +46,7 @@ const mapMetamodel = (row: any): Metamodel => ({ classes: Array.isArray(row.classes) ? row.classes : [], enums: Array.isArray(row.enums) ? row.enums : [], constraints: Array.isArray(row.constraints) ? row.constraints : [], + spatialRules: Array.isArray(row.spatialRules) ? row.spatialRules : [], }); const mapRun = (row: any): HeadlessPipelineRun => { diff --git a/backend/src/services/pipeline/model-validation.engine.ts b/backend/src/services/pipeline/model-validation.engine.ts index 8bf7605..8588595 100644 --- a/backend/src/services/pipeline/model-validation.engine.ts +++ b/backend/src/services/pipeline/model-validation.engine.ts @@ -9,21 +9,9 @@ import { ValidationResult, } from '../../../../shared/types'; import { spatialElementErrors } from '../../middleware/presentationValidation'; +import { evaluateSpatialRules, isSubtype } from '../../../../shared/spatial-rules'; -export const isSubtype = ( - metamodel: Metamodel, - typeId: string, - expectedTypeId: string, - visited = new Set() -): boolean => { - if (typeId === expectedTypeId) return true; - if (visited.has(typeId)) return false; - visited.add(typeId); - const metaClass = metamodel.classes.find(candidate => candidate.id === typeId); - return Boolean(metaClass?.superTypes?.some(superType => ( - isSubtype(metamodel, superType, expectedTypeId, visited) - ))); -}; +export { isSubtype }; export const valueMatchesAttribute = (value: unknown, attribute: MetaAttribute): boolean => { if (value === undefined || value === null || (attribute.required && value === '')) { @@ -161,6 +149,8 @@ export class ModelValidationEngine { } }); + issues.push(...evaluateSpatialRules(metamodel, model)); + return { valid: !issues.some(issue => issue.severity === 'error'), issues }; } } diff --git a/backend/src/services/sirius-interop.service.ts b/backend/src/services/sirius-interop.service.ts index 4725c85..5d5360d 100644 --- a/backend/src/services/sirius-interop.service.ts +++ b/backend/src/services/sirius-interop.service.ts @@ -590,6 +590,34 @@ class SiriusInteropService { ` nsURI="${this.escapeXml(metamodel.uri)}">`, ]; + const classNames = new Map(metamodel.classes.map(metaClass => [metaClass.id, metaClass.name])); + const spatialRules = (metamodel.spatialRules || []).filter(rule => ( + classNames.has(rule.sourceClassId) && classNames.has(rule.targetClassId) + )); + if (spatialRules.length > 0) { + // One detail per rule, keyed by rule name; the JSON value names classes instead of IDs. + lines.push(' '); + spatialRules.forEach(rule => { + const value = JSON.stringify({ + kind: rule.kind, + source: classNames.get(rule.sourceClassId), + target: classNames.get(rule.targetClassId), + distanceMm: rule.distanceMm, + mode: rule.mode, + severity: rule.severity, + message: rule.message, + enabled: rule.enabled, + }); + lines.push(`
`); + }); + lines.push(' '); + this.addWarning(report, 'warnings', { + severity: 'info', + code: 'SPATIALDSL_SPATIAL_RULES_ANNOTATED', + message: `${spatialRules.length} spatial rule(s) are carried as the EPackage annotation spatialdsl/spatialRules; Sirius and EMF do not evaluate them.`, + }); + } + metamodel.classes.forEach(metaClass => { const superTypes = (metaClass.superTypes || []) .map(id => metamodel.classes.find(candidate => candidate.id === id)) diff --git a/docs/README.md b/docs/README.md index d8e4f69..af855d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ Use this page to find the right documentation based on your goal. ### I want to use the application - [Metamodels](user-guide/metamodels.md) +- [Spatial Rules](user-guide/spatial-rules.md) - [Models](user-guide/models.md) - [Views (2D and 3D)](user-guide/diagrams.md) - [Viewpoints and Representation Descriptions](user-guide/viewpoints.md) @@ -46,15 +47,16 @@ Recommended reading path for first-time onboarding: 1. getting-started/local-setup.md 2. user-guide/metamodels.md -3. user-guide/models.md -4. user-guide/viewpoints.md -5. user-guide/diagrams.md -6. user-guide/transformations.md -7. user-guide/code-generation.md -8. user-guide/visual-components-code-generation.md -9. user-guide/omniverse-code-generation.md -10. user-guide/end-to-end-omniverse.md -11. user-guide/project-lifecycle.md -12. reference/sirius-compatibility.md -13. reference/sirius-file-compatibility.md -14. reference/architecture.md +3. user-guide/spatial-rules.md +4. user-guide/models.md +5. user-guide/viewpoints.md +6. user-guide/diagrams.md +7. user-guide/transformations.md +8. user-guide/code-generation.md +9. user-guide/visual-components-code-generation.md +10. user-guide/omniverse-code-generation.md +11. user-guide/end-to-end-omniverse.md +12. user-guide/project-lifecycle.md +13. reference/sirius-compatibility.md +14. reference/sirius-file-compatibility.md +15. reference/architecture.md diff --git a/docs/reference/api.md b/docs/reference/api.md index 85660e8..a4bcc38 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -130,6 +130,9 @@ Base: `/api/metamodels` - `POST /:id/constraints` - `POST /:id/classes/:classId/constraints` +`POST /` and `PUT /:id` accept an optional `spatialRules` array. Each rule is validated (kind, required +distance, severity, and source and target classes that exist in the metamodel); an invalid rule returns 400. + Metamodel evolution (project-scoped mount only): - `POST /:id/evolution/preview` diff --git a/docs/reference/data-model.md b/docs/reference/data-model.md index 9f90e44..040942f 100644 --- a/docs/reference/data-model.md +++ b/docs/reference/data-model.md @@ -122,6 +122,7 @@ Key fields: - `classes` (JSON) - `enums` (JSON) - `constraints` (JSON) +- `spatialRules` (JSON, `SpatialRule[]`; see [Spatial Rules](../user-guide/spatial-rules.md)) - `conformsToId` (EPackage FK) - `userId` diff --git a/docs/user-guide/metamodels.md b/docs/user-guide/metamodels.md index 2835d6d..dc93251 100644 --- a/docs/user-guide/metamodels.md +++ b/docs/user-guide/metamodels.md @@ -66,6 +66,7 @@ Ecore support is intended for common single-package EMF models. SpatialDSL Studi - enum classifiers and enum-typed attributes - attribute defaults through `defaultValueLiteral` - OCL constraints stored as Ecore annotations +- spatial rules stored as the `EPackage` annotation `spatialdsl/spatialRules` (class names resolved on import) Known lossy or unsupported areas: @@ -176,6 +177,12 @@ Use for procedural or advanced rule logic. The editor performs syntax checks and For OCL syntax examples, see [frontend/src/examples/ocl-examples.ts](../../frontend/src/examples/ocl-examples.ts). +### Spatial rules + +The Constraints tab also holds the metamodel's **Spatial Rules**: declarative rules such as "no overlap", +"minimum clearance", or "within a zone" over placed instances of two metaclasses. They are evaluated with +the other constraints in browser validation and in the headless pipeline. See [Spatial Rules](spatial-rules.md). + ## Validate a metamodel Metamodel validation checks structural consistency, including: @@ -243,12 +250,14 @@ Fix: move rule to correct constraint type tab. - `frontend/src/components/metamodel/MetamodelManager.tsx`: Main metamodel list/create/import/export UI. - `frontend/src/components/metamodel/VisualMetamodelEditor.tsx`: Visual editor for classes, references, and layout. - `frontend/src/components/metamodel/OCLConstraintEditor.tsx`: OCL constraint authoring panel. +- `frontend/src/components/metamodel/SpatialRulesEditor.tsx`: Spatial rule list and dialog. - `frontend/src/services/metamodel/metamodel.service.ts`: Frontend metamodel orchestration and state operations. - `backend/src/routes/metamodel.routes.ts`: Backend API endpoints for metamodel CRUD and related actions. ## Related docs - [Models](models.md) +- [Spatial Rules](spatial-rules.md) - [Viewpoints and Representation Descriptions](viewpoints.md) - [Sirius Desktop Compatibility](../reference/sirius-compatibility.md) - [Roles and Sharing](roles-and-sharing.md) diff --git a/docs/user-guide/spatial-rules.md b/docs/user-guide/spatial-rules.md new file mode 100644 index 0000000..76479b7 --- /dev/null +++ b/docs/user-guide/spatial-rules.md @@ -0,0 +1,168 @@ +# Spatial Rules + +Spatial rules are declarative constraints over where model elements are placed. They belong to the +language definition: a DSL designer declares them on the metamodel, next to OCL and JavaScript +constraints, and every model of that language is checked against them. + +The same evaluator runs in two places and produces identical issues for the same model: + +- browser validation (model validation dialog, the 2D and 3D view markers, and the view validation panel); +- the backend model validation engine, which the headless pipeline `validate-model` step uses. + +OCL and JavaScript constraints remain available for anything the rule kinds below cannot express. They +can call the same geometry through the `spatial` helpers (see [Escape hatch](#escape-hatch-ocl-and-javascript-helpers)). + +## Author a rule + +1. Open a metamodel in the visual editor. +2. Open the **Constraints** tab in the right sidebar. The **Spatial Rules** section lists the + metamodel's rules below the class constraints (it is visible with or without a selected class). +3. Click **Add Rule** and fill in the dialog. The distance field appears only for kinds that use it, + labelled for that kind. +4. Click **Add**. The rule is saved with the metamodel. + +To see violations, open a view of a model and click the refresh button of the validation panel. Offending +elements get an error, warning, or info marker in 2D and in 3D. + +## Rule fields + +| Field | Meaning | +| --- | --- | +| `name` | Shown as the prefix of every issue message. | +| `kind` | `noOverlap`, `minClearance`, `maxDistance`, `within`, or `adjacentTo`. | +| `sourceClassId` | Metaclass whose instances are checked. Instances of subclasses are included. | +| `targetClassId` | Metaclass the source instances are compared with (subclasses included). May equal the source class. | +| `distanceMm` | Required for `minClearance`, `maxDistance`, and `adjacentTo`; the margin for `within` (default 0). Must be zero or greater. | +| `mode` | `plan` (footprints only) or `volume` (footprints and elevation). Default `volume`. | +| `severity` | `error`, `warning`, or `info`. | +| `message` | Optional custom text. Placeholders: `{source}`, `{target}`, `{distance}`, `{rule}`. | +| `enabled` | Default `true`; disabled rules are skipped. | + +## Geometry + +All rules use the Phase 10 coordinate contract: one right-handed, Z-up world frame in millimetres. + +- `presentation.position3D.x/y` is the element's plan-view centre and `position3D.z` its base elevation. +- `size3D.widthMm` runs along the element's local X axis, `heightMm` along its local Y axis, and + `depthMm` is the vertical extent. +- `presentation.rotationZ` is the yaw in degrees about +Z. Positive values turn the element + counter-clockwise seen from above, the same sign the 3D editor and OpenUSD `xformOp:rotateZ` use. + +Each placed element is therefore a vertical prism: its footprint is the rectangle `widthMm x heightMm` +centred on `(x, y)` and turned by the yaw, extruded over `[z, z + depthMm]`. + +An element is **placed** when it has a valid `presentation.position3D` and resolvable extents. Extents +come from `presentation.size3D`; when an element has none, the metaclass default +`concreteSyntax.three_d.defaultSizeMm` is used, inherited along `superTypes` like other notation (the first +ancestor with 3D notation wins). Elements that are not placed are skipped and never satisfy an "exists" +clause. + +Validation does not depend on which view is open. Representation-level and viewpoint-level +`defaultSizeMm` overrides, and the renderer's built-in 500 x 800 x 200 mm placeholder, are therefore not +used for rules. Give elements an explicit `size3D`, or set a metaclass default, when a rule should see them. + +## Semantics + +S is the set of placed instances of the source class (subclasses included) and T the same for the target +class. + +| Kind | Holds when | Quantifier | Issues reported | +| --- | --- | --- | --- | +| `noOverlap` | no two distinct elements intersect | for all a in S, b in T, a != b | one per offending unordered pair, attached to both elements | +| `minClearance(d)` | the gap between distinct elements is at least d | for all a in S, b in T, a != b | one per offending unordered pair, attached to both elements | +| `maxDistance(d)` | each source has a target whose centre is within d | for all a in S, exists b in T | one per unsatisfied source | +| `within(margin)` | each source lies inside a target footprint shrunk by the margin on every side | for all a in S, exists b in T | one per unsatisfied source | +| `adjacentTo(tol)` | each source has a distinct target with a gap of at most tol | for all a in S, exists b in T, b != a | one per unsatisfied source | + +Mode details: + +| | `plan` | `volume` (default) | +| --- | --- | --- | +| overlap | footprints intersect | footprints intersect and vertical intervals intersect | +| gap | minimum distance between footprints | `sqrt(planGap^2 + verticalGap^2)`, the exact distance between the prisms | +| centre distance | between plan centres | between volume centres (plan centre at mid elevation) | +| within | footprint of a inside the shrunk footprint of b | as `plan`, and the vertical interval of a inside that of b (the margin applies in plan only) | + +Comparison details: + +- Touching does not count as overlapping: footprints that share only an edge or a corner, or volumes + that share only a face, do not overlap. Their gap is 0. +- `minClearance` is violated when the gap is strictly less than d. `maxDistance`, `within`, and + `adjacentTo` accept values equal to the limit. +- A tolerance of 1e-6 mm absorbs floating-point noise from rotation. +- For `within` and `maxDistance`, a source may be satisfied by itself when its class is also a target + class (an element lies inside its own footprint and is at distance 0 from itself). `adjacentTo` always + needs a different element. + +## Issues + +Every issue carries `constraintId` (the rule ID) and `location` (`spatialRules.`, for example `spatialRules.noOverlap`). + +- Pair kinds (`noOverlap`, `minClearance`) report each unordered pair once, on the source element, with + the other element in `relatedElementIds`. Views mark both elements, and the issue stays visible in a + view that shows only one of them. +- Exists kinds (`maxDistance`, `within`, `adjacentTo`) report each unsatisfied source element. +- A rule that is misconfigured (unknown class, missing or negative distance) produces one error issue + without an element, and is not evaluated. +- Issues follow rule order, then model element order, so results are deterministic. + +Default messages name the elements and the measured value, for example +`Storage rack aisle width: Storage Rack is 500 mm from Storage Rack #2; at least 1500 mm is required.` + +## Example: Smart Warehouse + +The bundled Smart Warehouse metamodel declares an abstract `FixedEquipment` superclass of `Conveyor`, +`ChargingStation`, `StorageRack`, and `Dock`, and four rules. The bundled model satisfies all of them. + +| Rule | Kind | Source | Target | Distance | Mode | Severity | +| --- | --- | --- | --- | --- | --- | --- | +| Fixed equipment must not overlap | `noOverlap` | FixedEquipment | FixedEquipment | | plan | error | +| Storage rack aisle width | `minClearance` | StorageRack | StorageRack | 1500 mm | plan | error | +| Charging station on a pathway | `adjacentTo` | ChargingStation | PathwayArea | 500 mm | plan | warning | +| Drone clearance from racks | `minClearance` | InspectionDrone | StorageRack | 1000 mm | volume | error | + +To see a violation, move or rotate one conveyor into the other in the 2D or 3D view and refresh the +validation panel. + +## Escape hatch: OCL and JavaScript helpers + +JavaScript and OCL constraints can call the same geometry through a `spatial` namespace: + +- `spatial.overlaps(a, b)` and `spatial.overlaps3D(a, b)` +- `spatial.clearance(a, b)` and `spatial.clearance3D(a, b)` +- `spatial.boundsOf(e)` and `spatial.bounds3DOf(e)` + +These helpers honour `rotationZ`. `boundsOf` and `bounds3DOf` return the axis-aligned envelope of the +rotated footprint. The helpers read `presentation.size3D` only; they do not apply metaclass defaults. + +## Persistence and interoperability + +- Rules are stored in the metamodel's `spatialRules` JSON column and travel with JSON import and export. +- Project checkpoints include them; restoring a checkpoint restores them. +- Metamodel evolution reports a blocker when a rule still references a class that the new version removes. + Deleting a class in the editor also deletes the rules that reference it. +- Ecore export writes the rules as an `EPackage` annotation with source `spatialdsl/spatialRules`: one + detail per rule, keyed by rule name, whose value is a JSON object naming the classes. Ecore import reads + the annotation back and resolves the class names. EMF and Sirius keep the annotation but do not evaluate + it. + +## Cost + +Pair kinds compare every source with every target, O(|S| x |T|), with an axis-aligned envelope test +before the exact oriented test. Exists kinds stop at the first satisfying target. + +## Relevant files + +- `shared/spatial-geometry.ts` and `shared/spatial-rules.ts`: geometry and evaluator used by the backend. +- `frontend/src/services/spatial/spatial-geometry.ts` and `frontend/src/services/spatial/spatial-rules.ts`: + the frontend mirror. A parity test keeps both copies identical below their imports. +- `frontend/src/components/metamodel/SpatialRulesEditor.tsx`: the Spatial Rules section and dialog. +- `backend/src/services/pipeline/model-validation.engine.ts`: headless validation. +- `frontend/src/services/model/model-validation.service.ts`: browser validation. + +## Related docs + +- [Metamodels](metamodels.md) +- [Views (2D and 3D)](diagrams.md) +- [Project Lifecycle](project-lifecycle.md) +- [Data Model](../reference/data-model.md) diff --git a/frontend/src/__tests__/components/SpatialRulesEditor.test.tsx b/frontend/src/__tests__/components/SpatialRulesEditor.test.tsx new file mode 100644 index 0000000..9bf1932 --- /dev/null +++ b/frontend/src/__tests__/components/SpatialRulesEditor.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import SpatialRulesEditor from '../../components/metamodel/SpatialRulesEditor'; +import { metamodelService } from '../../services/metamodel'; +import { Metamodel } from '../../models/types'; + +jest.mock('../../services/metamodel', () => ({ + metamodelService: { updateMetamodel: jest.fn() }, +})); + +const metamodel: Metamodel = { + id: 'mm-1', + name: 'Warehouse', + eClass: 'EPackage', + uri: 'http://example.org/warehouse', + prefix: 'wh', + conformsTo: 'ecore', + classes: [ + { id: 'rack', name: 'StorageRack', eClass: 'EClass', abstract: false, superTypes: [], attributes: [], references: [] }, + { id: 'conveyor', name: 'Conveyor', eClass: 'EClass', abstract: false, superTypes: [], attributes: [], references: [] }, + ], + spatialRules: [{ + id: 'rule-1', + name: 'Conveyors apart', + kind: 'noOverlap', + sourceClassId: 'conveyor', + targetClassId: 'conveyor', + mode: 'plan', + severity: 'error', + }], +}; + +const chooseOption = (label: string, option: string) => { + fireEvent.mouseDown(screen.getByLabelText(label)); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: option })); +}; + +describe('SpatialRulesEditor', () => { + beforeEach(() => { + (metamodelService.updateMetamodel as jest.Mock).mockImplementation((_id: string, updated: Metamodel) => updated); + }); + + it('lists existing rules with a readable summary', () => { + render(); + + expect(screen.getByText('Conveyors apart')).toBeInTheDocument(); + expect(screen.getByText('Conveyor and Conveyor do not overlap')).toBeInTheDocument(); + expect(screen.getByText('Plan view')).toBeInTheDocument(); + }); + + it('shows the distance field only for kinds that use it and requires it where needed', () => { + const onUpdateMetamodel = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Rule' })); + fireEvent.change(screen.getByLabelText('Rule name'), { target: { value: 'Aisle width' } }); + expect(screen.queryByLabelText('Minimum gap (mm)')).not.toBeInTheDocument(); + + chooseOption('Kind', 'Minimum clearance'); + chooseOption('Source class', 'StorageRack'); + chooseOption('Target class', 'StorageRack'); + expect(screen.getByLabelText('Minimum gap (mm)')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(screen.getByText('Minimum gap (mm) is required for Minimum clearance.')).toBeInTheDocument(); + expect(metamodelService.updateMetamodel).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByLabelText('Minimum gap (mm)'), { target: { value: '-5' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(screen.getByText('The distance must be a number of millimetres, zero or greater.')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('Minimum gap (mm)'), { target: { value: '1500' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + expect(metamodelService.updateMetamodel).toHaveBeenCalledWith('mm-1', expect.objectContaining({ + spatialRules: [ + metamodel.spatialRules![0], + expect.objectContaining({ + name: 'Aisle width', + kind: 'minClearance', + sourceClassId: 'rack', + targetClassId: 'rack', + distanceMm: 1500, + mode: 'volume', + severity: 'error', + }), + ], + })); + expect(onUpdateMetamodel).toHaveBeenCalledTimes(1); + }); + + it('requires both classes before saving', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add Rule' })); + fireEvent.change(screen.getByLabelText('Rule name'), { target: { value: 'Incomplete' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + expect(screen.getByText('Choose both a source class and a target class.')).toBeInTheDocument(); + expect(metamodelService.updateMetamodel).not.toHaveBeenCalled(); + }); + + it('deletes a rule and hides editing controls when read-only', () => { + const { rerender } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Delete Conveyors apart' })); + expect(metamodelService.updateMetamodel).toHaveBeenCalledWith('mm-1', expect.objectContaining({ spatialRules: [] })); + + rerender(); + expect(screen.queryByRole('button', { name: 'Delete Conveyors apart' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add Rule' })).toBeDisabled(); + }); +}); diff --git a/frontend/src/__tests__/services/ecore-xmi-interop.test.ts b/frontend/src/__tests__/services/ecore-xmi-interop.test.ts index a717330..2055ab4 100644 --- a/frontend/src/__tests__/services/ecore-xmi-interop.test.ts +++ b/frontend/src/__tests__/services/ecore-xmi-interop.test.ts @@ -227,6 +227,70 @@ describe('Ecore interop', () => { }); }); +describe('Ecore spatial rules annotation', () => { + it('exports spatial rules as an EPackage annotation and imports them against the new class ids', () => { + const source = mockCreateMetamodel('Plant'); + const rack = mockAddMetaClass(source.id, 'StorageRack')!; + const zone = mockAddMetaClass(source.id, 'Zone', true)!; + mockUpdateMetamodel(source.id, { + ...mockGetMetamodelById(source.id)!, + spatialRules: [ + { + id: 'rule-aisle', name: 'Aisle width', kind: 'minClearance', sourceClassId: rack.id, + targetClassId: rack.id, distanceMm: 1500, mode: 'plan', severity: 'error', message: '{source} is close', + }, + { + id: 'rule-zone', name: 'Inside a zone', kind: 'within', sourceClassId: rack.id, + targetClassId: zone.id, severity: 'warning', enabled: false, + }, + ], + }); + + const exported = ecoreService.metamodelToEcore(source.id)!; + expect(exported).toContain(''); + expect(exported).toContain('
+ + +
+ + +`; + + const result = ecoreService.importFromEcoreWithReport(ecore); + expect(result.warnings).toEqual([expect.objectContaining({ + type: 'skipped-annotation', + message: 'Skipped spatial rule Dangling; its classes could not be resolved.', + })]); + expect(mockGetMetamodelById(result.metamodelId!)!.spatialRules).toBeUndefined(); + }); +}); + describe('Model XMI interop', () => { it('imports a Library XMI model and exports namespace-stable XMI', () => { const metamodelId = ecoreService.importFromEcore(readFixture('library.ecore')); diff --git a/frontend/src/__tests__/services/spatial-helpers.test.ts b/frontend/src/__tests__/services/spatial-helpers.test.ts index d469e42..d9d57bb 100644 --- a/frontend/src/__tests__/services/spatial-helpers.test.ts +++ b/frontend/src/__tests__/services/spatial-helpers.test.ts @@ -11,11 +11,12 @@ import { evaluateJSConstraint } from '../../services/constraint/js/js.validation import { prepareContextForOCL } from '../../services/constraint/ocl/ocl.context'; import { Model, ModelElement, Metamodel, JSConstraint, MetaClass } from '../../models/types'; -// The helpers are pure AABB operations built on top of the -// coordinate contract in spatial.helpers.ts (position3D is the element's -// X/Y center and Z base, while size3D provides X/Y/Z extents). These tests -// pin down the box math and confirm the helpers are reachable from both -// the JS sandbox and the OCL context under a `spatial` namespace. +// The helpers work on oriented boxes built from the coordinate contract in +// spatial.helpers.ts (position3D is the element's X/Y center and Z base, +// size3D provides local X/Y/Z extents, rotationZ turns the footprint +// counter-clockwise seen from above). These tests pin down the box math, +// including rotated footprints, and confirm the helpers are reachable from +// both the JS sandbox and the OCL context under a `spatial` namespace. const metaClass: MetaClass = { id: 'mc-conveyor', @@ -181,6 +182,105 @@ describe('clearance3D', () => { }); }); +// Rotated footprints. A 2000 x 500 mm conveyor turned 90 degrees occupies +// 500 x 2000 mm in plan view, which an axis-aligned test gets wrong. +const conveyor = (id: string, x: number, y: number, rotationZ: number, z = 0) => makeElement(id, { + position3D: { x, y, z }, + size3D: { widthMm: 2000, heightMm: 500, depthMm: 1000 }, + rotationZ, +}); + +describe('rotation-aware geometry', () => { + const straight = conveyor('straight', 0, 0, 0); + + it('treats 0 degrees exactly like the unrotated footprint', () => { + expect(boundsOf(straight)).toEqual({ minX: -1000, minY: -250, maxX: 1000, maxY: 250 }); + }); + + it('reports overlap for a 90 degree conveyor that crosses a straight one', () => { + // Unrotated, the second conveyor would span y 550..1050 and miss the first. + const turned = conveyor('turned', 0, 800, 90); + expect(boundsOf(turned)).toEqual({ minX: -250, minY: -200, maxX: 250, maxY: 1800 }); + expect(overlaps(straight, turned)).toBe(true); + expect(overlaps(turned, straight)).toBe(true); + expect(clearance(straight, turned)).toBe(0); + }); + + it('reports no overlap for a 90 degree conveyor that an axis-aligned test would flag', () => { + // Unrotated, this conveyor would span x 400..2400 and overlap the first. + const turned = conveyor('turned-clear', 1400, 0, 90); + expect(overlaps(straight, turned)).toBe(false); + expect(overlaps(turned, straight)).toBe(false); + expect(clearance(straight, turned)).toBeCloseTo(150, 9); + }); + + it('does not count touching rotated faces as overlap', () => { + const left = conveyor('left', 0, 0, 90); + const right = conveyor('right', 500, 0, 90); + expect(overlaps(left, right)).toBe(false); + expect(clearance(left, right)).toBe(0); + + // Two conveyors at 30 degrees, offset by exactly one width along their local Y axis. + const radians = (30 * Math.PI) / 180; + const first = conveyor('first', 0, 0, 30); + const second = conveyor('second', -Math.sin(radians) * 500, Math.cos(radians) * 500, 30); + expect(overlaps(first, second)).toBe(false); + expect(clearance(first, second)).toBe(0); + }); + + it('uses the rotated corners for 45 degree footprints', () => { + const diamond = makeElement('diamond', { + position3D: { x: 0, y: 0, z: 0 }, + size3D: { widthMm: 1000, heightMm: 1000, depthMm: 100 }, + rotationZ: 45, + }); + const square = makeElement('square', { + position3D: { x: 1250, y: 0, z: 0 }, + size3D: { widthMm: 1000, heightMm: 1000, depthMm: 100 }, + }); + const halfDiagonal = 500 * Math.SQRT2; + + const bounds = boundsOf(diamond)!; + expect(bounds.minX).toBeCloseTo(-halfDiagonal, 9); + expect(bounds.maxX).toBeCloseTo(halfDiagonal, 9); + expect(bounds.maxY).toBeCloseTo(halfDiagonal, 9); + expect(overlaps(diamond, square)).toBe(false); + expect(clearance(diamond, square)).toBeCloseTo(750 - halfDiagonal, 9); + + const closer = makeElement('closer', { + position3D: { x: 1150, y: 0, z: 0 }, + size3D: { widthMm: 1000, heightMm: 1000, depthMm: 100 }, + }); + // The axis-aligned box of the diamond would stop at x = 500, short of 650. + expect(overlaps(diamond, closer)).toBe(true); + }); + + it('separates elevated and grounded elements by their vertical intervals', () => { + const grounded = conveyor('grounded', 0, 0, 90); + const elevated = conveyor('elevated', 0, 800, 0, 1500); + expect(overlaps(grounded, elevated)).toBe(true); + expect(overlaps3D(grounded, elevated)).toBe(false); + expect(clearance3D(grounded, elevated)).toBe(500); + expect(bounds3DOf(elevated)).toEqual({ + minX: -1000, minY: 550, minZ: 1500, maxX: 1000, maxY: 1050, maxZ: 2500, + }); + + const stacked = conveyor('stacked', 0, 800, 0, 1000); + expect(overlaps3D(grounded, stacked)).toBe(false); + expect(clearance3D(grounded, stacked)).toBe(0); + + const intersecting = conveyor('intersecting', 0, 800, 0, 900); + expect(overlaps3D(grounded, intersecting)).toBe(true); + }); + + it('combines rotated plan gaps with vertical gaps', () => { + const grounded = conveyor('grounded-gap', 0, 0, 0); + const elevated = conveyor('elevated-gap', 1400, 0, 90, 1400); + expect(clearance(grounded, elevated)).toBeCloseTo(150, 9); + expect(clearance3D(grounded, elevated)).toBeCloseTo(Math.sqrt(150 * 150 + 400 * 400), 9); + }); +}); + describe('spatial namespace in constraint contexts', () => { const model: Model = { id: 'model-1', diff --git a/frontend/src/__tests__/services/spatial-rules.test.ts b/frontend/src/__tests__/services/spatial-rules.test.ts new file mode 100644 index 0000000..8473dbf --- /dev/null +++ b/frontend/src/__tests__/services/spatial-rules.test.ts @@ -0,0 +1,365 @@ +import { MetaClass, Metamodel, Model, ModelElement, SpatialRule } from '../../models/types'; +import { + defaultSizeForClass, + evaluateSpatialRules, + validateSpatialRule, + validateSpatialRules, +} from '../../services/spatial'; +import { deleteMetaClass } from '../../services/metamodel/metaclass-crud.service'; +import smartWarehouseMetamodel from '../../examples/data/smart-warehouse-metamodel.json'; +import smartWarehouseModel from '../../examples/data/smart-warehouse-model.json'; + +const metaClass = (id: string, superTypes: string[] = [], extra: Partial = {}): MetaClass => ({ + id, + name: id.charAt(0).toUpperCase() + id.slice(1), + eClass: 'EClass', + abstract: false, + superTypes, + attributes: [], + references: [], + ...extra, +}); + +// equipment <- conveyor, rack; zone <- room; sensor; gateway +const classes: MetaClass[] = [ + metaClass('equipment', [], { abstract: true }), + metaClass('conveyor', ['equipment']), + metaClass('rack', ['equipment']), + metaClass('zone', [], { + abstract: true, + concreteSyntax: { three_d: { defaultSizeMm: { widthMm: 4000, heightMm: 3000, depthMm: 3000 } } }, + }), + metaClass('room', ['zone']), + metaClass('sensor'), + metaClass('gateway'), +]; + +const metamodelWith = (rules: SpatialRule[]): Metamodel => ({ + id: 'mm', + name: 'Spatial', + eClass: 'EPackage', + uri: 'http://example.org/spatial', + prefix: 'sp', + conformsTo: 'ecore', + classes, + spatialRules: rules, +}); + +const element = ( + id: string, + classId: string, + pose?: { x: number; y: number; z?: number; rotationZ?: number }, + size?: { widthMm: number; heightMm: number; depthMm: number } +): ModelElement => ({ + id, + modelElementId: classId, + style: { name: id }, + references: {}, + ...(pose ? { + presentation: { + position3D: { x: pose.x, y: pose.y, z: pose.z ?? 0 }, + ...(size ? { size3D: size } : {}), + ...(pose.rotationZ !== undefined ? { rotationZ: pose.rotationZ } : {}), + }, + } : {}), +}); + +const modelOf = (elements: ModelElement[]): Model => ({ + id: 'model', + name: 'Model', + metamodelId: 'mm', + conformsTo: 'mm', + elements, +}); + +const conveyorSize = { widthMm: 2000, heightMm: 500, depthMm: 1000 }; + +const rule = (overrides: Partial): SpatialRule => ({ + id: 'rule-1', + name: 'Rule', + kind: 'noOverlap', + sourceClassId: 'equipment', + targetClassId: 'equipment', + severity: 'error', + ...overrides, +}); + +describe('evaluateSpatialRules: noOverlap', () => { + it('reports one issue per offending unordered pair, attached to both elements', () => { + const issues = evaluateSpatialRules(metamodelWith([rule({ name: 'No overlap' })]), modelOf([ + element('c1', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('c2', 'conveyor', { x: 0, y: 800, rotationZ: 90 }, conveyorSize), + element('c3', 'conveyor', { x: 5000, y: 0 }, conveyorSize), + ])); + + expect(issues).toHaveLength(1); + expect(issues[0]).toEqual({ + severity: 'error', + message: 'No overlap: c1 overlaps c2.', + elementId: 'c1', + relatedElementIds: ['c2'], + location: 'spatialRules.noOverlap', + constraintId: 'rule-1', + }); + }); + + it('includes instances of subtypes of the source and target classes', () => { + const issues = evaluateSpatialRules(metamodelWith([rule({})]), modelOf([ + element('conveyor', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('rack', 'rack', { x: 1500, y: 0 }, conveyorSize), + element('sensor', 'sensor', { x: 0, y: 0 }, conveyorSize), + ])); + expect(issues.map(issue => [issue.elementId, issue.relatedElementIds])).toEqual([['conveyor', ['rack']]]); + }); + + it('uses the rotated footprint, so a turned conveyor can clear a neighbour', () => { + const elements = [ + element('c1', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('c2', 'conveyor', { x: 1400, y: 0, rotationZ: 90 }, conveyorSize), + ]; + expect(evaluateSpatialRules(metamodelWith([rule({})]), modelOf(elements))).toHaveLength(0); + elements[1].presentation!.rotationZ = 0; + expect(evaluateSpatialRules(metamodelWith([rule({})]), modelOf(elements))).toHaveLength(1); + }); + + it('separates plan and volume modes by elevation', () => { + const elements = [ + element('low', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('high', 'conveyor', { x: 0, y: 0, z: 1500 }, conveyorSize), + ]; + expect(evaluateSpatialRules(metamodelWith([rule({ mode: 'volume' })]), modelOf(elements))).toHaveLength(0); + expect(evaluateSpatialRules(metamodelWith([rule({})]), modelOf(elements))).toHaveLength(0); + const planIssues = evaluateSpatialRules(metamodelWith([rule({ mode: 'plan' })]), modelOf(elements)); + expect(planIssues).toHaveLength(1); + expect(planIssues[0].message).toBe('Rule: low overlaps high in plan view.'); + }); + + it('does not count touching footprints as overlapping', () => { + expect(evaluateSpatialRules(metamodelWith([rule({ mode: 'plan' })]), modelOf([ + element('a', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('b', 'conveyor', { x: 2000, y: 0 }, conveyorSize), + ]))).toHaveLength(0); + }); + + it('skips elements without a pose or without resolvable extents', () => { + expect(evaluateSpatialRules(metamodelWith([rule({})]), modelOf([ + element('placed', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('no-pose', 'conveyor'), + element('no-size', 'conveyor', { x: 0, y: 0 }), + ]))).toHaveLength(0); + }); +}); + +describe('evaluateSpatialRules: minClearance', () => { + const clearanceRule = rule({ kind: 'minClearance', distanceMm: 1000, mode: 'plan', name: 'Aisle' }); + + it('flags pairs closer than the distance and accepts a gap of exactly the distance', () => { + const issues = evaluateSpatialRules(metamodelWith([clearanceRule]), modelOf([ + element('a', 'rack', { x: 0, y: 0 }, conveyorSize), + element('b', 'rack', { x: 2600, y: 0 }, conveyorSize), + element('c', 'rack', { x: 0, y: 1500 }, conveyorSize), + ])); + expect(issues).toHaveLength(1); + expect(issues[0].message).toBe('Aisle: a is 600 mm from b; at least 1000 mm is required.'); + expect(issues[0].relatedElementIds).toEqual(['b']); + }); + + it('measures the gap in volume mode as sqrt(planGap^2 + verticalGap^2)', () => { + const elements = [ + element('a', 'rack', { x: 0, y: 0 }, conveyorSize), + element('b', 'rack', { x: 2600, y: 0, z: 1800 }, conveyorSize), + ]; + // plan gap 600, vertical gap 800 -> 1000 + expect(evaluateSpatialRules(metamodelWith([{ ...clearanceRule, mode: 'volume' }]), modelOf(elements))) + .toHaveLength(0); + expect(evaluateSpatialRules(metamodelWith([clearanceRule]), modelOf(elements))).toHaveLength(1); + }); + + it('fills custom message placeholders', () => { + const issues = evaluateSpatialRules(metamodelWith([{ + ...clearanceRule, + message: '{source} is too close to {target} (needs {distance} mm, rule {rule})', + }]), modelOf([ + element('a', 'rack', { x: 0, y: 0 }, conveyorSize), + element('b', 'rack', { x: 2600, y: 0 }, conveyorSize), + ])); + expect(issues[0].message).toBe('Aisle: a is too close to b (needs 1000 mm, rule Aisle)'); + }); +}); + +describe('evaluateSpatialRules: maxDistance', () => { + const rangeRule = rule({ + kind: 'maxDistance', sourceClassId: 'sensor', targetClassId: 'gateway', distanceMm: 10000, name: 'Range', + }); + const small = { widthMm: 100, heightMm: 100, depthMm: 100 }; + + it('reports each source with no target centre within the distance', () => { + const issues = evaluateSpatialRules(metamodelWith([rangeRule]), modelOf([ + element('gw', 'gateway', { x: 0, y: 0 }, small), + element('near', 'sensor', { x: 6000, y: 8000 }, small), + element('far', 'sensor', { x: 9000, y: 8000 }, small), + ])); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + elementId: 'far', + message: 'Range: far has no Gateway within 10000 mm; the nearest is 12041.6 mm away.', + }); + expect(issues[0].relatedElementIds).toBeUndefined(); + }); + + it('measures volume centres at mid elevation and plan centres ignoring elevation', () => { + const elements = [ + element('gw', 'gateway', { x: 0, y: 0, z: 0 }, small), + element('ceiling', 'sensor', { x: 6000, y: 6000, z: 7950 }, small), + ]; + // plan distance 8485.3, vertical distance 7950 -> 11627.5 in volume mode + expect(evaluateSpatialRules(metamodelWith([rangeRule]), modelOf(elements))).toHaveLength(1); + expect(evaluateSpatialRules(metamodelWith([{ ...rangeRule, mode: 'plan' }]), modelOf(elements))).toHaveLength(0); + }); + + it('treats unplaced targets as absent', () => { + const issues = evaluateSpatialRules(metamodelWith([rangeRule]), modelOf([ + element('gw', 'gateway'), + element('sensor', 'sensor', { x: 0, y: 0 }, small), + ])); + expect(issues[0].message).toBe('Range: sensor has no placed Gateway within 10000 mm.'); + }); +}); + +describe('evaluateSpatialRules: within', () => { + const withinRule = rule({ kind: 'within', sourceClassId: 'sensor', targetClassId: 'zone', name: 'In a room' }); + const detector = { widthMm: 200, heightMm: 200, depthMm: 100 }; + + it('uses the metaclass default extents for zones without size3D', () => { + expect(defaultSizeForClass(metamodelWith([]), 'room')).toEqual({ widthMm: 4000, heightMm: 3000, depthMm: 3000 }); + const issues = evaluateSpatialRules(metamodelWith([withinRule]), modelOf([ + element('room', 'room', { x: 0, y: 0 }), + element('inside', 'sensor', { x: 1800, y: 0, z: 2700 }, detector), + element('outside', 'sensor', { x: 2100, y: 0, z: 2700 }, detector), + ])); + expect(issues.map(issue => issue.elementId)).toEqual(['outside']); + expect(issues[0].message).toBe('In a room: outside is not inside any Zone.'); + }); + + it('shrinks the target footprint by the margin', () => { + const issues = evaluateSpatialRules(metamodelWith([{ ...withinRule, distanceMm: 300 }]), modelOf([ + element('room', 'room', { x: 0, y: 0 }), + element('edge', 'sensor', { x: 1800, y: 0, z: 2700 }, detector), + element('centre', 'sensor', { x: 0, y: 0, z: 2700 }, detector), + ])); + expect(issues.map(issue => issue.message)).toEqual(['In a room: edge is not inside any Zone with a 300 mm margin.']); + }); + + it('honours the target rotation', () => { + const elements = [ + element('room', 'room', { x: 0, y: 0, rotationZ: 90 }), + element('sensor', 'sensor', { x: 0, y: 1800, z: 100 }, detector), + ]; + // Turned 90 degrees, the 4000 mm side runs along Y. + expect(evaluateSpatialRules(metamodelWith([withinRule]), modelOf(elements))).toHaveLength(0); + elements[0].presentation!.rotationZ = 0; + expect(evaluateSpatialRules(metamodelWith([withinRule]), modelOf(elements))).toHaveLength(1); + }); + + it('requires the vertical interval inside the target in volume mode only', () => { + const elements = [ + element('room', 'room', { x: 0, y: 0 }), + element('above', 'sensor', { x: 0, y: 0, z: 2950 }, detector), + ]; + expect(evaluateSpatialRules(metamodelWith([withinRule]), modelOf(elements))).toHaveLength(1); + expect(evaluateSpatialRules(metamodelWith([{ ...withinRule, mode: 'plan' }]), modelOf(elements))).toHaveLength(0); + }); +}); + +describe('evaluateSpatialRules: adjacentTo', () => { + const adjacentRule = rule({ + kind: 'adjacentTo', sourceClassId: 'rack', targetClassId: 'equipment', distanceMm: 100, mode: 'plan', name: 'Next to', + }); + + it('needs a distinct target within the gap tolerance', () => { + const issues = evaluateSpatialRules(metamodelWith([adjacentRule]), modelOf([ + element('rack', 'rack', { x: 0, y: 0 }, conveyorSize), + element('lonely', 'rack', { x: 10000, y: 0 }, conveyorSize), + element('conveyor', 'conveyor', { x: 2050, y: 0 }, conveyorSize), + ])); + // rack is 50 mm from the conveyor; lonely only has itself, which does not count. + expect(issues.map(issue => issue.elementId)).toEqual(['lonely']); + expect(issues[0].message).toBe('Next to: lonely is not within 100 mm of any Equipment.'); + }); +}); + +describe('spatial rule configuration', () => { + it('validates required fields, distances, and class references', () => { + const metamodel = metamodelWith([]); + expect(validateSpatialRule(rule({}), metamodel)).toEqual([]); + expect(validateSpatialRule(rule({ kind: 'minClearance' }), metamodel)) + .toEqual(['spatialRule.distanceMm is required for minClearance']); + expect(validateSpatialRule(rule({ kind: 'within' }), metamodel)).toEqual([]); + expect(validateSpatialRule(rule({ kind: 'maxDistance', distanceMm: -1 }), metamodel)) + .toEqual(['spatialRule.distanceMm must be a non-negative number']); + expect(validateSpatialRule(rule({ targetClassId: 'missing' }), metamodel)) + .toEqual(['spatialRule.targetClassId references unknown metaclass missing']); + expect(validateSpatialRules([rule({}), rule({})], metamodel)) + .toEqual(['spatialRules[1].id duplicates rule rule-1']); + }); + + it('reports an invalid rule once, without evaluating it', () => { + const issues = evaluateSpatialRules(metamodelWith([rule({ sourceClassId: 'deleted', name: 'Broken' })]), modelOf([ + element('a', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('b', 'conveyor', { x: 0, y: 0 }, conveyorSize), + ])); + expect(issues).toEqual([{ + severity: 'error', + message: 'Spatial rule "Broken" is invalid: sourceClassId references unknown metaclass deleted', + location: 'spatialRules.noOverlap', + constraintId: 'rule-1', + }]); + }); + + it('drops rules that reference a deleted class', () => { + const metamodel = JSON.parse(JSON.stringify(metamodelWith([ + rule({ id: 'keep', sourceClassId: 'equipment', targetClassId: 'equipment' }), + rule({ id: 'drop', kind: 'within', sourceClassId: 'sensor', targetClassId: 'zone' }), + ]))); + expect(deleteMetaClass(metamodel, 'zone')).toBe(true); + expect(metamodel.spatialRules.map((candidate: SpatialRule) => candidate.id)).toEqual(['keep']); + }); + + it('ignores disabled rules', () => { + expect(evaluateSpatialRules(metamodelWith([rule({ enabled: false })]), modelOf([ + element('a', 'conveyor', { x: 0, y: 0 }, conveyorSize), + element('b', 'conveyor', { x: 0, y: 0 }, conveyorSize), + ]))).toEqual([]); + }); +}); + +describe('Smart Warehouse example rules', () => { + const metamodel = smartWarehouseMetamodel as unknown as Metamodel; + const model = smartWarehouseModel as unknown as Model; + + it('ships rules that the bundled model satisfies', () => { + expect(metamodel.spatialRules!.length).toBeGreaterThanOrEqual(3); + expect(validateSpatialRules(metamodel.spatialRules, metamodel)).toEqual([]); + expect(evaluateSpatialRules(metamodel, model)).toEqual([]); + }); + + it('reports a conveyor moved onto another conveyor, rotated or not', () => { + const moved: Model = JSON.parse(JSON.stringify(model)); + const conveyors = moved.elements.filter(candidate => candidate.style.name?.startsWith('Conveyor')); + const [first, second] = conveyors; + second.presentation!.position3D = { + x: first.presentation!.position3D!.x + 3500, + y: first.presentation!.position3D!.y, + z: 0, + }; + second.presentation!.rotationZ = 90; + + const issues = evaluateSpatialRules(metamodel, moved); + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ + severity: 'error', + elementId: first.id, + relatedElementIds: [second.id], + }); + }); +}); diff --git a/frontend/src/__tests__/services/view-validation.test.ts b/frontend/src/__tests__/services/view-validation.test.ts index 434d7ef..fdcfe33 100644 --- a/frontend/src/__tests__/services/view-validation.test.ts +++ b/frontend/src/__tests__/services/view-validation.test.ts @@ -69,6 +69,23 @@ describe('viewValidationService', () => { ])).toBe('error'); }); + it('marks both elements of a spatial-rule pair and keeps the issue when only one is visible', () => { + const pairIssue: ValidationIssue = { + severity: 'error', + message: 'No overlap: Conveyor overlaps Rack.', + elementId: 'conveyor-hidden', + relatedElementIds: ['robot-1'], + constraintId: 'spatial-rule-1', + }; + (modelService.validateModel as jest.Mock).mockReturnValue({ valid: false, issues: [pairIssue] }); + + const result = viewValidationService.validateDiagram(diagram); + + expect(result.issues).toEqual([pairIssue]); + expect(viewValidationService.findElementForIssue(diagram, pairIssue)?.id).toBe('view-node-1'); + expect(viewValidationService.getElementSeverity(diagram, diagram.elements[0], [pairIssue])).toBe('error'); + }); + it('warns without overwriting when an elevated element opens in a grounded representation', () => { (modelService.validateModel as jest.Mock).mockReturnValue({ valid: true, issues: [] }); (modelService.getModelById as jest.Mock).mockReturnValue({ diff --git a/frontend/src/components/metamodel/SpatialRulesEditor.tsx b/frontend/src/components/metamodel/SpatialRulesEditor.tsx new file mode 100644 index 0000000..dfc5841 --- /dev/null +++ b/frontend/src/components/metamodel/SpatialRulesEditor.tsx @@ -0,0 +1,391 @@ +import React, { useState } from 'react'; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + FormControlLabel, + FormHelperText, + IconButton, + InputLabel, + List, + ListItem, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import ErrorIcon from '@mui/icons-material/Error'; +import WarningIcon from '@mui/icons-material/Warning'; +import InfoIcon from '@mui/icons-material/Info'; +import { v4 as uuidv4 } from 'uuid'; +import { Metamodel, SpatialRule, SpatialRuleKind } from '../../models/types'; +import { metamodelService } from '../../services/metamodel'; +import { + SPATIAL_RULE_KINDS, + spatialRuleRequiresDistance, + spatialRuleUsesDistance, + validateSpatialRule, +} from '../../services/spatial'; + +interface SpatialRulesEditorProps { + metamodel: Metamodel; + readOnly?: boolean; + onUpdateMetamodel: (updatedMetamodel: Metamodel) => void; +} + +const KIND_LABELS: Record = { + noOverlap: 'No overlap', + minClearance: 'Minimum clearance', + maxDistance: 'Maximum distance', + within: 'Within', + adjacentTo: 'Adjacent to', +}; + +const DISTANCE_LABELS: Record = { + noOverlap: '', + minClearance: 'Minimum gap (mm)', + maxDistance: 'Maximum centre distance (mm)', + within: 'Margin from the target boundary (mm)', + adjacentTo: 'Maximum gap (mm)', +}; + +interface RuleDraft { + name: string; + kind: SpatialRuleKind; + sourceClassId: string; + targetClassId: string; + distance: string; + mode: 'plan' | 'volume'; + severity: SpatialRule['severity']; + message: string; + enabled: boolean; +} + +const emptyDraft = (): RuleDraft => ({ + name: '', + kind: 'noOverlap', + sourceClassId: '', + targetClassId: '', + distance: '', + mode: 'volume', + severity: 'error', + message: '', + enabled: true, +}); + +const draftFromRule = (rule: SpatialRule): RuleDraft => ({ + name: rule.name, + kind: rule.kind, + sourceClassId: rule.sourceClassId, + targetClassId: rule.targetClassId, + distance: rule.distanceMm === undefined ? '' : String(rule.distanceMm), + mode: rule.mode || 'volume', + severity: rule.severity, + message: rule.message || '', + enabled: rule.enabled !== false, +}); + +export const describeSpatialRule = (rule: SpatialRule, metamodel: Metamodel): string => { + const className = (classId: string) => metamodel.classes.find(cls => cls.id === classId)?.name || classId; + const source = className(rule.sourceClassId); + const target = className(rule.targetClassId); + const distance = rule.distanceMm ?? 0; + switch (rule.kind) { + case 'noOverlap': + return `${source} and ${target} do not overlap`; + case 'minClearance': + return `${source} keeps at least ${distance} mm from ${target}`; + case 'maxDistance': + return `Each ${source} has a ${target} centre within ${distance} mm`; + case 'within': + return `Each ${source} lies inside a ${target}${distance > 0 ? ` (${distance} mm margin)` : ''}`; + case 'adjacentTo': + return `Each ${source} is within ${distance} mm of a ${target}`; + default: + return rule.kind; + } +}; + +const severityIcon = (severity: SpatialRule['severity']) => { + if (severity === 'error') return ; + if (severity === 'warning') return ; + return ; +}; + +/** + * Metamodel-level editor for declarative spatial rules. Rules are part of + * the language definition and are evaluated by browser validation and by + * the headless pipeline. + */ +const SpatialRulesEditor: React.FC = ({ + metamodel, + readOnly = false, + onUpdateMetamodel, +}) => { + const rules = metamodel.spatialRules || []; + const [dialogOpen, setDialogOpen] = useState(false); + const [editingRuleId, setEditingRuleId] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [error, setError] = useState(null); + + const updateDraft = (changes: Partial) => setDraft(current => ({ ...current, ...changes })); + + const openDialog = (rule?: SpatialRule) => { + setEditingRuleId(rule ? rule.id : null); + setDraft(rule ? draftFromRule(rule) : emptyDraft()); + setError(null); + setDialogOpen(true); + }; + + const saveRules = (nextRules: SpatialRule[]): boolean => { + try { + const updated = metamodelService.updateMetamodel(metamodel.id, { ...metamodel, spatialRules: nextRules }); + if (updated) onUpdateMetamodel(updated); + return true; + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : String(saveError)); + return false; + } + }; + + const handleSave = () => { + if (!draft.name.trim()) { + setError('Enter a rule name.'); + return; + } + if (!draft.sourceClassId || !draft.targetClassId) { + setError('Choose both a source class and a target class.'); + return; + } + const usesDistance = spatialRuleUsesDistance(draft.kind); + const distanceText = draft.distance.trim(); + if (usesDistance && distanceText === '' && spatialRuleRequiresDistance(draft.kind)) { + setError(`${DISTANCE_LABELS[draft.kind]} is required for ${KIND_LABELS[draft.kind]}.`); + return; + } + const distanceMm = usesDistance && distanceText !== '' ? Number(distanceText) : undefined; + if (distanceMm !== undefined && (!Number.isFinite(distanceMm) || distanceMm < 0)) { + setError('The distance must be a number of millimetres, zero or greater.'); + return; + } + + const rule: SpatialRule = { + id: editingRuleId || uuidv4(), + name: draft.name.trim(), + kind: draft.kind, + sourceClassId: draft.sourceClassId, + targetClassId: draft.targetClassId, + ...(distanceMm !== undefined && { distanceMm }), + mode: draft.mode, + severity: draft.severity, + ...(draft.message.trim() && { message: draft.message.trim() }), + ...(!draft.enabled && { enabled: false }), + }; + const ruleErrors = validateSpatialRule(rule, metamodel); + if (ruleErrors.length > 0) { + setError(ruleErrors[0]); + return; + } + + const saved = saveRules(editingRuleId + ? rules.map(existing => (existing.id === editingRuleId ? rule : existing)) + : [...rules, rule]); + if (saved) setDialogOpen(false); + }; + + const handleDelete = (ruleId: string) => { + saveRules(rules.filter(rule => rule.id !== ruleId)); + }; + + return ( + + + Spatial Rules + + + + Rules over placed instances, checked with the other constraints when a model or view is validated. + + + {rules.length === 0 ? ( + No spatial rules defined for this metamodel. + ) : ( + + {rules.map(rule => ( + + + {severityIcon(rule.severity)} + + {rule.name} + + {!readOnly && ( + <> + openDialog(rule)}> + + + handleDelete(rule.id)}> + + + + )} + + + {describeSpatialRule(rule, metamodel)} + + + + + {rule.enabled === false && } + + + ))} + + )} + + setDialogOpen(false)} maxWidth="sm" fullWidth> + {editingRuleId ? 'Edit Spatial Rule' : 'Add Spatial Rule'} + + {error && {error}} + updateDraft({ name: event.target.value })} + /> + + Kind + + + + Source class + + Instances of this class and its subclasses are checked. + + + Target class + + + {spatialRuleUsesDistance(draft.kind) && ( + updateDraft({ distance: event.target.value })} + helperText={draft.kind === 'within' ? 'Optional; defaults to 0.' : undefined} + /> + )} + + Geometry + + + + Severity + + + updateDraft({ message: event.target.value })} + helperText="Placeholders: {source}, {target}, {distance}, {rule}" + /> + updateDraft({ enabled: event.target.checked })} + /> + )} + label="Enabled" + /> + + + + + + + + ); +}; + +export default SpatialRulesEditor; diff --git a/frontend/src/components/metamodel/components/sidebar/MetamodelSidebar.tsx b/frontend/src/components/metamodel/components/sidebar/MetamodelSidebar.tsx index 98c9f75..e81f538 100644 --- a/frontend/src/components/metamodel/components/sidebar/MetamodelSidebar.tsx +++ b/frontend/src/components/metamodel/components/sidebar/MetamodelSidebar.tsx @@ -25,6 +25,7 @@ import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ConstraintTypeSelector from '../../ConstraintTypeSelector'; import MetaClassNotationEditor from '../../MetaClassNotationEditor'; +import SpatialRulesEditor from '../../SpatialRulesEditor'; import { metamodelService } from '../../../../services/metamodel'; import { MetaClass, Metamodel } from '../../../../models/types'; @@ -669,6 +670,12 @@ export const MetamodelSidebar: React.FC = ({ )} + + boxB.minX && - boxA.minY < boxB.maxY && - boxA.maxY > boxB.minY - ); + const boxA = elementBox(a); + const boxB = elementBox(b); + return Boolean(boxA && boxB && planOverlap(boxA, boxB)); } -/** True when the two 3D AABBs overlap. Touching faces do not overlap. */ +/** True when the two rotated volumes overlap. Touching faces do not overlap. */ export function overlaps3D(a: ModelElement, b: ModelElement): boolean { - const boxA = bounds3DOf(a); - const boxB = bounds3DOf(b); - if (!boxA || !boxB) return false; - - return ( - boxA.minX < boxB.maxX && - boxA.maxX > boxB.minX && - boxA.minY < boxB.maxY && - boxA.maxY > boxB.minY && - boxA.minZ < boxB.maxZ && - boxA.maxZ > boxB.minZ - ); + const boxA = elementBox(a); + const boxB = elementBox(b); + return Boolean(boxA && boxB && volumeOverlap(boxA, boxB)); } /** - * Gap distance between the two elements' AABBs. Zero when they overlap or touch. + * Plan-view gap between the two rotated footprints. Zero when they overlap or touch. * Returns Infinity if either element is missing bounds data. */ export function clearance(a: ModelElement, b: ModelElement): number { - const boxA = boundsOf(a); - const boxB = boundsOf(b); - if (!boxA || !boxB) return Infinity; - - const dx = Math.max(boxA.minX - boxB.maxX, boxB.minX - boxA.maxX, 0); - const dy = Math.max(boxA.minY - boxB.maxY, boxB.minY - boxA.maxY, 0); - return Math.sqrt(dx * dx + dy * dy); + const boxA = elementBox(a); + const boxB = elementBox(b); + return boxA && boxB ? planGap(boxA, boxB) : Infinity; } - /** - * Euclidean gap between two 3D AABBs. Returns zero for overlap/touching and - * Infinity when either element has no spatial volume. + * Euclidean gap between two rotated volumes: sqrt(planGap^2 + verticalGap^2). + * Returns zero for overlap/touching and Infinity when either element has no + * spatial volume. */ export function clearance3D(a: ModelElement, b: ModelElement): number { - const boxA = bounds3DOf(a); - const boxB = bounds3DOf(b); - if (!boxA || !boxB) return Infinity; - - const dx = Math.max(boxA.minX - boxB.maxX, boxB.minX - boxA.maxX, 0); - const dy = Math.max(boxA.minY - boxB.maxY, boxB.minY - boxA.maxY, 0); - const dz = Math.max(boxA.minZ - boxB.maxZ, boxB.minZ - boxA.maxZ, 0); - return Math.sqrt(dx * dx + dy * dy + dz * dz); + const boxA = elementBox(a); + const boxB = elementBox(b); + return boxA && boxB ? volumeGap(boxA, boxB) : Infinity; } diff --git a/frontend/src/services/diagram/view-validation.service.ts b/frontend/src/services/diagram/view-validation.service.ts index c36c1c7..d796537 100644 --- a/frontend/src/services/diagram/view-validation.service.ts +++ b/frontend/src/services/diagram/view-validation.service.ts @@ -60,8 +60,11 @@ class ViewValidationService { } findElementForIssue(diagram: Diagram, issue: ValidationIssue): DiagramElement | undefined { - if (!issue.elementId) return undefined; - return diagram.elements.find(element => this.getSemanticIds(element).includes(issue.elementId!)); + for (const issueElementId of this.getIssueElementIds(issue)) { + const element = diagram.elements.find(candidate => this.getSemanticIds(candidate).includes(issueElementId)); + if (element) return element; + } + return undefined; } getElementSeverity( @@ -71,11 +74,17 @@ class ViewValidationService { ): ValidationSeverity | undefined { const semanticIds = this.getSemanticIds(element); return issues - .filter(issue => Boolean(issue.elementId && semanticIds.includes(issue.elementId))) + .filter(issue => this.getIssueElementIds(issue).some(id => semanticIds.includes(id))) .map(issue => issue.severity) .sort((left, right) => severityRank[right] - severityRank[left])[0]; } + /** The issue's element first, then any related elements such as the other half of a pair. */ + private getIssueElementIds(issue: ValidationIssue): string[] { + return [issue.elementId, ...(issue.relatedElementIds || [])] + .filter((value): value is string => typeof value === 'string' && value.length > 0); + } + private getSemanticIds(element: DiagramElement): string[] { return Array.from(new Set([ element.id, diff --git a/frontend/src/services/metamodel/ecore.service.ts b/frontend/src/services/metamodel/ecore.service.ts index 9b7a15b..eee8364 100644 --- a/frontend/src/services/metamodel/ecore.service.ts +++ b/frontend/src/services/metamodel/ecore.service.ts @@ -6,6 +6,7 @@ import { MetaEnum, MetaReference, Metamodel, + SpatialRule, } from '../../models/types'; import { metamodelService } from './metamodel.service'; import { v4 as uuidv4 } from 'uuid'; @@ -34,6 +35,9 @@ export interface EcoreImportResult { const ECORE_NS = 'http://www.eclipse.org/emf/2002/Ecore'; const OCL_SOURCE = 'http://www.eclipse.org/emf/2002/Ecore/OCL'; +// Spatial rules travel as one EPackage annotation detail per rule, keyed by +// rule name, whose value is a JSON object naming classes instead of IDs. +const SPATIAL_RULES_SOURCE = 'spatialdsl/spatialRules'; class EcoreService { metamodelToEcore(metamodelId: string): string | null { @@ -58,6 +62,8 @@ class EcoreService { nsPrefix="${this.escapeXml(metamodel.prefix)}" nsURI="${this.escapeXml(metamodel.uri)}">`; + ecoreContent += this.generateSpatialRulesAnnotationXml(metamodel); + for (const metaclass of metamodel.classes) { ecoreContent += this.generateEClassXml(metaclass, metamodel); } @@ -101,6 +107,72 @@ class EcoreService { return eClassXml; } + private generateSpatialRulesAnnotationXml(metamodel: Metamodel): string { + const classNames = new Map(metamodel.classes.map(cls => [cls.id, cls.name])); + const details = (metamodel.spatialRules || []) + .filter(rule => classNames.has(rule.sourceClassId) && classNames.has(rule.targetClassId)) + .map(rule => { + const value = JSON.stringify({ + kind: rule.kind, + source: classNames.get(rule.sourceClassId), + target: classNames.get(rule.targetClassId), + distanceMm: rule.distanceMm, + mode: rule.mode, + severity: rule.severity, + message: rule.message, + enabled: rule.enabled, + }); + return `\n
`; + }); + if (details.length === 0) return ''; + return `\n ${details.join('')}\n `; + } + + private parseSpatialRules( + ePackage: Element, + nameToClassId: Map, + warnings: EcoreImportWarning[] + ): SpatialRule[] { + const rules: SpatialRule[] = []; + Array.from(ePackage.children) + .filter(child => child.localName === 'eAnnotations' && child.getAttribute('source') === SPATIAL_RULES_SOURCE) + .flatMap(annotation => Array.from(annotation.children).filter(child => child.localName === 'details')) + .forEach(detail => { + const name = detail.getAttribute('key') || 'Spatial rule'; + try { + const parsed = JSON.parse(detail.getAttribute('value') || '{}'); + const sourceClassId = nameToClassId.get(parsed.source); + const targetClassId = nameToClassId.get(parsed.target); + if (!sourceClassId || !targetClassId) { + warnings.push({ + type: 'skipped-annotation', + message: `Skipped spatial rule ${name}; its classes could not be resolved.`, + details: [`${parsed.source} -> ${parsed.target}`], + }); + return; + } + rules.push({ + id: uuidv4(), + name, + kind: parsed.kind, + sourceClassId, + targetClassId, + ...(parsed.distanceMm !== undefined && { distanceMm: parsed.distanceMm }), + ...(parsed.mode !== undefined && { mode: parsed.mode }), + severity: parsed.severity || 'error', + ...(parsed.message !== undefined && { message: parsed.message }), + ...(parsed.enabled !== undefined && { enabled: parsed.enabled }), + }); + } catch { + warnings.push({ + type: 'skipped-annotation', + message: `Skipped spatial rule ${name}; its value is not valid JSON.`, + }); + } + }); + return rules; + } + private generateOclAnnotationXml(constraint: Constraint): string { return `\n
@@ -332,6 +404,12 @@ class EcoreService { }); this.resolveOpposites(newMetamodel.id, warnings); + + const spatialRules = this.parseSpatialRules(ePackage, nameToClassId, warnings); + const importedMetamodel = metamodelService.getMetamodelById(newMetamodel.id); + if (spatialRules.length > 0 && importedMetamodel) { + metamodelService.updateMetamodel(newMetamodel.id, { ...importedMetamodel, spatialRules }); + } return { metamodelId: newMetamodel.id, warnings }; } catch (error) { console.error('Error importing Ecore:', error); diff --git a/frontend/src/services/metamodel/metaclass-crud.service.ts b/frontend/src/services/metamodel/metaclass-crud.service.ts index 8a8783d..702a194 100644 --- a/frontend/src/services/metamodel/metaclass-crud.service.ts +++ b/frontend/src/services/metamodel/metaclass-crud.service.ts @@ -118,6 +118,11 @@ export function deleteMetaClass( cls.references = cls.references.filter(ref => ref.target !== classId); cls.superTypes = cls.superTypes.filter(st => st !== classId); }); + if (metamodel.spatialRules) { + metamodel.spatialRules = metamodel.spatialRules.filter(rule => ( + rule.sourceClassId !== classId && rule.targetClassId !== classId + )); + } return initialLength !== metamodel.classes.length; } diff --git a/frontend/src/services/metamodel/metamodel-api-sync.service.ts b/frontend/src/services/metamodel/metamodel-api-sync.service.ts index 3371516..64386a8 100644 --- a/frontend/src/services/metamodel/metamodel-api-sync.service.ts +++ b/frontend/src/services/metamodel/metamodel-api-sync.service.ts @@ -25,6 +25,7 @@ export function syncMetamodelToAPI( classes: metamodel.classes, enums: metamodel.enums || [], constraints: metamodel.constraints, + spatialRules: metamodel.spatialRules || [], }; // Check if there's a pending save for this metamodel, wait for it first @@ -91,6 +92,7 @@ export function saveMetamodelToAPI( classes: metamodel.classes, enums: metamodel.enums || [], constraints: metamodel.constraints, + spatialRules: metamodel.spatialRules || [], }; const savePromise = (async () => { diff --git a/frontend/src/services/metamodel/metamodel.service.ts b/frontend/src/services/metamodel/metamodel.service.ts index f786c78..0835230 100644 --- a/frontend/src/services/metamodel/metamodel.service.ts +++ b/frontend/src/services/metamodel/metamodel.service.ts @@ -9,7 +9,7 @@ import { validateMetamodel, validateMetamodelVerticalPlacementPolicies, } from './metamodel-validation.service'; -import { validateConcreteSyntaxVerticalPlacement } from '../spatial'; +import { validateConcreteSyntaxVerticalPlacement, validateSpatialRules } from '../spatial'; import { addMetaClass, updateMetaClass, deleteMetaClass } from './metaclass-crud.service'; import { addMetaAttribute, updateMetaAttribute, deleteMetaAttribute } from './metaattribute-crud.service'; import { @@ -169,6 +169,10 @@ class MetamodelService { } const policyErrors = validateMetamodelVerticalPlacementPolicies(metamodelData); if (policyErrors.length > 0) throw new Error(policyErrors[0]); + if (metamodelData.spatialRules !== undefined) { + const ruleErrors = validateSpatialRules(metamodelData.spatialRules, metamodelData); + if (ruleErrors.length > 0) throw new Error(ruleErrors[0]); + } const corePackage = metaMetamodelService.getCoreEPackage(); const importedMetamodel: Metamodel = { diff --git a/frontend/src/services/model/model-validation.service.ts b/frontend/src/services/model/model-validation.service.ts index 145a55f..53eb8a6 100644 --- a/frontend/src/services/model/model-validation.service.ts +++ b/frontend/src/services/model/model-validation.service.ts @@ -11,6 +11,7 @@ import { import { metamodelService } from '../metamodel'; import { oclService } from '../constraint'; import { jsService } from '../constraint'; +import { evaluateSpatialRules } from '../spatial'; import { modelInheritanceUtilsService } from './model-inheritance-utils.service'; /** @@ -76,6 +77,9 @@ export class ModelValidationService { // Validate JavaScript constraints this.validateJSConstraints(model, metamodel, issues); + // Validate declarative spatial rules of the metamodel + this.validateSpatialRules(model, metamodel, issues); + // Store the validation issues this.lastValidationIssues = issues.filter(issue => 'constraintId' in issue) as OCLValidationIssue[]; @@ -605,6 +609,19 @@ export class ModelValidationService { } } + private validateSpatialRules(model: Model, metamodel: Metamodel, issues: ValidationIssue[]): void { + try { + issues.push(...evaluateSpatialRules(metamodel, model)); + } catch (error) { + console.error('Error validating spatial rules:', error); + issues.push({ + severity: 'error', + message: `Error validating spatial rules: ${error instanceof Error ? error.message : String(error)}`, + elementId: model.id + }); + } + } + private findOppositeReference(reference: MetaReference, metamodel: Metamodel): MetaReference | undefined { if (!reference.opposite) return undefined; diff --git a/frontend/src/services/spatial/index.ts b/frontend/src/services/spatial/index.ts index d3cda7f..610141d 100644 --- a/frontend/src/services/spatial/index.ts +++ b/frontend/src/services/spatial/index.ts @@ -1 +1,3 @@ export * from './spatial-presentation'; +export * from './spatial-geometry'; +export * from './spatial-rules'; diff --git a/frontend/src/services/spatial/spatial-geometry.ts b/frontend/src/services/spatial/spatial-geometry.ts new file mode 100644 index 0000000..66eacf5 --- /dev/null +++ b/frontend/src/services/spatial/spatial-geometry.ts @@ -0,0 +1,241 @@ +import { ModelElement, Position3D, Size3D } from '../../models/types'; +import { isFiniteNumber, normalizePosition3D } from './spatial-presentation'; + +// Mirrored by shared/spatial-geometry.ts (backend) and +// frontend/src/services/spatial/spatial-geometry.ts. Keep both copies +// identical below the import block; a parity test enforces it. + +/** + * Oriented geometry for placed model elements. + * + * Domain frame: right-handed, Z up, millimetres. `position3D.x/y` is the + * plan-view centre and `position3D.z` the base elevation. `size3D.widthMm` + * runs along the element's local X axis, `heightMm` along its local Y axis, + * and `depthMm` is the vertical extent. `rotationZ` is yaw in degrees about + * +Z; positive values turn counter-clockwise seen from above, the same sign + * the 3D editor and OpenUSD `xformOp:rotateZ` use. + * + * An element therefore occupies a vertical prism: an oriented rectangle in + * plan view extruded over `[z, z + depthMm]`. + */ + +export interface PlanPoint { + x: number; + y: number; +} + +export interface PlanBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface OrientedBox { + center: PlanPoint; + halfWidth: number; + halfHeight: number; + rotationZ: number; + /** Unit vector of the local X axis in the domain frame. */ + axisX: PlanPoint; + /** Unit vector of the local Y axis in the domain frame. */ + axisY: PlanPoint; + /** Plan-view corners, counter-clockwise from local (-w/2, -h/2). */ + corners: PlanPoint[]; + minZ: number; + maxZ: number; +} + +/** Tolerance in millimetres that absorbs floating-point noise from rotation. */ +export const GEOMETRY_EPSILON_MM = 1e-6; + +const rotationAxes = (rotationZ: number): { cos: number; sin: number } => { + const normalized = ((rotationZ % 360) + 360) % 360; + // Exact quarter turns keep axis-aligned layouts free of rounding noise. + if (normalized === 0) return { cos: 1, sin: 0 }; + if (normalized === 90) return { cos: 0, sin: 1 }; + if (normalized === 180) return { cos: -1, sin: 0 }; + if (normalized === 270) return { cos: 0, sin: -1 }; + const radians = (normalized * Math.PI) / 180; + return { cos: Math.cos(radians), sin: Math.sin(radians) }; +}; + +export function isValidSize3D(value: unknown): value is Size3D { + if (!value || typeof value !== 'object') return false; + const size = value as Record; + return ['widthMm', 'heightMm', 'depthMm'].every(field => ( + isFiniteNumber(size[field]) && (size[field] as number) >= 0 + )); +} + +export function orientedBox(position: Position3D, size: Size3D, rotationZ = 0): OrientedBox { + const { cos, sin } = rotationAxes(rotationZ); + const halfWidth = size.widthMm / 2; + const halfHeight = size.heightMm / 2; + const axisX = { x: cos, y: sin }; + const axisY = { x: -sin, y: cos }; + const corner = (u: number, v: number): PlanPoint => ({ + x: position.x + axisX.x * u + axisY.x * v, + y: position.y + axisX.y * u + axisY.y * v, + }); + return { + center: { x: position.x, y: position.y }, + halfWidth, + halfHeight, + rotationZ, + axisX, + axisY, + corners: [ + corner(-halfWidth, -halfHeight), + corner(halfWidth, -halfHeight), + corner(halfWidth, halfHeight), + corner(-halfWidth, halfHeight), + ], + minZ: position.z, + maxZ: position.z + size.depthMm, + }; +} + +/** + * Oriented box for an element's persisted presentation. `fallbackSize` is + * used when the element has no valid `size3D` of its own. Returns undefined + * when the pose or the extents cannot be resolved. + */ +export function elementBox(element: ModelElement, fallbackSize?: Size3D): OrientedBox | undefined { + const presentation = element.presentation; + const position = normalizePosition3D(presentation?.position3D); + if (!position) return undefined; + const size = isValidSize3D(presentation?.size3D) ? presentation!.size3D! : fallbackSize; + if (!size || !isValidSize3D(size)) return undefined; + const rotationZ = isFiniteNumber(presentation?.rotationZ) ? presentation!.rotationZ! : 0; + return orientedBox(position, size, rotationZ); +} + +/** Axis-aligned envelope of the rotated plan-view footprint. */ +export function planEnvelope(box: OrientedBox): PlanBounds { + const xs = box.corners.map(point => point.x); + const ys = box.corners.map(point => point.y); + return { + minX: Math.min(...xs), + minY: Math.min(...ys), + maxX: Math.max(...xs), + maxY: Math.max(...ys), + }; +} + +const dot = (a: PlanPoint, b: PlanPoint): number => a.x * b.x + a.y * b.y; + +const projectedRadius = (box: OrientedBox, axis: PlanPoint): number => ( + box.halfWidth * Math.abs(dot(box.axisX, axis)) + box.halfHeight * Math.abs(dot(box.axisY, axis)) +); + +/** + * Separating-axis test on the two plan-view footprints. Interiors must + * intersect: footprints that only touch along an edge or at a corner do not + * overlap. + */ +export function planOverlap(a: OrientedBox, b: OrientedBox): boolean { + const offset = { x: b.center.x - a.center.x, y: b.center.y - a.center.y }; + const axes = [a.axisX, a.axisY, b.axisX, b.axisY]; + for (let index = 0; index < axes.length; index += 1) { + const axis = axes[index]; + const distance = Math.abs(dot(offset, axis)); + if (distance >= projectedRadius(a, axis) + projectedRadius(b, axis) - GEOMETRY_EPSILON_MM) { + return false; + } + } + return true; +} + +/** True when the vertical intervals share more than a touching face. */ +export function verticalOverlap(a: OrientedBox, b: OrientedBox): boolean { + return a.minZ < b.maxZ - GEOMETRY_EPSILON_MM && b.minZ < a.maxZ - GEOMETRY_EPSILON_MM; +} + +/** Volume overlap: plan footprints and vertical intervals both overlap. */ +export function volumeOverlap(a: OrientedBox, b: OrientedBox): boolean { + return verticalOverlap(a, b) && planOverlap(a, b); +} + +const pointSegmentDistance = (point: PlanPoint, start: PlanPoint, end: PlanPoint): number => { + const segment = { x: end.x - start.x, y: end.y - start.y }; + const lengthSquared = dot(segment, segment); + const t = lengthSquared === 0 + ? 0 + : Math.max(0, Math.min(1, dot({ x: point.x - start.x, y: point.y - start.y }, segment) / lengthSquared)); + const dx = point.x - (start.x + segment.x * t); + const dy = point.y - (start.y + segment.y * t); + return Math.sqrt(dx * dx + dy * dy); +}; + +const cornersToEdgesDistance = (from: OrientedBox, to: OrientedBox): number => { + let minimum = Infinity; + from.corners.forEach(point => { + to.corners.forEach((start, index) => { + const end = to.corners[(index + 1) % to.corners.length]; + minimum = Math.min(minimum, pointSegmentDistance(point, start, end)); + }); + }); + return minimum; +}; + +/** + * Minimum plan-view distance between the two footprints; 0 when they + * overlap or touch. For disjoint convex polygons the minimum is reached + * between a corner of one and an edge of the other. + */ +export function planGap(a: OrientedBox, b: OrientedBox): number { + if (planOverlap(a, b)) return 0; + const gap = Math.min(cornersToEdgesDistance(a, b), cornersToEdgesDistance(b, a)); + return gap < GEOMETRY_EPSILON_MM ? 0 : gap; +} + +/** Distance between the vertical intervals; 0 when they overlap or touch. */ +export function verticalGap(a: OrientedBox, b: OrientedBox): number { + return Math.max(a.minZ - b.maxZ, b.minZ - a.maxZ, 0); +} + +/** + * Exact distance between the two prisms: the plan-view gap and the vertical + * gap are independent, so the 3D gap is sqrt(planGap^2 + verticalGap^2). + */ +export function volumeGap(a: OrientedBox, b: OrientedBox): number { + const plan = planGap(a, b); + const vertical = verticalGap(a, b); + return Math.sqrt(plan * plan + vertical * vertical); +} + +/** Centre-to-centre distance in plan view. */ +export function planCenterDistance(a: OrientedBox, b: OrientedBox): number { + const dx = a.center.x - b.center.x; + const dy = a.center.y - b.center.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** Distance between the volume centres (plan centre at mid elevation). */ +export function volumeCenterDistance(a: OrientedBox, b: OrientedBox): number { + const plan = planCenterDistance(a, b); + const dz = (a.minZ + a.maxZ) / 2 - (b.minZ + b.maxZ) / 2; + return Math.sqrt(plan * plan + dz * dz); +} + +/** + * True when every corner of `inner` lies inside `outer`'s footprint shrunk + * by `marginMm` on each side. Boundary contact counts as inside. A margin + * larger than half of the outer footprint leaves no room for anything. + */ +export function planContains(outer: OrientedBox, inner: OrientedBox, marginMm = 0): boolean { + const limitX = outer.halfWidth - marginMm; + const limitY = outer.halfHeight - marginMm; + if (limitX < -GEOMETRY_EPSILON_MM || limitY < -GEOMETRY_EPSILON_MM) return false; + return inner.corners.every(point => { + const offset = { x: point.x - outer.center.x, y: point.y - outer.center.y }; + return Math.abs(dot(offset, outer.axisX)) <= limitX + GEOMETRY_EPSILON_MM + && Math.abs(dot(offset, outer.axisY)) <= limitY + GEOMETRY_EPSILON_MM; + }); +} + +/** True when `inner`'s vertical interval lies inside `outer`'s. */ +export function verticalContains(outer: OrientedBox, inner: OrientedBox): boolean { + return inner.minZ >= outer.minZ - GEOMETRY_EPSILON_MM && inner.maxZ <= outer.maxZ + GEOMETRY_EPSILON_MM; +} diff --git a/frontend/src/services/spatial/spatial-rules.ts b/frontend/src/services/spatial/spatial-rules.ts new file mode 100644 index 0000000..3cebc35 --- /dev/null +++ b/frontend/src/services/spatial/spatial-rules.ts @@ -0,0 +1,311 @@ +import { ConcreteSyntax3D, Metamodel, Model, ModelElement, Size3D, SpatialRuleKind, ValidationIssue } from '../../models/types'; +import { isFiniteNumber } from './spatial-presentation'; +import { GEOMETRY_EPSILON_MM, OrientedBox, PlanBounds, elementBox, isValidSize3D, planCenterDistance, planContains, planEnvelope, planGap, planOverlap, verticalContains, verticalGap, verticalOverlap, volumeCenterDistance, volumeGap } from './spatial-geometry'; + +// Mirrored by shared/spatial-rules.ts (backend) and +// frontend/src/services/spatial/spatial-rules.ts. Keep both copies identical +// below the import block; a parity test enforces it. + +/** + * Declarative spatial rules. + * + * S is the set of placed instances of the rule's source class (subtypes + * included) and T the same for the target class. An element is placed when + * it has a valid `presentation.position3D` and resolvable extents: + * `presentation.size3D`, otherwise the metaclass default + * `concreteSyntax.three_d.defaultSizeMm` (inherited along `superTypes` the + * way the concrete-syntax resolver inherits notation). Unplaced elements are + * skipped and never satisfy an "exists" clause. + * + * | kind | holds when | issues | + * | ------------------ | ------------------------------------------------------------ | ---------------------------- | + * | noOverlap | no a in S, b in T, a != b intersect | one per offending pair | + * | minClearance(d) | gap(a, b) >= d for all a in S, b in T, a != b | one per offending pair | + * | maxDistance(d) | every a in S has some b in T with centreDistance(a, b) <= d | one per unsatisfied source | + * | within(margin) | every a in S lies inside some b in T shrunk by margin | one per unsatisfied source | + * | adjacentTo(tol) | every a in S has some b in T, b != a, with gap(a, b) <= tol | one per unsatisfied source | + * + * `mode: 'plan'` compares plan-view footprints and plan centres; `'volume'` + * (the default) uses the vertical prisms: overlap needs the vertical + * intervals to overlap too, gaps are sqrt(planGap^2 + verticalGap^2), + * centres sit at mid elevation, and `within` also needs the vertical + * interval of a inside that of b. Touching does not count as overlapping. + * Pair issues are reported once per unordered pair, on the source element, + * with the target in `relatedElementIds`. + */ + +export const SPATIAL_RULE_KINDS: SpatialRuleKind[] = ['noOverlap', 'minClearance', 'maxDistance', 'within', 'adjacentTo']; + +const SEVERITIES = ['error', 'warning', 'info']; + +/** Kinds that cannot be evaluated without `distanceMm`. */ +export const spatialRuleRequiresDistance = (kind: SpatialRuleKind): boolean => ( + kind === 'minClearance' || kind === 'maxDistance' || kind === 'adjacentTo' +); + +/** Kinds that read `distanceMm` at all (`within` treats it as an optional margin). */ +export const spatialRuleUsesDistance = (kind: SpatialRuleKind): boolean => kind !== 'noOverlap'; + +export const isSubtype = ( + metamodel: Pick, + typeId: string, + expectedTypeId: string, + visited: Record = {} +): boolean => { + if (typeId === expectedTypeId) return true; + if (visited[typeId]) return false; + visited[typeId] = true; + const metaClass = metamodel.classes.find(candidate => candidate.id === typeId); + return Boolean(metaClass?.superTypes?.some(superType => ( + isSubtype(metamodel, superType, expectedTypeId, visited) + ))); +}; + +/** + * Validate one spatial rule. With a metamodel, the source and target + * classes must exist in it. + */ +export function validateSpatialRule( + value: unknown, + metamodel?: Pick, + path = 'spatialRule' +): string[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${path} must be an object`]; + const rule = value as Record; + const errors: string[] = []; + if (typeof rule.id !== 'string' || !rule.id.trim()) errors.push(`${path}.id is required`); + if (typeof rule.name !== 'string' || !rule.name.trim()) errors.push(`${path}.name is required`); + const kind = rule.kind as SpatialRuleKind; + if (SPATIAL_RULE_KINDS.indexOf(kind) < 0) { + errors.push(`${path}.kind must be one of ${SPATIAL_RULE_KINDS.join(', ')}`); + } + ['sourceClassId', 'targetClassId'].forEach(field => { + const classId = rule[field]; + if (typeof classId !== 'string' || !classId) { + errors.push(`${path}.${field} is required`); + } else if (metamodel && !metamodel.classes.some(metaClass => metaClass.id === classId)) { + errors.push(`${path}.${field} references unknown metaclass ${classId}`); + } + }); + if (rule.distanceMm !== undefined && rule.distanceMm !== null) { + if (!isFiniteNumber(rule.distanceMm) || rule.distanceMm < 0) { + errors.push(`${path}.distanceMm must be a non-negative number`); + } + } else if (SPATIAL_RULE_KINDS.indexOf(kind) >= 0 && spatialRuleRequiresDistance(kind)) { + errors.push(`${path}.distanceMm is required for ${kind}`); + } + if (rule.mode !== undefined && rule.mode !== 'plan' && rule.mode !== 'volume') { + errors.push(`${path}.mode must be plan or volume`); + } + if (SEVERITIES.indexOf(rule.severity as string) < 0) { + errors.push(`${path}.severity must be error, warning, or info`); + } + if (rule.message !== undefined && rule.message !== null && typeof rule.message !== 'string') { + errors.push(`${path}.message must be a string`); + } + if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { + errors.push(`${path}.enabled must be a boolean`); + } + return errors; +} + +/** Validate a metamodel's `spatialRules` array, including unique rule IDs. */ +export function validateSpatialRules( + value: unknown, + metamodel?: Pick, + path = 'spatialRules' +): string[] { + if (!Array.isArray(value)) return [`${path} must be an array`]; + const errors: string[] = []; + const seen: Record = {}; + value.forEach((rule, index) => { + errors.push(...validateSpatialRule(rule, metamodel, `${path}[${index}]`)); + const id = rule && typeof rule === 'object' ? (rule as Record).id : undefined; + if (typeof id === 'string' && id) { + if (seen[id]) errors.push(`${path}[${index}].id duplicates rule ${id}`); + seen[id] = true; + } + }); + return errors; +} + +const nearestThreeDNotation = ( + metamodel: Pick, + classId: string, + visited: Record +): ConcreteSyntax3D | undefined => { + if (visited[classId]) return undefined; + visited[classId] = true; + const metaClass = metamodel.classes.find(candidate => candidate.id === classId); + if (!metaClass) return undefined; + const own = metaClass.concreteSyntax?.three_d; + if (own && Object.keys(own).length > 0) return own; + for (let index = 0; index < (metaClass.superTypes || []).length; index += 1) { + const inherited = nearestThreeDNotation(metamodel, metaClass.superTypes[index], visited); + if (inherited) return inherited; + } + return undefined; +}; + +/** + * Metaclass default extents, `concreteSyntax.three_d.defaultSizeMm`, taken + * from the nearest 3D notation along the class and its `superTypes` + * (declared order, first ancestor with notation wins), as the + * concrete-syntax resolver inherits notation. + */ +export function defaultSizeForClass(metamodel: Pick, classId: string): Size3D | undefined { + const notation = nearestThreeDNotation(metamodel, classId, {}); + return notation && isValidSize3D(notation.defaultSizeMm) ? notation.defaultSizeMm : undefined; +} + +interface PlacedElement { + index: number; + element: ModelElement; + box: OrientedBox; + envelope: PlanBounds; +} + +const envelopeGap = (a: PlanBounds, b: PlanBounds): number => { + const dx = Math.max(a.minX - b.maxX, b.minX - a.maxX, 0); + const dy = Math.max(a.minY - b.maxY, b.minY - a.maxY, 0); + return Math.sqrt(dx * dx + dy * dy); +}; + +const round = (value: number): number => Math.round(value * 10) / 10; + +const elementLabel = (element: ModelElement): string => ( + String(element.name || element.style?.name || element.id) +); + +const fillMessage = (template: string, values: Record): string => ( + template.replace(/\{(source|target|distance|rule)\}/g, (_match, key: string) => values[key]) +); + +/** + * Evaluate every enabled spatial rule of the metamodel against the model. + * Pure and deterministic: issues follow rule order, then model element order. + */ +export function evaluateSpatialRules(metamodel: Metamodel, model: Model): ValidationIssue[] { + const rules = (metamodel.spatialRules || []).filter(rule => rule && rule.enabled !== false); + if (rules.length === 0) return []; + + const defaultSizes: Record = {}; + const placed: PlacedElement[] = []; + model.elements.forEach(element => { + if (!(element.modelElementId in defaultSizes)) { + defaultSizes[element.modelElementId] = defaultSizeForClass(metamodel, element.modelElementId); + } + const box = elementBox(element, defaultSizes[element.modelElementId]); + if (box) placed.push({ index: placed.length, element, box, envelope: planEnvelope(box) }); + }); + + const issues: ValidationIssue[] = []; + rules.forEach(rule => { + const location = `spatialRules.${SPATIAL_RULE_KINDS.indexOf(rule.kind) >= 0 ? rule.kind : 'invalid'}`; + const errors = validateSpatialRule(rule, metamodel); + if (errors.length > 0) { + issues.push({ + severity: 'error', + message: `Spatial rule "${rule.name || rule.id}" is invalid: ${errors[0].replace(/^spatialRule\./, '')}`, + location, + constraintId: rule.id, + }); + return; + } + + const membership = (classId: string): boolean[] => { + const byClass: Record = {}; + return placed.map(item => { + const typeId = item.element.modelElementId; + if (!(typeId in byClass)) byClass[typeId] = isSubtype(metamodel, typeId, classId); + return byClass[typeId]; + }); + }; + const inSource = membership(rule.sourceClassId); + const inTarget = membership(rule.targetClassId); + const sources = placed.filter(item => inSource[item.index]); + const targets = placed.filter(item => inTarget[item.index]); + const volume = rule.mode !== 'plan'; + const distance = rule.distanceMm ?? 0; + const targetClassName = metamodel.classes.find(metaClass => metaClass.id === rule.targetClassId)?.name + || rule.targetClassId; + const report = (source: PlacedElement, defaultText: string, target?: PlacedElement): void => { + const text = rule.message + ? fillMessage(rule.message, { + source: elementLabel(source.element), + target: target ? elementLabel(target.element) : targetClassName, + distance: String(distance), + rule: rule.name, + }) + : defaultText; + issues.push({ + severity: rule.severity, + message: `${rule.name}: ${text}`, + elementId: source.element.id, + ...(target ? { relatedElementIds: [target.element.id] } : {}), + location, + constraintId: rule.id, + }); + }; + + if (rule.kind === 'noOverlap' || rule.kind === 'minClearance') { + sources.forEach(a => { + targets.forEach(b => { + if (a.index === b.index) return; + // Each unordered pair once: skip (a, b) when it was already visited as (b, a). + if (inSource[b.index] && inTarget[a.index] && b.index < a.index) return; + if (rule.kind === 'noOverlap') { + if (volume && !verticalOverlap(a.box, b.box)) return; + if (envelopeGap(a.envelope, b.envelope) > 0 || !planOverlap(a.box, b.box)) return; + report(a, `${elementLabel(a.element)} overlaps ${elementLabel(b.element)}${volume ? '' : ' in plan view'}.`, b); + return; + } + const vertical = volume ? verticalGap(a.box, b.box) : 0; + const lowerBound = envelopeGap(a.envelope, b.envelope); + if (Math.sqrt(lowerBound * lowerBound + vertical * vertical) >= distance) return; + const gap = volume ? volumeGap(a.box, b.box) : planGap(a.box, b.box); + if (gap >= distance - GEOMETRY_EPSILON_MM) return; + report( + a, + `${elementLabel(a.element)} is ${round(gap)} mm from ${elementLabel(b.element)}; at least ${distance} mm is required.`, + b + ); + }); + }); + return; + } + + sources.forEach(a => { + if (rule.kind === 'maxDistance') { + let nearest = Infinity; + for (let index = 0; index < targets.length; index += 1) { + const b = targets[index]; + const centreDistance = volume ? volumeCenterDistance(a.box, b.box) : planCenterDistance(a.box, b.box); + nearest = Math.min(nearest, centreDistance); + if (centreDistance <= distance + GEOMETRY_EPSILON_MM) return; + } + report(a, nearest === Infinity + ? `${elementLabel(a.element)} has no placed ${targetClassName} within ${distance} mm.` + : `${elementLabel(a.element)} has no ${targetClassName} within ${distance} mm; the nearest is ${round(nearest)} mm away.`); + return; + } + if (rule.kind === 'within') { + const inside = targets.some(b => ( + planContains(b.box, a.box, distance) && (!volume || verticalContains(b.box, a.box)) + )); + if (!inside) { + report(a, `${elementLabel(a.element)} is not inside any ${targetClassName}${distance > 0 ? ` with a ${distance} mm margin` : ''}.`); + } + return; + } + const adjacent = targets.some(b => ( + b.index !== a.index + && (volume ? volumeGap(a.box, b.box) : planGap(a.box, b.box)) <= distance + GEOMETRY_EPSILON_MM + )); + if (!adjacent) { + report(a, `${elementLabel(a.element)} is not within ${distance} mm of any ${targetClassName}.`); + } + }); + }); + return issues; +} diff --git a/shared/spatial-geometry.ts b/shared/spatial-geometry.ts new file mode 100644 index 0000000..b85a866 --- /dev/null +++ b/shared/spatial-geometry.ts @@ -0,0 +1,241 @@ +import { ModelElement, Position3D, Size3D } from './types'; +import { isFiniteNumber, normalizePosition3D } from './spatial'; + +// Mirrored by shared/spatial-geometry.ts (backend) and +// frontend/src/services/spatial/spatial-geometry.ts. Keep both copies +// identical below the import block; a parity test enforces it. + +/** + * Oriented geometry for placed model elements. + * + * Domain frame: right-handed, Z up, millimetres. `position3D.x/y` is the + * plan-view centre and `position3D.z` the base elevation. `size3D.widthMm` + * runs along the element's local X axis, `heightMm` along its local Y axis, + * and `depthMm` is the vertical extent. `rotationZ` is yaw in degrees about + * +Z; positive values turn counter-clockwise seen from above, the same sign + * the 3D editor and OpenUSD `xformOp:rotateZ` use. + * + * An element therefore occupies a vertical prism: an oriented rectangle in + * plan view extruded over `[z, z + depthMm]`. + */ + +export interface PlanPoint { + x: number; + y: number; +} + +export interface PlanBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface OrientedBox { + center: PlanPoint; + halfWidth: number; + halfHeight: number; + rotationZ: number; + /** Unit vector of the local X axis in the domain frame. */ + axisX: PlanPoint; + /** Unit vector of the local Y axis in the domain frame. */ + axisY: PlanPoint; + /** Plan-view corners, counter-clockwise from local (-w/2, -h/2). */ + corners: PlanPoint[]; + minZ: number; + maxZ: number; +} + +/** Tolerance in millimetres that absorbs floating-point noise from rotation. */ +export const GEOMETRY_EPSILON_MM = 1e-6; + +const rotationAxes = (rotationZ: number): { cos: number; sin: number } => { + const normalized = ((rotationZ % 360) + 360) % 360; + // Exact quarter turns keep axis-aligned layouts free of rounding noise. + if (normalized === 0) return { cos: 1, sin: 0 }; + if (normalized === 90) return { cos: 0, sin: 1 }; + if (normalized === 180) return { cos: -1, sin: 0 }; + if (normalized === 270) return { cos: 0, sin: -1 }; + const radians = (normalized * Math.PI) / 180; + return { cos: Math.cos(radians), sin: Math.sin(radians) }; +}; + +export function isValidSize3D(value: unknown): value is Size3D { + if (!value || typeof value !== 'object') return false; + const size = value as Record; + return ['widthMm', 'heightMm', 'depthMm'].every(field => ( + isFiniteNumber(size[field]) && (size[field] as number) >= 0 + )); +} + +export function orientedBox(position: Position3D, size: Size3D, rotationZ = 0): OrientedBox { + const { cos, sin } = rotationAxes(rotationZ); + const halfWidth = size.widthMm / 2; + const halfHeight = size.heightMm / 2; + const axisX = { x: cos, y: sin }; + const axisY = { x: -sin, y: cos }; + const corner = (u: number, v: number): PlanPoint => ({ + x: position.x + axisX.x * u + axisY.x * v, + y: position.y + axisX.y * u + axisY.y * v, + }); + return { + center: { x: position.x, y: position.y }, + halfWidth, + halfHeight, + rotationZ, + axisX, + axisY, + corners: [ + corner(-halfWidth, -halfHeight), + corner(halfWidth, -halfHeight), + corner(halfWidth, halfHeight), + corner(-halfWidth, halfHeight), + ], + minZ: position.z, + maxZ: position.z + size.depthMm, + }; +} + +/** + * Oriented box for an element's persisted presentation. `fallbackSize` is + * used when the element has no valid `size3D` of its own. Returns undefined + * when the pose or the extents cannot be resolved. + */ +export function elementBox(element: ModelElement, fallbackSize?: Size3D): OrientedBox | undefined { + const presentation = element.presentation; + const position = normalizePosition3D(presentation?.position3D); + if (!position) return undefined; + const size = isValidSize3D(presentation?.size3D) ? presentation!.size3D! : fallbackSize; + if (!size || !isValidSize3D(size)) return undefined; + const rotationZ = isFiniteNumber(presentation?.rotationZ) ? presentation!.rotationZ! : 0; + return orientedBox(position, size, rotationZ); +} + +/** Axis-aligned envelope of the rotated plan-view footprint. */ +export function planEnvelope(box: OrientedBox): PlanBounds { + const xs = box.corners.map(point => point.x); + const ys = box.corners.map(point => point.y); + return { + minX: Math.min(...xs), + minY: Math.min(...ys), + maxX: Math.max(...xs), + maxY: Math.max(...ys), + }; +} + +const dot = (a: PlanPoint, b: PlanPoint): number => a.x * b.x + a.y * b.y; + +const projectedRadius = (box: OrientedBox, axis: PlanPoint): number => ( + box.halfWidth * Math.abs(dot(box.axisX, axis)) + box.halfHeight * Math.abs(dot(box.axisY, axis)) +); + +/** + * Separating-axis test on the two plan-view footprints. Interiors must + * intersect: footprints that only touch along an edge or at a corner do not + * overlap. + */ +export function planOverlap(a: OrientedBox, b: OrientedBox): boolean { + const offset = { x: b.center.x - a.center.x, y: b.center.y - a.center.y }; + const axes = [a.axisX, a.axisY, b.axisX, b.axisY]; + for (let index = 0; index < axes.length; index += 1) { + const axis = axes[index]; + const distance = Math.abs(dot(offset, axis)); + if (distance >= projectedRadius(a, axis) + projectedRadius(b, axis) - GEOMETRY_EPSILON_MM) { + return false; + } + } + return true; +} + +/** True when the vertical intervals share more than a touching face. */ +export function verticalOverlap(a: OrientedBox, b: OrientedBox): boolean { + return a.minZ < b.maxZ - GEOMETRY_EPSILON_MM && b.minZ < a.maxZ - GEOMETRY_EPSILON_MM; +} + +/** Volume overlap: plan footprints and vertical intervals both overlap. */ +export function volumeOverlap(a: OrientedBox, b: OrientedBox): boolean { + return verticalOverlap(a, b) && planOverlap(a, b); +} + +const pointSegmentDistance = (point: PlanPoint, start: PlanPoint, end: PlanPoint): number => { + const segment = { x: end.x - start.x, y: end.y - start.y }; + const lengthSquared = dot(segment, segment); + const t = lengthSquared === 0 + ? 0 + : Math.max(0, Math.min(1, dot({ x: point.x - start.x, y: point.y - start.y }, segment) / lengthSquared)); + const dx = point.x - (start.x + segment.x * t); + const dy = point.y - (start.y + segment.y * t); + return Math.sqrt(dx * dx + dy * dy); +}; + +const cornersToEdgesDistance = (from: OrientedBox, to: OrientedBox): number => { + let minimum = Infinity; + from.corners.forEach(point => { + to.corners.forEach((start, index) => { + const end = to.corners[(index + 1) % to.corners.length]; + minimum = Math.min(minimum, pointSegmentDistance(point, start, end)); + }); + }); + return minimum; +}; + +/** + * Minimum plan-view distance between the two footprints; 0 when they + * overlap or touch. For disjoint convex polygons the minimum is reached + * between a corner of one and an edge of the other. + */ +export function planGap(a: OrientedBox, b: OrientedBox): number { + if (planOverlap(a, b)) return 0; + const gap = Math.min(cornersToEdgesDistance(a, b), cornersToEdgesDistance(b, a)); + return gap < GEOMETRY_EPSILON_MM ? 0 : gap; +} + +/** Distance between the vertical intervals; 0 when they overlap or touch. */ +export function verticalGap(a: OrientedBox, b: OrientedBox): number { + return Math.max(a.minZ - b.maxZ, b.minZ - a.maxZ, 0); +} + +/** + * Exact distance between the two prisms: the plan-view gap and the vertical + * gap are independent, so the 3D gap is sqrt(planGap^2 + verticalGap^2). + */ +export function volumeGap(a: OrientedBox, b: OrientedBox): number { + const plan = planGap(a, b); + const vertical = verticalGap(a, b); + return Math.sqrt(plan * plan + vertical * vertical); +} + +/** Centre-to-centre distance in plan view. */ +export function planCenterDistance(a: OrientedBox, b: OrientedBox): number { + const dx = a.center.x - b.center.x; + const dy = a.center.y - b.center.y; + return Math.sqrt(dx * dx + dy * dy); +} + +/** Distance between the volume centres (plan centre at mid elevation). */ +export function volumeCenterDistance(a: OrientedBox, b: OrientedBox): number { + const plan = planCenterDistance(a, b); + const dz = (a.minZ + a.maxZ) / 2 - (b.minZ + b.maxZ) / 2; + return Math.sqrt(plan * plan + dz * dz); +} + +/** + * True when every corner of `inner` lies inside `outer`'s footprint shrunk + * by `marginMm` on each side. Boundary contact counts as inside. A margin + * larger than half of the outer footprint leaves no room for anything. + */ +export function planContains(outer: OrientedBox, inner: OrientedBox, marginMm = 0): boolean { + const limitX = outer.halfWidth - marginMm; + const limitY = outer.halfHeight - marginMm; + if (limitX < -GEOMETRY_EPSILON_MM || limitY < -GEOMETRY_EPSILON_MM) return false; + return inner.corners.every(point => { + const offset = { x: point.x - outer.center.x, y: point.y - outer.center.y }; + return Math.abs(dot(offset, outer.axisX)) <= limitX + GEOMETRY_EPSILON_MM + && Math.abs(dot(offset, outer.axisY)) <= limitY + GEOMETRY_EPSILON_MM; + }); +} + +/** True when `inner`'s vertical interval lies inside `outer`'s. */ +export function verticalContains(outer: OrientedBox, inner: OrientedBox): boolean { + return inner.minZ >= outer.minZ - GEOMETRY_EPSILON_MM && inner.maxZ <= outer.maxZ + GEOMETRY_EPSILON_MM; +} diff --git a/shared/spatial-rules.ts b/shared/spatial-rules.ts new file mode 100644 index 0000000..726d9be --- /dev/null +++ b/shared/spatial-rules.ts @@ -0,0 +1,311 @@ +import { ConcreteSyntax3D, Metamodel, Model, ModelElement, Size3D, SpatialRuleKind, ValidationIssue } from './types'; +import { isFiniteNumber } from './spatial'; +import { GEOMETRY_EPSILON_MM, OrientedBox, PlanBounds, elementBox, isValidSize3D, planCenterDistance, planContains, planEnvelope, planGap, planOverlap, verticalContains, verticalGap, verticalOverlap, volumeCenterDistance, volumeGap } from './spatial-geometry'; + +// Mirrored by shared/spatial-rules.ts (backend) and +// frontend/src/services/spatial/spatial-rules.ts. Keep both copies identical +// below the import block; a parity test enforces it. + +/** + * Declarative spatial rules. + * + * S is the set of placed instances of the rule's source class (subtypes + * included) and T the same for the target class. An element is placed when + * it has a valid `presentation.position3D` and resolvable extents: + * `presentation.size3D`, otherwise the metaclass default + * `concreteSyntax.three_d.defaultSizeMm` (inherited along `superTypes` the + * way the concrete-syntax resolver inherits notation). Unplaced elements are + * skipped and never satisfy an "exists" clause. + * + * | kind | holds when | issues | + * | ------------------ | ------------------------------------------------------------ | ---------------------------- | + * | noOverlap | no a in S, b in T, a != b intersect | one per offending pair | + * | minClearance(d) | gap(a, b) >= d for all a in S, b in T, a != b | one per offending pair | + * | maxDistance(d) | every a in S has some b in T with centreDistance(a, b) <= d | one per unsatisfied source | + * | within(margin) | every a in S lies inside some b in T shrunk by margin | one per unsatisfied source | + * | adjacentTo(tol) | every a in S has some b in T, b != a, with gap(a, b) <= tol | one per unsatisfied source | + * + * `mode: 'plan'` compares plan-view footprints and plan centres; `'volume'` + * (the default) uses the vertical prisms: overlap needs the vertical + * intervals to overlap too, gaps are sqrt(planGap^2 + verticalGap^2), + * centres sit at mid elevation, and `within` also needs the vertical + * interval of a inside that of b. Touching does not count as overlapping. + * Pair issues are reported once per unordered pair, on the source element, + * with the target in `relatedElementIds`. + */ + +export const SPATIAL_RULE_KINDS: SpatialRuleKind[] = ['noOverlap', 'minClearance', 'maxDistance', 'within', 'adjacentTo']; + +const SEVERITIES = ['error', 'warning', 'info']; + +/** Kinds that cannot be evaluated without `distanceMm`. */ +export const spatialRuleRequiresDistance = (kind: SpatialRuleKind): boolean => ( + kind === 'minClearance' || kind === 'maxDistance' || kind === 'adjacentTo' +); + +/** Kinds that read `distanceMm` at all (`within` treats it as an optional margin). */ +export const spatialRuleUsesDistance = (kind: SpatialRuleKind): boolean => kind !== 'noOverlap'; + +export const isSubtype = ( + metamodel: Pick, + typeId: string, + expectedTypeId: string, + visited: Record = {} +): boolean => { + if (typeId === expectedTypeId) return true; + if (visited[typeId]) return false; + visited[typeId] = true; + const metaClass = metamodel.classes.find(candidate => candidate.id === typeId); + return Boolean(metaClass?.superTypes?.some(superType => ( + isSubtype(metamodel, superType, expectedTypeId, visited) + ))); +}; + +/** + * Validate one spatial rule. With a metamodel, the source and target + * classes must exist in it. + */ +export function validateSpatialRule( + value: unknown, + metamodel?: Pick, + path = 'spatialRule' +): string[] { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${path} must be an object`]; + const rule = value as Record; + const errors: string[] = []; + if (typeof rule.id !== 'string' || !rule.id.trim()) errors.push(`${path}.id is required`); + if (typeof rule.name !== 'string' || !rule.name.trim()) errors.push(`${path}.name is required`); + const kind = rule.kind as SpatialRuleKind; + if (SPATIAL_RULE_KINDS.indexOf(kind) < 0) { + errors.push(`${path}.kind must be one of ${SPATIAL_RULE_KINDS.join(', ')}`); + } + ['sourceClassId', 'targetClassId'].forEach(field => { + const classId = rule[field]; + if (typeof classId !== 'string' || !classId) { + errors.push(`${path}.${field} is required`); + } else if (metamodel && !metamodel.classes.some(metaClass => metaClass.id === classId)) { + errors.push(`${path}.${field} references unknown metaclass ${classId}`); + } + }); + if (rule.distanceMm !== undefined && rule.distanceMm !== null) { + if (!isFiniteNumber(rule.distanceMm) || rule.distanceMm < 0) { + errors.push(`${path}.distanceMm must be a non-negative number`); + } + } else if (SPATIAL_RULE_KINDS.indexOf(kind) >= 0 && spatialRuleRequiresDistance(kind)) { + errors.push(`${path}.distanceMm is required for ${kind}`); + } + if (rule.mode !== undefined && rule.mode !== 'plan' && rule.mode !== 'volume') { + errors.push(`${path}.mode must be plan or volume`); + } + if (SEVERITIES.indexOf(rule.severity as string) < 0) { + errors.push(`${path}.severity must be error, warning, or info`); + } + if (rule.message !== undefined && rule.message !== null && typeof rule.message !== 'string') { + errors.push(`${path}.message must be a string`); + } + if (rule.enabled !== undefined && typeof rule.enabled !== 'boolean') { + errors.push(`${path}.enabled must be a boolean`); + } + return errors; +} + +/** Validate a metamodel's `spatialRules` array, including unique rule IDs. */ +export function validateSpatialRules( + value: unknown, + metamodel?: Pick, + path = 'spatialRules' +): string[] { + if (!Array.isArray(value)) return [`${path} must be an array`]; + const errors: string[] = []; + const seen: Record = {}; + value.forEach((rule, index) => { + errors.push(...validateSpatialRule(rule, metamodel, `${path}[${index}]`)); + const id = rule && typeof rule === 'object' ? (rule as Record).id : undefined; + if (typeof id === 'string' && id) { + if (seen[id]) errors.push(`${path}[${index}].id duplicates rule ${id}`); + seen[id] = true; + } + }); + return errors; +} + +const nearestThreeDNotation = ( + metamodel: Pick, + classId: string, + visited: Record +): ConcreteSyntax3D | undefined => { + if (visited[classId]) return undefined; + visited[classId] = true; + const metaClass = metamodel.classes.find(candidate => candidate.id === classId); + if (!metaClass) return undefined; + const own = metaClass.concreteSyntax?.three_d; + if (own && Object.keys(own).length > 0) return own; + for (let index = 0; index < (metaClass.superTypes || []).length; index += 1) { + const inherited = nearestThreeDNotation(metamodel, metaClass.superTypes[index], visited); + if (inherited) return inherited; + } + return undefined; +}; + +/** + * Metaclass default extents, `concreteSyntax.three_d.defaultSizeMm`, taken + * from the nearest 3D notation along the class and its `superTypes` + * (declared order, first ancestor with notation wins), as the + * concrete-syntax resolver inherits notation. + */ +export function defaultSizeForClass(metamodel: Pick, classId: string): Size3D | undefined { + const notation = nearestThreeDNotation(metamodel, classId, {}); + return notation && isValidSize3D(notation.defaultSizeMm) ? notation.defaultSizeMm : undefined; +} + +interface PlacedElement { + index: number; + element: ModelElement; + box: OrientedBox; + envelope: PlanBounds; +} + +const envelopeGap = (a: PlanBounds, b: PlanBounds): number => { + const dx = Math.max(a.minX - b.maxX, b.minX - a.maxX, 0); + const dy = Math.max(a.minY - b.maxY, b.minY - a.maxY, 0); + return Math.sqrt(dx * dx + dy * dy); +}; + +const round = (value: number): number => Math.round(value * 10) / 10; + +const elementLabel = (element: ModelElement): string => ( + String(element.name || element.style?.name || element.id) +); + +const fillMessage = (template: string, values: Record): string => ( + template.replace(/\{(source|target|distance|rule)\}/g, (_match, key: string) => values[key]) +); + +/** + * Evaluate every enabled spatial rule of the metamodel against the model. + * Pure and deterministic: issues follow rule order, then model element order. + */ +export function evaluateSpatialRules(metamodel: Metamodel, model: Model): ValidationIssue[] { + const rules = (metamodel.spatialRules || []).filter(rule => rule && rule.enabled !== false); + if (rules.length === 0) return []; + + const defaultSizes: Record = {}; + const placed: PlacedElement[] = []; + model.elements.forEach(element => { + if (!(element.modelElementId in defaultSizes)) { + defaultSizes[element.modelElementId] = defaultSizeForClass(metamodel, element.modelElementId); + } + const box = elementBox(element, defaultSizes[element.modelElementId]); + if (box) placed.push({ index: placed.length, element, box, envelope: planEnvelope(box) }); + }); + + const issues: ValidationIssue[] = []; + rules.forEach(rule => { + const location = `spatialRules.${SPATIAL_RULE_KINDS.indexOf(rule.kind) >= 0 ? rule.kind : 'invalid'}`; + const errors = validateSpatialRule(rule, metamodel); + if (errors.length > 0) { + issues.push({ + severity: 'error', + message: `Spatial rule "${rule.name || rule.id}" is invalid: ${errors[0].replace(/^spatialRule\./, '')}`, + location, + constraintId: rule.id, + }); + return; + } + + const membership = (classId: string): boolean[] => { + const byClass: Record = {}; + return placed.map(item => { + const typeId = item.element.modelElementId; + if (!(typeId in byClass)) byClass[typeId] = isSubtype(metamodel, typeId, classId); + return byClass[typeId]; + }); + }; + const inSource = membership(rule.sourceClassId); + const inTarget = membership(rule.targetClassId); + const sources = placed.filter(item => inSource[item.index]); + const targets = placed.filter(item => inTarget[item.index]); + const volume = rule.mode !== 'plan'; + const distance = rule.distanceMm ?? 0; + const targetClassName = metamodel.classes.find(metaClass => metaClass.id === rule.targetClassId)?.name + || rule.targetClassId; + const report = (source: PlacedElement, defaultText: string, target?: PlacedElement): void => { + const text = rule.message + ? fillMessage(rule.message, { + source: elementLabel(source.element), + target: target ? elementLabel(target.element) : targetClassName, + distance: String(distance), + rule: rule.name, + }) + : defaultText; + issues.push({ + severity: rule.severity, + message: `${rule.name}: ${text}`, + elementId: source.element.id, + ...(target ? { relatedElementIds: [target.element.id] } : {}), + location, + constraintId: rule.id, + }); + }; + + if (rule.kind === 'noOverlap' || rule.kind === 'minClearance') { + sources.forEach(a => { + targets.forEach(b => { + if (a.index === b.index) return; + // Each unordered pair once: skip (a, b) when it was already visited as (b, a). + if (inSource[b.index] && inTarget[a.index] && b.index < a.index) return; + if (rule.kind === 'noOverlap') { + if (volume && !verticalOverlap(a.box, b.box)) return; + if (envelopeGap(a.envelope, b.envelope) > 0 || !planOverlap(a.box, b.box)) return; + report(a, `${elementLabel(a.element)} overlaps ${elementLabel(b.element)}${volume ? '' : ' in plan view'}.`, b); + return; + } + const vertical = volume ? verticalGap(a.box, b.box) : 0; + const lowerBound = envelopeGap(a.envelope, b.envelope); + if (Math.sqrt(lowerBound * lowerBound + vertical * vertical) >= distance) return; + const gap = volume ? volumeGap(a.box, b.box) : planGap(a.box, b.box); + if (gap >= distance - GEOMETRY_EPSILON_MM) return; + report( + a, + `${elementLabel(a.element)} is ${round(gap)} mm from ${elementLabel(b.element)}; at least ${distance} mm is required.`, + b + ); + }); + }); + return; + } + + sources.forEach(a => { + if (rule.kind === 'maxDistance') { + let nearest = Infinity; + for (let index = 0; index < targets.length; index += 1) { + const b = targets[index]; + const centreDistance = volume ? volumeCenterDistance(a.box, b.box) : planCenterDistance(a.box, b.box); + nearest = Math.min(nearest, centreDistance); + if (centreDistance <= distance + GEOMETRY_EPSILON_MM) return; + } + report(a, nearest === Infinity + ? `${elementLabel(a.element)} has no placed ${targetClassName} within ${distance} mm.` + : `${elementLabel(a.element)} has no ${targetClassName} within ${distance} mm; the nearest is ${round(nearest)} mm away.`); + return; + } + if (rule.kind === 'within') { + const inside = targets.some(b => ( + planContains(b.box, a.box, distance) && (!volume || verticalContains(b.box, a.box)) + )); + if (!inside) { + report(a, `${elementLabel(a.element)} is not inside any ${targetClassName}${distance > 0 ? ` with a ${distance} mm margin` : ''}.`); + } + return; + } + const adjacent = targets.some(b => ( + b.index !== a.index + && (volume ? volumeGap(a.box, b.box) : planGap(a.box, b.box)) <= distance + GEOMETRY_EPSILON_MM + )); + if (!adjacent) { + report(a, `${elementLabel(a.element)} is not within ${distance} mm of any ${targetClassName}.`); + } + }); + }); + return issues; +} diff --git a/shared/types/index.ts b/shared/types/index.ts index 55c3df1..1abd1fe 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -358,6 +358,31 @@ export interface JSConstraint { export type Constraint = OCLConstraint | JSConstraint; +// ============ Spatial Rule Types ============ + +export type SpatialRuleKind = 'noOverlap' | 'minClearance' | 'maxDistance' | 'within' | 'adjacentTo'; + +/** + * Declarative spatial rule, part of the language definition. Rules quantify + * over placed instances of a source and a target metaclass (subtypes + * included); see shared/spatial-rules.ts for the exact semantics. + */ +export interface SpatialRule { + id: string; + name: string; + kind: SpatialRuleKind; + sourceClassId: string; + targetClassId: string; + /** Required for minClearance, maxDistance, adjacentTo; the margin for within (default 0). */ + distanceMm?: number; + /** Plan-view footprint or full volume; default 'volume'. */ + mode?: 'plan' | 'volume'; + severity: 'error' | 'warning' | 'info'; + message?: string; + /** Default true. */ + enabled?: boolean; +} + // ============ Metamodel Types ============ export interface MetamodelElement { @@ -422,6 +447,7 @@ export interface Metamodel extends MetamodelElement { enums?: MetaEnum[]; conformsTo: string; constraints?: Constraint[]; + spatialRules?: SpatialRule[]; } export interface ConcreteSyntax2D { @@ -906,6 +932,8 @@ export interface ValidationIssue { severity: 'error' | 'warning' | 'info'; message: string; elementId?: string; + /** Further elements the issue concerns, for example the other element of an overlapping pair. */ + relatedElementIds?: string[]; location?: string; constraintId?: string; } @@ -997,6 +1025,7 @@ export interface CreateMetamodelRequest { classes?: MetaClass[]; enums?: MetaEnum[]; constraints?: Constraint[]; + spatialRules?: SpatialRule[]; } export interface UpdateMetamodelRequest { @@ -1007,6 +1036,7 @@ export interface UpdateMetamodelRequest { classes?: MetaClass[]; enums?: MetaEnum[]; constraints?: Constraint[]; + spatialRules?: SpatialRule[]; } export interface CreateModelRequest { From b64c50aaab6cf77c132cda700e82204683161cdb Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Sun, 11 Oct 2026 21:55:42 -0400 Subject: [PATCH 2/7] Author UsdGeom sizes as double in generated layers and example assets The UsdGeom schema declares Cube.size, Sphere.radius, and Cylinder.radius and height as double. The generated layout and physics layers and the project-authored inspection drone and F1TENTH layers authored them as float, which UsdValidation reports as AttributeTypeMismatch on the composed warehouse stage. Values are unchanged. --- .../smart-warehouse-omniverse-project.json | 4 +- .../layers/articulation.usda | 8 +-- .../nvidia-f1tenth-amr/layers/collision.usda | 12 ++--- .../nvidia-f1tenth-amr/layers/visual.usda | 52 +++++++++---------- .../warehouse-kit/inspection_drone.usda | 26 +++++----- .../smart-warehouse-omniverse-project.json | 4 +- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/examples/codegen-projects/smart-warehouse-omniverse-project.json b/examples/codegen-projects/smart-warehouse-omniverse-project.json index 23964f6..4660d1e 100644 --- a/examples/codegen-projects/smart-warehouse-omniverse-project.json +++ b/examples/codegen-projects/smart-warehouse-omniverse-project.json @@ -22,7 +22,7 @@ "id": "a2c1e7d0-0000-4000-8000-0000000000f2", "name": "Generate Layered USD Layout", "language": "plaintext", - "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"SpatialDSL model layout only; asset and physics opinions live in sibling layers.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"layout\"\n\n def Cube \"Floor\"\n {\n float size = 1\n color3f[] primvars:displayColor = [(0.18, 0.20, 0.23)]\n double3 xformOp:scale = (80, 60, 0.1)\n double3 xformOp:translate = (5, 0, -0.05)\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n }\n\n def Xform \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"MobileRobot\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n\n def Xform \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Conveyor\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"OutputLocation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"ChargingStation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"PathwayArea\"\n {\n{{#each elementsByClassName.PathwayArea}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"PathwayArea\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"StorageRack\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Dock\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"InspectionDrone\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n}\n", + "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"SpatialDSL model layout only; asset and physics opinions live in sibling layers.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"layout\"\n\n def Cube \"Floor\"\n {\n double size = 1\n color3f[] primvars:displayColor = [(0.18, 0.20, 0.23)]\n double3 xformOp:scale = (80, 60, 0.1)\n double3 xformOp:translate = (5, 0, -0.05)\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n }\n\n def Xform \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"MobileRobot\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n\n def Xform \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Conveyor\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"OutputLocation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"ChargingStation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"PathwayArea\"\n {\n{{#each elementsByClassName.PathwayArea}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"PathwayArea\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"StorageRack\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Dock\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"InspectionDrone\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n}\n", "outputPattern": "warehouse_layout.usda" }, { @@ -36,7 +36,7 @@ "id": "a2c1e7d0-0000-4000-8000-0000000000f4", "name": "Generate Layered USD Physics", "language": "plaintext", - "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"PhysX scene, static proxy colliders, and kinematic robot/drone body defaults.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"physics\"\n\n def PhysicsScene \"PhysicsScene\"\n {\n vector3f physics:gravityDirection = (0, 0, -1)\n float physics:gravityMagnitude = 9.81\n }\n\n def Scope \"PhysicsMaterials\"\n {\n def Material \"WarehouseDefault\" (\n prepend apiSchemas = [\"PhysicsMaterialAPI\"]\n )\n {\n float physics:staticFriction = 0.72\n float physics:dynamicFriction = 0.58\n float physics:restitution = 0.05\n }\n }\n\n over \"Floor\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n rel physics:material:binding = \n }\n\n over \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 3.1\n custom string spatialDsl:bodyMode = \"kinematic\"\n custom string spatialDsl:dynamicControl = \"velocity\"\n }\n{{/each}}\n }\n\n over \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 1.5\n custom string spatialDsl:bodyMode = \"kinematic-aerial-placement\"\n\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n double3 xformOp:translate = (0, 0, {{halfMeters Height}})\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n}\n", + "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"PhysX scene, static proxy colliders, and kinematic robot/drone body defaults.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"physics\"\n\n def PhysicsScene \"PhysicsScene\"\n {\n vector3f physics:gravityDirection = (0, 0, -1)\n float physics:gravityMagnitude = 9.81\n }\n\n def Scope \"PhysicsMaterials\"\n {\n def Material \"WarehouseDefault\" (\n prepend apiSchemas = [\"PhysicsMaterialAPI\"]\n )\n {\n float physics:staticFriction = 0.72\n float physics:dynamicFriction = 0.58\n float physics:restitution = 0.05\n }\n }\n\n over \"Floor\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n rel physics:material:binding = \n }\n\n over \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 3.1\n custom string spatialDsl:bodyMode = \"kinematic\"\n custom string spatialDsl:dynamicControl = \"velocity\"\n }\n{{/each}}\n }\n\n over \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 1.5\n custom string spatialDsl:bodyMode = \"kinematic-aerial-placement\"\n\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n double3 xformOp:translate = (0, 0, {{halfMeters Height}})\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n}\n", "outputPattern": "warehouse_physics.usda" }, { diff --git a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/articulation.usda b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/articulation.usda index 00e06ca..c59b543 100644 --- a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/articulation.usda +++ b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/articulation.usda @@ -26,8 +26,8 @@ over "F1TenthAMR" ( uniform token axis = "Z" uniform token purpose = "guide" token visibility = "invisible" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 float physics:mass = 0.12 float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (0, 0.162, 0.058) @@ -41,8 +41,8 @@ over "F1TenthAMR" ( uniform token axis = "Z" uniform token purpose = "guide" token visibility = "invisible" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 float physics:mass = 0.12 float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (0, -0.162, 0.058) diff --git a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/collision.usda b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/collision.usda index aa31b9e..ffeefcd 100644 --- a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/collision.usda +++ b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/collision.usda @@ -15,7 +15,7 @@ over "F1TenthAMR" { uniform token purpose = "guide" token visibility = "invisible" - float size = 1 + double size = 1 custom string spatialDsl:colliderRole = "chassis" double3 xformOp:scale = (0.49, 0.25, 0.15) double3 xformOp:translate = (0, 0, 0.12) @@ -28,7 +28,7 @@ over "F1TenthAMR" { uniform token purpose = "guide" token visibility = "invisible" - float size = 1 + double size = 1 custom string spatialDsl:colliderRole = "sensor-envelope" double3 xformOp:scale = (0.30, 0.18, 0.14) double3 xformOp:translate = (0.015, 0, 0.235) @@ -42,8 +42,8 @@ over "F1TenthAMR" uniform token axis = "Z" uniform token purpose = "guide" token visibility = "invisible" - float height = 0.32 - float radius = 0.057 + double height = 0.32 + double radius = 0.057 custom string spatialDsl:colliderRole = "front-wheel-envelope" float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (0.174, 0, 0.058) @@ -57,8 +57,8 @@ over "F1TenthAMR" uniform token axis = "Z" uniform token purpose = "guide" token visibility = "invisible" - float height = 0.32 - float radius = 0.057 + double height = 0.32 + double radius = 0.057 custom string spatialDsl:colliderRole = "rear-wheel-envelope" float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (-0.174, 0, 0.058) diff --git a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/visual.usda b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/visual.usda index 37763f1..f9111fe 100644 --- a/examples/omniverse-assets/nvidia-f1tenth-amr/layers/visual.usda +++ b/examples/omniverse-assets/nvidia-f1tenth-amr/layers/visual.usda @@ -22,7 +22,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.46, 0.245, 0.012) double3 xformOp:translate = (0, 0, 0.09) @@ -33,7 +33,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.37, 0.225, 0.012) double3 xformOp:translate = (-0.015, 0, 0.178) @@ -44,7 +44,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.045, 0.315, 0.052) double3 xformOp:translate = (0.255, 0, 0.082) @@ -55,7 +55,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.04, 0.29, 0.045) double3 xformOp:translate = (-0.255, 0, 0.082) @@ -66,7 +66,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.19, 0.118, 0.07) double3 xformOp:translate = (-0.06, 0, 0.132) @@ -77,7 +77,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.14, 0.13, 0.055) double3 xformOp:translate = (0.075, 0, 0.218) @@ -88,7 +88,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.33, 0.012, 0.025) double3 xformOp:translate = (-0.01, 0.145, 0.135) @@ -99,7 +99,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.33, 0.012, 0.025) double3 xformOp:translate = (-0.01, -0.145, 0.135) @@ -113,8 +113,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 rel material:binding = float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (0.174, 0.162, 0.058) @@ -125,8 +125,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 rel material:binding = float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (0.174, -0.162, 0.058) @@ -137,8 +137,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 rel material:binding = float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (-0.174, 0.162, 0.058) @@ -149,8 +149,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.052 - float radius = 0.057 + double height = 0.052 + double radius = 0.057 rel material:binding = float3 xformOp:rotateXYZ = (90, 0, 0) double3 xformOp:translate = (-0.174, -0.162, 0.058) @@ -164,7 +164,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.035, 0.255, 0.018) double3 xformOp:translate = (0.174, 0, 0.075) @@ -174,7 +174,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.035, 0.255, 0.018) double3 xformOp:translate = (-0.174, 0, 0.075) @@ -189,8 +189,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.075 - float radius = 0.012 + double height = 0.075 + double radius = 0.012 rel material:binding = double3 xformOp:translate = (-0.085, 0, 0.247) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -200,8 +200,8 @@ def Xform "F1TenthAMR" ( ) { uniform token axis = "Z" - float height = 0.038 - float radius = 0.047 + double height = 0.038 + double radius = 0.047 rel material:binding = double3 xformOp:translate = (-0.085, 0, 0.3) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -210,7 +210,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float size = 1 + double size = 1 rel material:binding = double3 xformOp:scale = (0.035, 0.105, 0.038) double3 xformOp:translate = (0.172, 0, 0.242) @@ -220,7 +220,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float radius = 0.012 + double radius = 0.012 rel material:binding = double3 xformOp:translate = (0.192, 0.033, 0.244) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -229,7 +229,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float radius = 0.012 + double radius = 0.012 rel material:binding = double3 xformOp:translate = (0.192, -0.033, 0.244) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -238,7 +238,7 @@ def Xform "F1TenthAMR" ( prepend apiSchemas = ["MaterialBindingAPI"] ) { - float radius = 0.014 + double radius = 0.014 rel material:binding = double3 xformOp:translate = (-0.165, 0, 0.225) uniform token[] xformOpOrder = ["xformOp:translate"] diff --git a/examples/omniverse-assets/warehouse-kit/inspection_drone.usda b/examples/omniverse-assets/warehouse-kit/inspection_drone.usda index ffd6b9c..e2b5903 100644 --- a/examples/omniverse-assets/warehouse-kit/inspection_drone.usda +++ b/examples/omniverse-assets/warehouse-kit/inspection_drone.usda @@ -14,7 +14,7 @@ def Xform "InspectionDrone" def Cube "Body" { - float size = 1 + double size = 1 color3f[] primvars:displayColor = [(0.34, 0.16, 0.62)] double3 xformOp:scale = (0.55, 0.34, 0.20) double3 xformOp:translate = (0, 0, 0.20) @@ -23,7 +23,7 @@ def Xform "InspectionDrone" def Cube "ArmX" { - float size = 1 + double size = 1 color3f[] primvars:displayColor = [(0.55, 0.38, 0.82)] double3 xformOp:scale = (1.0, 0.08, 0.08) double3 xformOp:translate = (0, 0, 0.23) @@ -32,7 +32,7 @@ def Xform "InspectionDrone" def Cube "ArmY" { - float size = 1 + double size = 1 color3f[] primvars:displayColor = [(0.55, 0.38, 0.82)] double3 xformOp:scale = (0.08, 1.0, 0.08) double3 xformOp:translate = (0, 0, 0.23) @@ -41,7 +41,7 @@ def Xform "InspectionDrone" def Cube "LandingSkidLeft" { - float size = 1 + double size = 1 color3f[] primvars:displayColor = [(0.16, 0.18, 0.24)] double3 xformOp:scale = (0.55, 0.05, 0.08) double3 xformOp:translate = (0, -0.27, 0.04) @@ -50,7 +50,7 @@ def Xform "InspectionDrone" def Cube "LandingSkidRight" { - float size = 1 + double size = 1 color3f[] primvars:displayColor = [(0.16, 0.18, 0.24)] double3 xformOp:scale = (0.55, 0.05, 0.08) double3 xformOp:translate = (0, 0.27, 0.04) @@ -60,8 +60,8 @@ def Xform "InspectionDrone" def Cylinder "RotorFront" { uniform token axis = "Z" - float height = 0.025 - float radius = 0.20 + double height = 0.025 + double radius = 0.20 color3f[] primvars:displayColor = [(0.10, 0.12, 0.18)] double3 xformOp:translate = (0.50, 0, 0.31) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -70,8 +70,8 @@ def Xform "InspectionDrone" def Cylinder "RotorBack" { uniform token axis = "Z" - float height = 0.025 - float radius = 0.20 + double height = 0.025 + double radius = 0.20 color3f[] primvars:displayColor = [(0.10, 0.12, 0.18)] double3 xformOp:translate = (-0.50, 0, 0.31) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -80,8 +80,8 @@ def Xform "InspectionDrone" def Cylinder "RotorLeft" { uniform token axis = "Z" - float height = 0.025 - float radius = 0.20 + double height = 0.025 + double radius = 0.20 color3f[] primvars:displayColor = [(0.10, 0.12, 0.18)] double3 xformOp:translate = (0, -0.50, 0.31) uniform token[] xformOpOrder = ["xformOp:translate"] @@ -90,8 +90,8 @@ def Xform "InspectionDrone" def Cylinder "RotorRight" { uniform token axis = "Z" - float height = 0.025 - float radius = 0.20 + double height = 0.025 + double radius = 0.20 color3f[] primvars:displayColor = [(0.10, 0.12, 0.18)] double3 xformOp:translate = (0, 0.50, 0.31) uniform token[] xformOpOrder = ["xformOp:translate"] diff --git a/frontend/src/examples/data/smart-warehouse-omniverse-project.json b/frontend/src/examples/data/smart-warehouse-omniverse-project.json index 23964f6..4660d1e 100644 --- a/frontend/src/examples/data/smart-warehouse-omniverse-project.json +++ b/frontend/src/examples/data/smart-warehouse-omniverse-project.json @@ -22,7 +22,7 @@ "id": "a2c1e7d0-0000-4000-8000-0000000000f2", "name": "Generate Layered USD Layout", "language": "plaintext", - "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"SpatialDSL model layout only; asset and physics opinions live in sibling layers.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"layout\"\n\n def Cube \"Floor\"\n {\n float size = 1\n color3f[] primvars:displayColor = [(0.18, 0.20, 0.23)]\n double3 xformOp:scale = (80, 60, 0.1)\n double3 xformOp:translate = (5, 0, -0.05)\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n }\n\n def Xform \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"MobileRobot\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n\n def Xform \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Conveyor\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"OutputLocation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"ChargingStation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"PathwayArea\"\n {\n{{#each elementsByClassName.PathwayArea}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"PathwayArea\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"StorageRack\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Dock\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"InspectionDrone\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n}\n", + "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"SpatialDSL model layout only; asset and physics opinions live in sibling layers.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"layout\"\n\n def Cube \"Floor\"\n {\n double size = 1\n color3f[] primvars:displayColor = [(0.18, 0.20, 0.23)]\n double3 xformOp:scale = (80, 60, 0.1)\n double3 xformOp:translate = (5, 0, -0.05)\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n }\n\n def Xform \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"MobileRobot\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n\n def Xform \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Conveyor\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"OutputLocation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"ChargingStation\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"PathwayArea\"\n {\n{{#each elementsByClassName.PathwayArea}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"PathwayArea\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"StorageRack\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"Dock\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{baseCenterMeters Z Height}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\", \"xformOp:scale\"]\n }\n{{/each}}\n }\n\n def Xform \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n def Xform \"{{usdIdentifier name}}_{{@index}}\"\n {\n custom string spatialDsl:className = \"InspectionDrone\"\n custom string spatialDsl:sourceName = \"{{name}}\"\n custom double spatialDsl:baseElevationMm = {{Z}}\n double3 xformOp:translate = ({{meters X}}, {{meters Y}}, {{meters Z}})\n float xformOp:rotateZ = {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:rotateZ\"]\n }\n{{/each}}\n }\n}\n", "outputPattern": "warehouse_layout.usda" }, { @@ -36,7 +36,7 @@ "id": "a2c1e7d0-0000-4000-8000-0000000000f4", "name": "Generate Layered USD Physics", "language": "plaintext", - "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"PhysX scene, static proxy colliders, and kinematic robot/drone body defaults.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"physics\"\n\n def PhysicsScene \"PhysicsScene\"\n {\n vector3f physics:gravityDirection = (0, 0, -1)\n float physics:gravityMagnitude = 9.81\n }\n\n def Scope \"PhysicsMaterials\"\n {\n def Material \"WarehouseDefault\" (\n prepend apiSchemas = [\"PhysicsMaterialAPI\"]\n )\n {\n float physics:staticFriction = 0.72\n float physics:dynamicFriction = 0.58\n float physics:restitution = 0.05\n }\n }\n\n over \"Floor\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n rel physics:material:binding = \n }\n\n over \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 3.1\n custom string spatialDsl:bodyMode = \"kinematic\"\n custom string spatialDsl:dynamicControl = \"velocity\"\n }\n{{/each}}\n }\n\n over \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 1.5\n custom string spatialDsl:bodyMode = \"kinematic-aerial-placement\"\n\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n float size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n double3 xformOp:translate = (0, 0, {{halfMeters Height}})\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n}\n", + "templateContent": "#usda 1.0\n(\n defaultPrim = \"World\"\n documentation = \"PhysX scene, static proxy colliders, and kinematic robot/drone body defaults.\"\n metersPerUnit = 1\n timeCodesPerSecond = 60\n upAxis = \"Z\"\n)\n\ndef Xform \"World\"\n{\n custom string spatialDsl:layerRole = \"physics\"\n\n def PhysicsScene \"PhysicsScene\"\n {\n vector3f physics:gravityDirection = (0, 0, -1)\n float physics:gravityMagnitude = 9.81\n }\n\n def Scope \"PhysicsMaterials\"\n {\n def Material \"WarehouseDefault\" (\n prepend apiSchemas = [\"PhysicsMaterialAPI\"]\n )\n {\n float physics:staticFriction = 0.72\n float physics:dynamicFriction = 0.58\n float physics:restitution = 0.05\n }\n }\n\n over \"Floor\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n rel physics:material:binding = \n }\n\n over \"MobileRobot\"\n {\n{{#each elementsByClassName.MobileRobot}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 3.1\n custom string spatialDsl:bodyMode = \"kinematic\"\n custom string spatialDsl:dynamicControl = \"velocity\"\n }\n{{/each}}\n }\n\n over \"Conveyor\"\n {\n{{#each elementsByClassName.Conveyor}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"OutputLocation\"\n {\n{{#each elementsByClassName.OutputLocation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"ChargingStation\"\n {\n{{#each elementsByClassName.ChargingStation}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"StorageRack\"\n {\n{{#each elementsByClassName.StorageRack}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"Dock\"\n {\n{{#each elementsByClassName.Dock}}\n over \"{{usdIdentifier name}}_{{@index}}\"\n {\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n\n over \"InspectionDrone\"\n {\n{{#each elementsByClassName.InspectionDrone}}\n over \"{{usdIdentifier name}}_{{@index}}\" (\n prepend apiSchemas = [\"PhysicsRigidBodyAPI\", \"PhysicsMassAPI\"]\n )\n {\n bool physics:kinematicEnabled = true\n bool physics:rigidBodyEnabled = true\n float physics:mass = 1.5\n custom string spatialDsl:bodyMode = \"kinematic-aerial-placement\"\n\n def Cube \"PhysicsProxy\" (\n prepend apiSchemas = [\"PhysicsCollisionAPI\"]\n )\n {\n double size = 1\n uniform token purpose = \"guide\"\n token visibility = \"invisible\"\n double3 xformOp:translate = (0, 0, {{halfMeters Height}})\n double3 xformOp:scale = ({{meters Length}}, {{meters Width}}, {{meters Height}})\n uniform token[] xformOpOrder = [\"xformOp:translate\", \"xformOp:scale\"]\n rel physics:material:binding = \n }\n }\n{{/each}}\n }\n}\n", "outputPattern": "warehouse_physics.usda" }, { From 9e2b9a538fb3eb12fcc98e7ae342a7f2680dc76d Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Sun, 11 Oct 2026 21:55:42 -0400 Subject: [PATCH 3/7] Add unattended scalability and generation fidelity benchmarks benchmarks/scalability.ts generates seeded synthetic Smart Warehouse models (100 to 10,000 placed elements, random yaw, about 5 percent seeded rule violations) and reports median timings of spatial rule evaluation, backend model validation, and OpenUSD code generation, plus the model size and machine details. It fails unless every seeded violation yields exactly one rule issue. benchmarks/fidelity.py applies 200 seeded pose edits to the bundled model, regenerates through the backend generation engine after each edit, and compares every element's pose in the composed USD stage (read with pxr), the Visual Components location files, and the Omniverse layout JSON with the model. It also validates the composed stages with UsdValidation and checks that generation is byte-for-byte deterministic. Both scripts run in-process without a browser, server, or database, and write CSV, JSON, and Markdown to the git-ignored benchmarks/results/. --- .gitignore | 4 + benchmarks/README.md | 52 +++++ benchmarks/fidelity-generate.ts | 140 +++++++++++++ benchmarks/fidelity.py | 273 ++++++++++++++++++++++++++ benchmarks/lib/offline-env.ts | 7 + benchmarks/lib/synthetic-warehouse.ts | 187 ++++++++++++++++++ benchmarks/package.json | 10 + benchmarks/scalability.ts | 161 +++++++++++++++ 8 files changed, 834 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/fidelity-generate.ts create mode 100644 benchmarks/fidelity.py create mode 100644 benchmarks/lib/offline-env.ts create mode 100644 benchmarks/lib/synthetic-warehouse.ts create mode 100644 benchmarks/package.json create mode 100644 benchmarks/scalability.ts diff --git a/.gitignore b/.gitignore index 561b1b5..a061144 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ spatialdsl_deploy.key # Claude Code .claude/ CLAUDE.md + +# Benchmark outputs and the local Python environment +benchmarks/results/ +benchmarks/.venv/ diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..f6edef6 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,52 @@ +# Evaluation scripts + +Two unattended scripts measure SpatialDSL Studio without a browser, an HTTP +server, or a database. Both call the backend engines in-process and write their +results to `benchmarks/results/` (git-ignored). + +## Prerequisites + +```bash +cd backend && npm ci # the scripts reuse the backend's ts-node and engines +cd ../benchmarks +python3 -m venv .venv && .venv/bin/pip install usd-core # fidelity only; provides pxr +``` + +## Scalability + +```bash +cd benchmarks && npm run scalability +``` + +Generates seeded synthetic Smart Warehouse models with 100, 500, 1,000, 2,500, +5,000, and 10,000 placed elements on a grid with random yaw, about 5 percent of +them placed to violate one of the example spatial rules. For each size it +reports the median of 5 runs (after one warm-up run) of spatial rule +evaluation, the full backend model validation engine, and code generation of +the Smart Warehouse OpenUSD project, plus the serialized model size. The script +fails if the number of rule issues differs from the number of seeded +violations. + +Outputs: `results/scalability.csv`, `results/scalability.md`, +`results/scalability.json` (including CPU, RAM, OS, and Node.js version). +Expected runtime: about 20 seconds on an Apple M2. + +## Generation fidelity + +```bash +cd benchmarks && npm run fidelity # same as: .venv/bin/python fidelity.py [K] [seed] +``` + +Applies K = 200 seeded pose edits (move, rotate, elevate) to the bundled Smart +Warehouse model and regenerates after each edit. Every generated layered USD +stage is opened with pxr; each element's `xformOp:translate`, composed world +translation, `xformOp:rotateZ`, `xformOp:scale`, and `spatialDsl:*` attributes +are compared with the model after the millimetre-to-metre conversion +(tolerance 1e-6). The Visual Components location files and the Omniverse +`warehouse_layout.json` are compared the same way. The first and last composed +stages are checked with the `UsdValidation` framework, and the unedited model +is generated twice to confirm byte-identical outputs. The script exits non-zero +on any mismatch or nondeterminism. + +Outputs: `results/fidelity.json`, `results/fidelity.md`, and the generated +bundles in `results/fidelity/`. Expected runtime: under 10 seconds. diff --git a/benchmarks/fidelity-generate.ts b/benchmarks/fidelity-generate.ts new file mode 100644 index 0000000..44dc6ef --- /dev/null +++ b/benchmarks/fidelity-generate.ts @@ -0,0 +1,140 @@ +/** + * Generation half of the fidelity check (run by fidelity.py). + * + * Applies K seeded pose edits (move, rotate, elevate) to the bundled Smart + * Warehouse model one after another, regenerates the layered OpenUSD bundle + * and the Visual Components location files through the backend generation + * engine after each edit, and writes the outputs plus the expected poses. + * It also generates the unedited model twice and records whether the outputs + * are byte-identical. + * + * npm run fidelity:generate -- [K] [seed] + */ +import './lib/offline-env'; +import fs from 'fs'; +import path from 'path'; +import { CodeGenerationTemplate, Metamodel, Model, ModelElement } from '../shared/types'; +import { codeGenerationEngine } from '../backend/src/services/pipeline/code-generation.engine'; +import { seededRandom } from './lib/synthetic-warehouse'; + +const repoRoot = path.resolve(__dirname, '..'); +const outDir = path.resolve(process.argv[2] || path.join(__dirname, 'results', 'fidelity')); +const editCount = Number(process.argv[3] || 200); +const seed = Number(process.argv[4] || 20261011); + +const readJson = (relativePath: string): any => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); +const metamodel = readJson('frontend/src/examples/data/smart-warehouse-metamodel.json') as Metamodel; +const baseModel = readJson('frontend/src/examples/data/smart-warehouse-model.json') as Model; +const omniverseTemplates = readJson('examples/codegen-projects/smart-warehouse-omniverse-project.json').templates as CodeGenerationTemplate[]; +const visualComponentsTemplates = readJson('examples/codegen-projects/smart-warehouse-visual-components-project.json').templates as CodeGenerationTemplate[]; + +const USD_LAYERS = ['warehouse_layout.usda', 'warehouse_assets.usda', 'warehouse_physics.usda', 'warehouse_simulation.usda']; +const OMNIVERSE_FILES = [...USD_LAYERS, 'warehouse_layout.json']; +const VISUAL_COMPONENTS_FILES = [ + 'PathwayAreaLocation.json', 'RobotLocation.json', 'ConveyorLocation.json', 'OutputLocation.json', 'ChargingStationLocation.json', +]; + +const classNames = new Map(metamodel.classes.map(metaClass => [metaClass.id, metaClass.name])); +const isPlaced = (element: ModelElement): boolean => Boolean(element.presentation?.position3D && element.presentation?.size3D); +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +const generateAll = (model: Model) => ({ + omniverse: codeGenerationEngine.generate(model, metamodel, omniverseTemplates), + visualComponents: codeGenerationEngine.generate(model, metamodel, visualComponentsTemplates), +}); + +const expectedPoses = (model: Model) => model.elements.filter(isPlaced).map(element => ({ + id: element.id, + name: String(element.style?.name || element.name || element.id), + className: classNames.get(element.modelElementId) || element.modelElementId, + x: element.presentation!.position3D!.x, + y: element.presentation!.position3D!.y, + z: element.presentation!.position3D!.z, + rotationZ: element.presentation!.rotationZ ?? 0, + widthMm: element.presentation!.size3D!.widthMm, + heightMm: element.presentation!.size3D!.heightMm, + depthMm: element.presentation!.size3D!.depthMm, +})); + +const writeOutputs = (directory: string, model: Model, outputs: ReturnType): void => { + fs.mkdirSync(path.join(directory, 'visual-components'), { recursive: true }); + const byName = (files: typeof outputs.omniverse) => new Map(files.map(file => [file.filename, file.content])); + const omniverse = byName(outputs.omniverse); + const visualComponents = byName(outputs.visualComponents); + OMNIVERSE_FILES.forEach(name => fs.writeFileSync(path.join(directory, name), omniverse.get(name)!)); + VISUAL_COMPONENTS_FILES.forEach(name => fs.writeFileSync(path.join(directory, 'visual-components', name), visualComponents.get(name)!)); + // Layers reference assets as @omniverse-assets/...@ relative to the bundle root. + const assetLink = path.join(directory, 'omniverse-assets'); + if (!fs.existsSync(assetLink)) fs.symlinkSync(path.join(repoRoot, 'examples', 'omniverse-assets'), assetLink, 'dir'); + fs.writeFileSync(path.join(directory, 'expected.json'), `${JSON.stringify(expectedPoses(model), null, 2)}\n`); +}; + +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); + +// Determinism: the unedited model generated twice must give byte-identical files. +const first = generateAll(baseModel); +const second = generateAll(JSON.parse(JSON.stringify(baseModel))); +const allFiles = (outputs: ReturnType) => [...outputs.omniverse, ...outputs.visualComponents]; +const differing = allFiles(first) + .filter((file, index) => { + const other = allFiles(second)[index]; + return !other || other.filename !== file.filename || other.content !== file.content || other.contentHash !== file.contentHash; + }) + .map(file => file.filename); +const determinism = { + files: allFiles(first).length, + byteIdentical: differing.length === 0 && allFiles(first).length === allFiles(second).length, + differing, + contentHashes: Object.fromEntries(allFiles(first).map(file => [file.filename, file.contentHash])), +}; + +const random = seededRandom(seed); +const model: Model = JSON.parse(JSON.stringify(baseModel)); +const placed = model.elements.filter(isPlaced); +const edits: Array> = []; +const started = Date.now(); +writeOutputs(path.join(outDir, 'edit-000'), model, first); + +for (let index = 1; index <= editCount; index += 1) { + const element = placed[Math.floor(random() * placed.length)]; + const presentation = element.presentation!; + const before = { ...presentation.position3D!, rotationZ: presentation.rotationZ ?? 0 }; + const roll = random(); + let kind: string; + if (roll < 1 / 3) { + kind = 'move'; + presentation.position3D = { + ...presentation.position3D!, + x: round3(presentation.position3D!.x + (random() * 6000 - 3000)), + y: round3(presentation.position3D!.y + (random() * 6000 - 3000)), + }; + } else if (roll < 2 / 3) { + kind = 'rotate'; + presentation.rotationZ = round3(random() * 360 - 180); + } else { + kind = 'elevate'; + presentation.position3D = { ...presentation.position3D!, z: round3(random() * 3000) }; + } + edits.push({ + index, + kind, + element: element.style?.name, + className: classNames.get(element.modelElementId), + before, + after: { ...presentation.position3D!, rotationZ: presentation.rotationZ ?? 0 }, + }); + writeOutputs(path.join(outDir, `edit-${String(index).padStart(3, '0')}`), model, generateAll(model)); +} + +fs.writeFileSync(path.join(outDir, 'manifest.json'), `${JSON.stringify({ + seed, + edits: editCount, + placedElements: placed.length, + omniverseFiles: OMNIVERSE_FILES, + visualComponentsFiles: VISUAL_COMPONENTS_FILES, + determinism, + generationSeconds: Math.round((Date.now() - started) / 100) / 10, + editLog: edits, +}, null, 2)}\n`); +process.stdout.write(`Generated ${editCount + 1} bundles in ${outDir}; determinism byte-identical: ${determinism.byteIdentical}\n`); diff --git a/benchmarks/fidelity.py b/benchmarks/fidelity.py new file mode 100644 index 0000000..78b1389 --- /dev/null +++ b/benchmarks/fidelity.py @@ -0,0 +1,273 @@ +"""Generation fidelity check for the Smart Warehouse example. + +Runs the Node generator (fidelity-generate.ts), which applies K seeded pose +edits to the bundled model and regenerates the layered OpenUSD bundle and the +Visual Components location files after each edit. This script then opens every +generated USD stage with pxr and parses the JSON outputs, compares each +element's pose with the model (millimetres to metres for USD), validates the +composed stage with UsdValidation, and reports the determinism result. + + cd benchmarks && npm run fidelity # or: .venv/bin/python fidelity.py [K] [seed] + +Writes results/fidelity.json and results/fidelity.md. +""" + +import importlib.metadata +import json +import platform +import struct +import subprocess +import sys +import time +from pathlib import Path + +from pxr import Usd, UsdGeom, UsdValidation + +HERE = Path(__file__).resolve().parent +RESULTS = HERE / "results" +OUT = RESULTS / "fidelity" +TOLERANCE = 1e-6 +SCALED_CLASSES = {"Conveyor", "OutputLocation", "ChargingStation", "PathwayArea", "StorageRack", "Dock"} +UNSCALED_CLASSES = {"MobileRobot", "InspectionDrone"} +VC_FILES = { + "PathwayAreaLocation.json": "PathwayArea", + "RobotLocation.json": "MobileRobot", + "ConveyorLocation.json": "Conveyor", + "OutputLocation.json": "OutputLocation", + "ChargingStationLocation.json": "ChargingStation", +} +LAYOUT_JSON_GROUPS = { + "robots": {"MobileRobot"}, + "obstacles": {"StorageRack", "Dock"}, + "pickups": {"Conveyor"}, + "dropoffs": {"OutputLocation"}, + "chargers": {"ChargingStation"}, + "drones": {"InspectionDrone"}, +} + + +def float32(value): + """Round to single precision, the declared type of xformOp:rotateZ.""" + return struct.unpack("f", struct.pack("f", float(value)))[0] + + +class Tally: + def __init__(self): + self.checked = 0 + self.mismatches = [] + + def compare(self, target, edit, element, field, actual, expected, tolerance=TOLERANCE): + self.checked += 1 + if actual is None or abs(float(actual) - float(expected)) > tolerance: + self.mismatches.append({ + "target": target, "edit": edit, "element": element, "field": field, + "actual": actual, "expected": expected, + }) + + def missing(self, target, edit, element): + self.checked += 1 + self.mismatches.append({"target": target, "edit": edit, "element": element, "field": "missing"}) + + +def check_usd(edit_dir, edit, expected, tally): + stage = Usd.Stage.Open(str(edit_dir / "warehouse_simulation.usda")) + prims = {} + for prim in stage.Traverse(): + attribute = prim.GetAttribute("spatialDsl:sourceName") + if attribute and attribute.HasAuthoredValue(): + prims[attribute.Get()] = prim + xform_cache = UsdGeom.XformCache() + for element in expected: + name, class_name = element["name"], element["className"] + if class_name not in SCALED_CLASSES | UNSCALED_CLASSES: + continue + prim = prims.get(name) + if prim is None: + tally.missing("usd", edit, name) + continue + x_m, y_m = element["x"] / 1000, element["y"] / 1000 + if class_name in SCALED_CLASSES: + z_m = (element["z"] + element["depthMm"] / 2) / 1000 + else: + z_m = element["z"] / 1000 + translate = prim.GetAttribute("xformOp:translate").Get() + for axis, value in zip("xyz", (x_m, y_m, z_m)): + tally.compare("usd", edit, name, f"translate.{axis}", translate["xyz".index(axis)] if translate else None, value) + # The composed world transform must agree with the layout opinion. + world = xform_cache.GetLocalToWorldTransform(prim).ExtractTranslation() + for axis, value in zip("xyz", (x_m, y_m, z_m)): + tally.compare("usd", edit, name, f"world.{axis}", world["xyz".index(axis)], value) + tally.compare("usd", edit, name, "rotateZ", prim.GetAttribute("xformOp:rotateZ").Get(), float32(element["rotationZ"])) + tally.compare("usd", edit, name, "spatialDsl:baseElevationMm", prim.GetAttribute("spatialDsl:baseElevationMm").Get(), element["z"]) + class_attr = prim.GetAttribute("spatialDsl:className").Get() + tally.checked += 1 + if class_attr != class_name: + tally.mismatches.append({"target": "usd", "edit": edit, "element": name, "field": "className", "actual": class_attr, "expected": class_name}) + if class_name in SCALED_CLASSES: + scale = prim.GetAttribute("xformOp:scale").Get() + for axis, value in zip("xyz", (element["widthMm"], element["heightMm"], element["depthMm"])): + tally.compare("usd", edit, name, f"scale.{axis}", scale["xyz".index(axis)] if scale else None, value / 1000) + return stage + + +def check_visual_components(edit_dir, edit, expected, tally): + for filename, class_name in VC_FILES.items(): + rows = {row["Name"]: row for row in json.loads((edit_dir / "visual-components" / filename).read_text())} + for element in (e for e in expected if e["className"] == class_name): + row = rows.get(element["name"]) + if row is None: + tally.missing("visual-components", edit, element["name"]) + continue + tally.compare("visual-components", edit, element["name"], "X", row["X"], element["x"]) + tally.compare("visual-components", edit, element["name"], "Y", row["Y"], element["y"]) + tally.compare("visual-components", edit, element["name"], "Rz", row["Rz"], element["rotationZ"]) + if class_name == "PathwayArea": + tally.compare("visual-components", edit, element["name"], "AreaLength", row["AreaLength"], element["widthMm"]) + tally.compare("visual-components", edit, element["name"], "AreaWidth", row["AreaWidth"], element["heightMm"]) + + +def check_layout_json(edit_dir, edit, expected, tally): + layout = json.loads((edit_dir / "warehouse_layout.json").read_text()) + for group, classes in LAYOUT_JSON_GROUPS.items(): + rows = {row["name"]: row for row in layout[group]} + for element in (e for e in expected if e["className"] in classes): + row = rows.get(element["name"]) + if row is None: + tally.missing("omniverse-layout-json", edit, element["name"]) + continue + for field, key in (("x", "x"), ("y", "y"), ("z", "z")): + tally.compare("omniverse-layout-json", edit, element["name"], field, row[key], element[key]) + if "length" in row: + tally.compare("omniverse-layout-json", edit, element["name"], "length", row["length"], element["widthMm"]) + tally.compare("omniverse-layout-json", edit, element["name"], "width", row["width"], element["heightMm"]) + tally.compare("omniverse-layout-json", edit, element["name"], "height", row["height"], element["depthMm"]) + if "yaw" in row: + tally.compare("omniverse-layout-json", edit, element["name"], "yaw", row["yaw"], element["rotationZ"]) + + +def compliance(path): + """Run the UsdValidation framework on the composed stage. + + Every registered validator runs except the usdz package validators, which + only apply to .usdz archives. + """ + registry = UsdValidation.ValidationRegistry() + names = [meta.name for meta in registry.GetAllValidatorMetadata() + if not meta.isSuite and "UsdzValidators" not in meta.GetKeywords()] + context = UsdValidation.ValidationContext(registry.GetOrLoadValidatorsByName(names)) + findings = context.Validate(Usd.Stage.Open(str(path))) + def describe(finding): + return f"{finding.GetValidator().GetMetadata().name}: {finding.GetMessage()}" + by_type = lambda kind: [describe(f) for f in findings if f.GetType() == kind] + return { + "layer": path.name, + "validators": len(names), + "errors": by_type(UsdValidation.ValidationErrorType.Error), + "failedChecks": [], + "warnings": by_type(UsdValidation.ValidationErrorType.Warn), + "info": by_type(UsdValidation.ValidationErrorType.Info), + } + + +def main(): + edits = int(sys.argv[1]) if len(sys.argv) > 1 else 200 + seed = int(sys.argv[2]) if len(sys.argv) > 2 else 20261011 + started = time.time() + ts_node = HERE.parent / "backend" / "node_modules" / ".bin" / "ts-node" + subprocess.run([str(ts_node), "--transpile-only", "--project", str(HERE.parent / "backend" / "tsconfig.json"), + str(HERE / "fidelity-generate.ts"), str(OUT), str(edits), str(seed)], check=True, cwd=HERE) + generated = time.time() + manifest = json.loads((OUT / "manifest.json").read_text()) + + tally = Tally() + edit_dirs = sorted(p for p in OUT.iterdir() if p.is_dir() and p.name.startswith("edit-")) + for edit_dir in edit_dirs: + edit = int(edit_dir.name.split("-")[1]) + expected = json.loads((edit_dir / "expected.json").read_text()) + check_usd(edit_dir, edit, expected, tally) + check_visual_components(edit_dir, edit, expected, tally) + check_layout_json(edit_dir, edit, expected, tally) + + checks = [compliance(edit_dirs[0] / "warehouse_simulation.usda"), compliance(edit_dirs[-1] / "warehouse_simulation.usda")] + checks[0]["edit"] = edit_dirs[0].name + checks[1]["edit"] = edit_dirs[-1].name + by_target = {} + for item in tally.mismatches: + by_target[item["target"]] = by_target.get(item["target"], 0) + 1 + kinds = {} + for item in manifest["editLog"]: + kinds[item["kind"]] = kinds.get(item["kind"], 0) + 1 + + result = { + "edits": edits, + "editKinds": kinds, + "seed": seed, + "bundles": len(edit_dirs), + "placedElements": manifest["placedElements"], + "valuesChecked": tally.checked, + "mismatches": len(tally.mismatches), + "mismatchesByTarget": by_target, + "mismatchSamples": tally.mismatches[:20], + "tolerance": TOLERANCE, + "determinism": manifest["determinism"], + "compliance": checks, + "usdVersion": importlib.metadata.version("usd-core"), + "python": platform.python_version(), + "platform": f"{platform.system()} {platform.release()} ({platform.machine()})", + "seconds": {"generation": round(generated - started, 1), "checks": round(time.time() - generated, 1)}, + } + RESULTS.mkdir(exist_ok=True) + (RESULTS / "fidelity.json").write_text(json.dumps(result, indent=2) + "\n") + + def compliance_line(check): + status = "passed" if not check["errors"] and not check["failedChecks"] else "failed" + return (f"| {check['edit']}/{check['layer']} | {check['validators']} | {status} | {len(check['errors'])} | " + f"{len(check['warnings'])} |") + + lines = [ + "# Generation fidelity results", + "", + f"{edits} seeded pose edits (seed {seed}; {', '.join(f'{v} {k}' for k, v in sorted(kinds.items()))}) applied one after another to the bundled Smart Warehouse model " + f"({manifest['placedElements']} placed elements); outputs regenerated after each edit, {len(edit_dirs)} bundles in total.", + "", + "| Target | Values compared | Mismatches |", + "| --- | ---: | ---: |", + f"| All (tolerance {TOLERANCE}) | {tally.checked} | {len(tally.mismatches)} |", + ] + lines += [f"| {target} | | {count} |" for target, count in sorted(by_target.items())] + lines += [ + "", + "USD values are read from the composed stage `warehouse_simulation.usda` (layout, asset, and physics layers): " + "`xformOp:translate` and the composed world translation in metres, `xformOp:rotateZ` (declared `float`, so the expected " + "yaw is rounded to single precision), `xformOp:scale`, `spatialDsl:baseElevationMm`, and `spatialDsl:className`. " + "Visual Components location files and the Omniverse `warehouse_layout.json` are compared in millimetres and degrees.", + "", + "## USD validation", + "", + "`UsdUtils.ComplianceChecker` is not available in usd-core 26, so the stages are checked with the `UsdValidation` " + "framework instead, running every registered validator except the usdz package ones.", + "", + "| Stage | Validators | Result | Errors | Warnings |", + "| --- | ---: | --- | ---: | ---: |", + *[compliance_line(check) for check in checks], + "", + "## Determinism", + "", + f"Generating the unedited model twice gives byte-identical outputs: **{manifest['determinism']['byteIdentical']}** " + f"({manifest['determinism']['files']} files compared).", + "", + f"Environment: usd-core {result['usdVersion']}, Python {result['python']}, {result['platform']}. " + f"Generation {result['seconds']['generation']} s, checks {result['seconds']['checks']} s.", + "", + ] + for check in checks: + details = check["errors"] + check["warnings"] + check["info"] + if details: + lines += [f"Checker messages for {check['edit']}:", "", *[f"- {message}" for message in details], ""] + (RESULTS / "fidelity.md").write_text("\n".join(lines)) + print("\n".join(lines)) + return 0 if not tally.mismatches and manifest["determinism"]["byteIdentical"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/lib/offline-env.ts b/benchmarks/lib/offline-env.ts new file mode 100644 index 0000000..8e4207f --- /dev/null +++ b/benchmarks/lib/offline-env.ts @@ -0,0 +1,7 @@ +// The backend engines import the API middleware, whose config module exits +// when DATABASE_URL is unset. The benchmarks never open a database +// connection, so a placeholder is enough. Import this module first. +process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://benchmarks:unused@localhost:5432/unused'; +process.env.NODE_ENV = process.env.NODE_ENV || 'benchmark'; + +export {}; diff --git a/benchmarks/lib/synthetic-warehouse.ts b/benchmarks/lib/synthetic-warehouse.ts new file mode 100644 index 0000000..d57a820 --- /dev/null +++ b/benchmarks/lib/synthetic-warehouse.ts @@ -0,0 +1,187 @@ +import { Metamodel, Model, ModelElement } from '../../shared/types'; + +/** Deterministic 32-bit PRNG (mulberry32). Same seed, same sequence on every platform. */ +export const seededRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; + +/** Grid pitch. Larger than the longest rotated footprint, so normal cells never conflict. */ +export const CELL_MM = 8000; + +/** Probability that a cell deliberately violates one rule; about 5 percent of elements end up involved. */ +export const VIOLATION_CELL_PROBABILITY = 0.035; + +type ViolationKind = 'overlap' | 'aisle' | 'stranded-charger' | 'drone-over-rack'; + +export interface SyntheticModel { + model: Model; + violatingElementIds: Set; + violationCells: Record; +} + +interface ElementSpec { + className: string; + size: [number, number, number]; + z?: number; + style?: Record; +} + +const SPECS: Record = { + Conveyor: { className: 'Conveyor', size: [6000, 2000, 3000], style: { InitialPositions: '', Produced: false } }, + StorageRack: { className: 'StorageRack', size: [2000, 1000, 2500], style: { InitialPositions: '', Capacity: 100, Levels: 4 } }, + Dock: { className: 'Dock', size: [5000, 3000, 500], style: { InitialPositions: '', GateNumber: 1 } }, + ChargingStation: { className: 'ChargingStation', size: [2000, 2000, 6000], style: { InitialPositions: '' } }, + PathwayArea: { className: 'PathwayArea', size: [6000, 4000, 200], style: { InitialPositions: '' } }, + OutputLocation: { className: 'OutputLocation', size: [6000, 3000, 3000], style: { InitialPositions: '' } }, + MobileRobot: { + className: 'MobileRobot', + size: [3000, 3000, 4000], + style: { InitialPositions: '', Priority: 1, LowBatteryThreshold: 20, ChargeRate: 10, BatteryLevel: 100, HasProduct: false }, + }, + InspectionDrone: { + className: 'InspectionDrone', + size: [1200, 1200, 400], + z: 4500, + style: { Status: 'Airborne', BatteryLevel: 90, MaxAltitudeMm: 10000 }, + }, +}; + +/** Normal cells, weighted. A charger cell holds a charging station on top of a pathway area. */ +const NORMAL_CELLS: Array<[string, number]> = [ + ['Conveyor', 3], + ['StorageRack', 3], + ['Dock', 1], + ['charger', 1], + ['PathwayArea', 2], + ['OutputLocation', 1], + ['MobileRobot', 2], + ['InspectionDrone', 1], +]; + +const VIOLATIONS: Array<[ViolationKind, number]> = [ + ['overlap', 2], // two conveyors 1000 mm apart: noOverlap among fixed equipment + ['aisle', 2], // two racks with a 200 mm aisle: minimum aisle width + ['stranded-charger', 1], // a charging station with no pathway: adjacency + ['drone-over-rack', 1], // a drone 500 mm above a rack: 3D clearance +]; + +const pickWeighted = (random: () => number, entries: Array<[T, number]>): T => { + const total = entries.reduce((sum, [, weight]) => sum + weight, 0); + let roll = random() * total; + for (const [value, weight] of entries) { + roll -= weight; + if (roll < 0) return value; + } + return entries[entries.length - 1][0]; +}; + +const yaw = (random: () => number): number => Math.round((random() * 360 - 180) * 1000) / 1000; + +/** + * Generate a Smart Warehouse model with exactly `count` placed elements on a + * square grid, each with a random yaw. About 5 percent of the elements sit in + * cells built to violate one of the example spatial rules. + */ +export function generateSyntheticWarehouse(metamodel: Metamodel, count: number, seed: number): SyntheticModel { + const random = seededRandom(seed); + const classIds = Object.fromEntries(metamodel.classes.map(metaClass => [metaClass.name, metaClass.id])); + const elements: ModelElement[] = []; + const violatingElementIds = new Set(); + const violationCells: Record = { + overlap: 0, aisle: 0, 'stranded-charger': 0, 'drone-over-rack': 0, + }; + const chargingStationIds: string[] = []; + const columns = Math.ceil(Math.sqrt(count)); + let cell = 0; + + const add = (spec: ElementSpec, x: number, y: number, rotationZ: number, z = spec.z ?? 0): ModelElement => { + const index = elements.length; + const id = `synthetic-${seed}-${String(index).padStart(6, '0')}`; + const element: ModelElement = { + id, + modelElementId: classIds[spec.className], + style: { name: `${spec.className} ${index}`, ...(spec.style || {}) }, + references: {}, + presentation: { + position3D: { x, y, z }, + size3D: { widthMm: spec.size[0], heightMm: spec.size[1], depthMm: spec.size[2] }, + rotationZ, + }, + }; + if (spec.className === 'ChargingStation') chargingStationIds.push(id); + if (spec.className === 'MobileRobot') { + // assignedStation is mandatory (1..1); the first cell is always a charger cell. + element.references.assignedStation = chargingStationIds[chargingStationIds.length - 1]; + } + elements.push(element); + return element; + }; + + while (elements.length < count) { + const cx = (cell % columns) * CELL_MM; + const cy = Math.floor(cell / columns) * CELL_MM; + const remaining = count - elements.length; + cell += 1; + + if (cell === 1 && remaining >= 2) { + add(SPECS.PathwayArea, cx, cy, yaw(random)); + add(SPECS.ChargingStation, cx, cy, yaw(random)); + continue; + } + + if (remaining >= 2 && random() < VIOLATION_CELL_PROBABILITY) { + const kind = pickWeighted(random, VIOLATIONS); + violationCells[kind] += 1; + const angle = yaw(random); + const radians = (angle * Math.PI) / 180; + const alongY = { x: -Math.sin(radians), y: Math.cos(radians) }; + let created: ModelElement[]; + if (kind === 'overlap') { + created = [ + add(SPECS.Conveyor, cx, cy, angle), + add(SPECS.Conveyor, cx + Math.cos(radians) * 1000, cy + Math.sin(radians) * 1000, angle), + ]; + } else if (kind === 'aisle') { + created = [ + add(SPECS.StorageRack, cx - alongY.x * 600, cy - alongY.y * 600, angle), + add(SPECS.StorageRack, cx + alongY.x * 600, cy + alongY.y * 600, angle), + ]; + } else if (kind === 'stranded-charger') { + created = [add(SPECS.ChargingStation, cx, cy, angle)]; + } else { + created = [add(SPECS.StorageRack, cx, cy, angle), add(SPECS.InspectionDrone, cx, cy, yaw(random), 3000)]; + } + created.forEach(element => violatingElementIds.add(element.id)); + continue; + } + + let kind = pickWeighted(random, NORMAL_CELLS); + if (kind === 'charger' && remaining < 2) kind = 'Conveyor'; + if (kind === 'charger') { + add(SPECS.PathwayArea, cx, cy, yaw(random)); + add(SPECS.ChargingStation, cx, cy, yaw(random)); + } else { + add(SPECS[kind], cx, cy, yaw(random)); + } + } + + return { + model: { + id: `synthetic-warehouse-${count}`, + name: `Synthetic warehouse (${count} elements, seed ${seed})`, + metamodelId: metamodel.id, + conformsTo: metamodel.id, + elements, + connections: [], + }, + violatingElementIds, + violationCells, + }; +} diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000..30ef112 --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,10 @@ +{ + "name": "spatialdsl-benchmarks", + "private": true, + "description": "Unattended evaluation scripts for SpatialDSL Studio (scalability and generation fidelity).", + "scripts": { + "scalability": "../backend/node_modules/.bin/ts-node --transpile-only --project ../backend/tsconfig.json scalability.ts", + "fidelity:generate": "../backend/node_modules/.bin/ts-node --transpile-only --project ../backend/tsconfig.json fidelity-generate.ts", + "fidelity": ".venv/bin/python fidelity.py" + } +} diff --git a/benchmarks/scalability.ts b/benchmarks/scalability.ts new file mode 100644 index 0000000..7b59665 --- /dev/null +++ b/benchmarks/scalability.ts @@ -0,0 +1,161 @@ +/** + * Scalability benchmark: spatial rule evaluation, backend model validation, + * and OpenUSD code generation on seeded synthetic Smart Warehouse models. + * + * Runs in-process (no HTTP, no browser, no database): + * cd benchmarks && npm run scalability + * + * Writes benchmarks/results/scalability.csv, scalability.md, and scalability.json. + */ +import './lib/offline-env'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { CodeGenerationTemplate, Metamodel } from '../shared/types'; +import { evaluateSpatialRules } from '../shared/spatial-rules'; +import { modelValidationEngine } from '../backend/src/services/pipeline/model-validation.engine'; +import { codeGenerationEngine } from '../backend/src/services/pipeline/code-generation.engine'; +import { generateSyntheticWarehouse } from './lib/synthetic-warehouse'; + +const SIZES = [100, 500, 1000, 2500, 5000, 10000]; +const RUNS = 5; +const SEED = 20261011; + +const repoRoot = path.resolve(__dirname, '..'); +const resultsDir = path.join(__dirname, 'results'); +const readJson = (relativePath: string): any => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); + +const metamodel = readJson('frontend/src/examples/data/smart-warehouse-metamodel.json') as Metamodel; +const omniverseProject = readJson('examples/codegen-projects/smart-warehouse-omniverse-project.json'); +const templates = omniverseProject.templates as CodeGenerationTemplate[]; + +const median = (values: number[]): number => { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +}; + +const timeMs = (operation: () => T): { result: T; ms: number } => { + const start = process.hrtime.bigint(); + const result = operation(); + return { result, ms: Number(process.hrtime.bigint() - start) / 1e6 }; +}; + +const round = (value: number, digits = 2): number => Math.round(value * 10 ** digits) / 10 ** digits; + +const machine = { + cpu: os.cpus()[0]?.model || 'unknown', + logicalCores: os.cpus().length, + memoryGiB: round(os.totalmem() / 1024 ** 3, 1), + os: `${os.type()} ${os.release()} (${os.platform()}/${os.arch()})`, + node: process.version, + date: new Date().toISOString(), +}; + +interface Row { + elements: number; + violatingElements: number; + seededViolations: number; + spatialIssues: number; + ruleEvaluationMs: number; + validationMs: number; + codeGenerationMs: number; + modelJsonBytes: number; + generatedBytes: number; +} + +const ruleIds = new Set((metamodel.spatialRules || []).map(rule => rule.id)); +const rows: Row[] = []; +const started = Date.now(); + +for (const size of SIZES) { + const { model, violatingElementIds, violationCells } = generateSyntheticWarehouse(metamodel, size, SEED); + // Every seeded violation cell breaks exactly one rule once; normal cells break none. + const seededViolations = Object.values(violationCells).reduce((sum, count) => sum + count, 0); + const ruleTimes: number[] = []; + const validationTimes: number[] = []; + const generationTimes: number[] = []; + let spatialIssues = 0; + let generatedBytes = 0; + + // One warm-up pass (JIT, Handlebars template compilation caches) before the timed runs. + evaluateSpatialRules(metamodel, model); + modelValidationEngine.validate(model, metamodel); + codeGenerationEngine.generate(model, metamodel, templates); + + for (let run = 0; run < RUNS; run += 1) { + const rules = timeMs(() => evaluateSpatialRules(metamodel, model)); + ruleTimes.push(rules.ms); + spatialIssues = rules.result.length; + + const validation = timeMs(() => modelValidationEngine.validate(model, metamodel)); + validationTimes.push(validation.ms); + const ruleIssueCount = validation.result.issues.filter(issue => ruleIds.has(issue.constraintId || '')).length; + if (ruleIssueCount !== spatialIssues) { + throw new Error(`Validation engine reported ${ruleIssueCount} rule issues, evaluator ${spatialIssues}`); + } + + const generation = timeMs(() => codeGenerationEngine.generate(model, metamodel, templates)); + generationTimes.push(generation.ms); + generatedBytes = generation.result.reduce((sum, file) => sum + Buffer.byteLength(file.content), 0); + } + + const row: Row = { + elements: model.elements.length, + violatingElements: violatingElementIds.size, + seededViolations, + spatialIssues, + ruleEvaluationMs: round(median(ruleTimes)), + validationMs: round(median(validationTimes)), + codeGenerationMs: round(median(generationTimes)), + modelJsonBytes: Buffer.byteLength(JSON.stringify(model)), + generatedBytes, + }; + if (row.spatialIssues !== seededViolations) { + throw new Error(`N=${size}: ${row.spatialIssues} rule issues, but ${seededViolations} violations were seeded`); + } + rows.push(row); + process.stdout.write(`N=${row.elements}: rules ${row.ruleEvaluationMs} ms, validation ${row.validationMs} ms, ` + + `codegen ${row.codeGenerationMs} ms, ${row.spatialIssues} rule issues\n`); +} + +fs.mkdirSync(resultsDir, { recursive: true }); +const columns: Array = [ + 'elements', 'violatingElements', 'seededViolations', 'spatialIssues', 'ruleEvaluationMs', + 'validationMs', 'codeGenerationMs', 'modelJsonBytes', 'generatedBytes', +]; +fs.writeFileSync( + path.join(resultsDir, 'scalability.csv'), + `${columns.join(',')}\n${rows.map(row => columns.map(column => row[column]).join(',')).join('\n')}\n` +); +fs.writeFileSync(path.join(resultsDir, 'scalability.json'), `${JSON.stringify({ + machine, seed: SEED, runs: RUNS, sizes: SIZES, rules: metamodel.spatialRules, rows, +}, null, 2)}\n`); + +const kib = (bytes: number): string => `${round(bytes / 1024, 1)}`; +const markdown = [ + '# Scalability results', + '', + `Median of ${RUNS} runs after one warm-up run; seed ${SEED}; ${(metamodel.spatialRules || []).length} spatial rules from the Smart Warehouse example.`, + '', + '| Elements | Violating elements | Seeded violations | Rule issues | Rule evaluation (ms) | Model validation (ms) | OpenUSD generation (ms) | Model JSON (KiB) | Generated (KiB) |', + '| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows.map(row => `| ${row.elements} | ${row.violatingElements} | ${row.seededViolations} | ${row.spatialIssues} | ${row.ruleEvaluationMs} | ` + + `${row.validationMs} | ${row.codeGenerationMs} | ${kib(row.modelJsonBytes)} | ${kib(row.generatedBytes)} |`), + '', + 'Every seeded violation cell (two overlapping conveyors, a 200 mm rack aisle, a charging station off any pathway, or a drone 500 mm above a rack) yields exactly one rule issue, and the script fails otherwise.', + 'Model validation is the full backend engine (conformance, references, presentation, and spatial rules); its timings come from separate runs, so it can be slightly below the rule evaluation median.', + 'OpenUSD generation renders all templates of `examples/codegen-projects/smart-warehouse-omniverse-project.json`.', + '', + '## Machine', + '', + `- CPU: ${machine.cpu} (${machine.logicalCores} logical cores)`, + `- RAM: ${machine.memoryGiB} GiB`, + `- OS: ${machine.os}`, + `- Node.js: ${machine.node}`, + `- Date: ${machine.date}`, + `- Total benchmark wall time: ${round((Date.now() - started) / 1000, 1)} s`, + '', +].join('\n'); +fs.writeFileSync(path.join(resultsDir, 'scalability.md'), markdown); +process.stdout.write(`\n${markdown}`); From 6d17fbed03e7eaf61722ef8eed6221110354a732 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 12 Oct 2026 07:04:10 -0400 Subject: [PATCH 4/7] Explain that spatial rules read the 3D pose The warehouse example said a violation could be provoked in the 2D view, but 2D moves only change the 3D pose that rules check when an element's 2D and 3D plan positions coincide, which is not the case for the bundled examples. Say so, and point to the 3D view and its Position & Rotation fields instead. --- docs/user-guide/spatial-rules.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/spatial-rules.md b/docs/user-guide/spatial-rules.md index 76479b7..188c894 100644 --- a/docs/user-guide/spatial-rules.md +++ b/docs/user-guide/spatial-rules.md @@ -57,6 +57,11 @@ come from `presentation.size3D`; when an element has none, the metaclass default ancestor with 3D notation wins). Elements that are not placed are skipped and never satisfy an "exists" clause. +Rules read the 3D pose, not the 2D diagram position. Moving or rotating an element in the 3D view, by +dragging it or with the **Position & Rotation** fields of the properties panel, changes the pose. A 2D +move changes it only for elements whose 2D position equals their 3D plan position; the bundled examples +draw schematic 2D layouts, so their 2D moves do not. + Validation does not depend on which view is open. Representation-level and viewpoint-level `defaultSizeMm` overrides, and the renderer's built-in 500 x 800 x 200 mm placeholder, are therefore not used for rules. Give elements an explicit `size3D`, or set a metaclass default, when a rule should see them. @@ -121,8 +126,8 @@ The bundled Smart Warehouse metamodel declares an abstract `FixedEquipment` supe | Charging station on a pathway | `adjacentTo` | ChargingStation | PathwayArea | 500 mm | plan | warning | | Drone clearance from racks | `minClearance` | InspectionDrone | StorageRack | 1000 mm | volume | error | -To see a violation, move or rotate one conveyor into the other in the 2D or 3D view and refresh the -validation panel. +To see a violation, open a warehouse view in 3D, move or rotate one conveyor into the other, and refresh +the validation panel. ## Escape hatch: OCL and JavaScript helpers From 5286287e0e24c2824ee487fb74d57d81cad10fff Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 12 Oct 2026 07:16:41 -0400 Subject: [PATCH 5/7] Keep the backend image buildable with the parity test The backend Dockerfile compiles src/, tests included, with only backend/ and shared/ in the image. The spatial rules parity test imported the frontend mirror and the warehouse fixtures statically, so the image build failed. Load those files at run time instead, as the Sirius fixture test does. --- .../services/spatial-rules-parity.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/backend/src/__tests__/services/spatial-rules-parity.test.ts b/backend/src/__tests__/services/spatial-rules-parity.test.ts index 46cc778..a9b70cb 100644 --- a/backend/src/__tests__/services/spatial-rules-parity.test.ts +++ b/backend/src/__tests__/services/spatial-rules-parity.test.ts @@ -2,17 +2,24 @@ import fs from 'fs'; import path from 'path'; import * as sharedGeometry from '../../../../shared/spatial-geometry'; import * as sharedRules from '../../../../shared/spatial-rules'; -import * as frontendGeometry from '../../../../frontend/src/services/spatial/spatial-geometry'; -import * as frontendRules from '../../../../frontend/src/services/spatial/spatial-rules'; import { MetaClass, Metamodel, Model, ModelElement, SpatialRule, SpatialRuleKind } from '../../../../shared/types'; -import smartWarehouseMetamodel from '../../../../frontend/src/examples/data/smart-warehouse-metamodel.json'; -import smartWarehouseModel from '../../../../frontend/src/examples/data/smart-warehouse-model.json'; // The frontend cannot import shared/, so the geometry and rule evaluator are // mirrored. These tests keep the two copies byte-identical below their // import blocks and run the same fixtures through both. +// Frontend files are loaded at run time rather than imported: the backend build +// compiles src/ (tests included) in an image that has no frontend/ folder. const repoRoot = path.resolve(__dirname, '../../../..'); +const frontendGeometry: typeof sharedGeometry = jest.requireActual( + path.join(repoRoot, 'frontend/src/services/spatial/spatial-geometry') +); +const frontendRules: typeof sharedRules = jest.requireActual( + path.join(repoRoot, 'frontend/src/services/spatial/spatial-rules') +); +const readJson = (relativePath: string): unknown => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); +const smartWarehouseMetamodel = readJson('frontend/src/examples/data/smart-warehouse-metamodel.json'); +const smartWarehouseModel = readJson('frontend/src/examples/data/smart-warehouse-model.json'); const bodyOf = (relativePath: string): string => { const lines = fs.readFileSync(path.join(repoRoot, relativePath), 'utf8').split('\n'); return lines.slice(lines.findIndex(line => !line.startsWith('import '))).join('\n'); From 21ae860c2431a8346d9676821bdd1d96767f00a0 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 12 Oct 2026 21:33:32 -0400 Subject: [PATCH 6/7] Validate spatial rules on every path that stores them Metamodel evolution now runs the full rule validation (kind, distance, mode, severity, duplicate IDs) before a preview can be applied, a classes-only update checks the stored rules against the new classes, and Ecore import skips annotation rules that are not valid rules with a warning instead of importing them. --- .../metamodel-evolution.service.test.ts | 15 +++++++++ .../services/metamodel.service.test.ts | 24 ++++++++++++++ .../lifecycle/metamodel-evolution.service.ts | 4 +++ backend/src/services/metamodel.service.ts | 15 ++++++--- .../services/ecore-xmi-interop.test.ts | 33 +++++++++++++++++++ .../src/services/metamodel/ecore.service.ts | 15 +++++++-- 6 files changed, 99 insertions(+), 7 deletions(-) diff --git a/backend/src/__tests__/services/metamodel-evolution.service.test.ts b/backend/src/__tests__/services/metamodel-evolution.service.test.ts index 5db0057..fe6e51d 100644 --- a/backend/src/__tests__/services/metamodel-evolution.service.test.ts +++ b/backend/src/__tests__/services/metamodel-evolution.service.test.ts @@ -198,6 +198,21 @@ describe('MetamodelEvolutionService', () => { expect(withoutRule.blockers).toEqual([]); }); + it('blocks an evolution whose spatial rules are not valid rules', async () => { + const report = await metamodelEvolutionService.preview(projectId, metamodelId, { + ...currentMetamodel, + spatialRules: [ + { id: 'rule-1', name: 'Gap', kind: 'minClearance', sourceClassId: 'robot-class', targetClassId: 'robot-class', severity: 'error' }, + { id: 'rule-1', name: 'Apart', kind: 'noOverlap', sourceClassId: 'robot-class', targetClassId: 'robot-class', severity: 'error' }, + ] as any, + }); + + expect(report.blockers).toEqual(expect.arrayContaining([ + 'spatialRules[0].distanceMm is required for minClearance', + expect.stringContaining('duplicate'), + ])); + }); + it('keeps the fingerprint of rule-free metamodels and changes it when rules change', async () => { const plain = await metamodelEvolutionService.preview(projectId, metamodelId, currentMetamodel); const emptyRules = await metamodelEvolutionService.preview(projectId, metamodelId, { ...currentMetamodel, spatialRules: [] }); diff --git a/backend/src/__tests__/services/metamodel.service.test.ts b/backend/src/__tests__/services/metamodel.service.test.ts index a6a2b54..7143713 100644 --- a/backend/src/__tests__/services/metamodel.service.test.ts +++ b/backend/src/__tests__/services/metamodel.service.test.ts @@ -359,6 +359,30 @@ describe('MetamodelService', () => { }, 'user-uuid-1', 'DSL_DESIGNER')).rejects.toThrow('spatialRules[0].sourceClassId references unknown metaclass conveyor'); }); + it('rejects a classes-only update that would leave a stored spatial rule dangling', async () => { + sharingServiceMock.checkAccess.mockResolvedValue({ hasAccess: true, isOwner: true }); + const rack = { id: 'rack', name: 'Rack', eClass: 'EClass', abstract: false, superTypes: [], attributes: [], references: [] }; + const zone = { ...rack, id: 'zone', name: 'Zone' }; + prismaMock.metamodel.findUnique.mockResolvedValue({ + ...mockMetamodelRow, + classes: [rack, zone], + spatialRules: [{ + id: 'rule-1', name: 'Racks in zones', kind: 'within', sourceClassId: 'rack', targetClassId: 'zone', severity: 'error', + }], + } as any); + + await expect(metamodelService.update('mm-uuid-1', { classes: [rack] }, 'user-uuid-1', 'DSL_DESIGNER')) + .rejects.toThrow('spatialRules[0].targetClassId references unknown metaclass zone'); + expect(prismaMock.metamodel.update).not.toHaveBeenCalled(); + + prismaMock.metamodel.update.mockResolvedValue({ ...mockMetamodelRow, classes: [rack, zone] } as any); + await metamodelService.update('mm-uuid-1', { classes: [rack, zone] }, 'user-uuid-1', 'DSL_DESIGNER'); + expect(prismaMock.metamodel.update).toHaveBeenCalledWith({ + where: { id: 'mm-uuid-1' }, + data: { classes: [rack, zone] }, + }); + }); + it('rejects a metamodel update whose default elevation is outside its bounds', async () => { sharingServiceMock.checkAccess.mockResolvedValue({ hasAccess: true, diff --git a/backend/src/services/lifecycle/metamodel-evolution.service.ts b/backend/src/services/lifecycle/metamodel-evolution.service.ts index 0a61712..1a98d6e 100644 --- a/backend/src/services/lifecycle/metamodel-evolution.service.ts +++ b/backend/src/services/lifecycle/metamodel-evolution.service.ts @@ -17,6 +17,7 @@ import { SpatialRule, } from '../../../../shared/types'; import { validateConcreteSyntaxVerticalPlacement } from '../../../../shared/spatial'; +import { validateSpatialRules } from '../../../../shared/spatial-rules'; import { contentHash, stableStringify } from './canonical'; import { projectCheckpointService } from './project-checkpoint.service'; @@ -373,6 +374,9 @@ class MetamodelEvolutionService { } }); + // Apply writes the rules directly, so they get the same validation as a metamodel + // update. Class references are checked below to name removed classes. + blockers.push(...validateSpatialRules(next.spatialRules ?? [])); const nextClassNames = new Map(next.classes.map(metaClass => [metaClass.id, metaClass.name])); const currentClassNames = new Map(context.current.classes.map(metaClass => [metaClass.id, metaClass.name])); asArray(next.spatialRules).forEach(rule => { diff --git a/backend/src/services/metamodel.service.ts b/backend/src/services/metamodel.service.ts index 5488295..0302350 100644 --- a/backend/src/services/metamodel.service.ts +++ b/backend/src/services/metamodel.service.ts @@ -213,11 +213,16 @@ class MetamodelService { if (data.classes !== undefined) { this.assertValidClassesVerticalPlacement(data.classes); } - if (data.spatialRules !== undefined) { - const classes = data.classes !== undefined - ? data.classes - : (await prisma.metamodel.findUnique({ where: { id }, select: { classes: true } }))?.classes; - this.assertValidSpatialRules(data.spatialRules, classes); + if (data.spatialRules !== undefined || data.classes !== undefined) { + // Check the rules that will be stored against the classes that will be stored, so a + // classes-only update cannot leave a rule pointing at a removed class. + const stored = data.spatialRules !== undefined && data.classes !== undefined + ? undefined + : await prisma.metamodel.findUnique({ where: { id }, select: { classes: true, spatialRules: true } }); + this.assertValidSpatialRules( + data.spatialRules !== undefined ? data.spatialRules : stored?.spatialRules ?? [], + data.classes !== undefined ? data.classes : stored?.classes + ); } const metamodel = await prisma.metamodel.update({ diff --git a/frontend/src/__tests__/services/ecore-xmi-interop.test.ts b/frontend/src/__tests__/services/ecore-xmi-interop.test.ts index 2055ab4..a1554e5 100644 --- a/frontend/src/__tests__/services/ecore-xmi-interop.test.ts +++ b/frontend/src/__tests__/services/ecore-xmi-interop.test.ts @@ -289,6 +289,39 @@ describe('Ecore spatial rules annotation', () => { })]); expect(mockGetMetamodelById(result.metamodelId!)!.spatialRules).toBeUndefined(); }); + + it('skips spatial rules that resolve but are not valid rules', () => { + const ecore = ` + + +
+
+
+ + +`; + + const result = ecoreService.importFromEcoreWithReport(ecore); + expect(result.warnings).toEqual([ + expect.objectContaining({ + type: 'skipped-annotation', + message: 'Skipped spatial rule Bogus; it is not a valid spatial rule.', + details: [expect.stringContaining('kind')], + }), + expect.objectContaining({ + type: 'skipped-annotation', + message: 'Skipped spatial rule No gap; it is not a valid spatial rule.', + details: ['spatialRule.distanceMm is required for minClearance'], + }), + ]); + expect(mockGetMetamodelById(result.metamodelId!)!.spatialRules).toEqual([ + expect.objectContaining({ name: 'Apart', kind: 'noOverlap' }), + ]); + }); }); describe('Model XMI interop', () => { diff --git a/frontend/src/services/metamodel/ecore.service.ts b/frontend/src/services/metamodel/ecore.service.ts index eee8364..83e71c4 100644 --- a/frontend/src/services/metamodel/ecore.service.ts +++ b/frontend/src/services/metamodel/ecore.service.ts @@ -9,6 +9,7 @@ import { SpatialRule, } from '../../models/types'; import { metamodelService } from './metamodel.service'; +import { validateSpatialRule } from '../spatial/spatial-rules'; import { v4 as uuidv4 } from 'uuid'; export interface EcoreExportWarning { @@ -151,7 +152,7 @@ class EcoreService { }); return; } - rules.push({ + const rule: SpatialRule = { id: uuidv4(), name, kind: parsed.kind, @@ -162,7 +163,17 @@ class EcoreService { severity: parsed.severity || 'error', ...(parsed.message !== undefined && { message: parsed.message }), ...(parsed.enabled !== undefined && { enabled: parsed.enabled }), - }); + }; + const errors = validateSpatialRule(rule); + if (errors.length > 0) { + warnings.push({ + type: 'skipped-annotation', + message: `Skipped spatial rule ${name}; it is not a valid spatial rule.`, + details: errors, + }); + return; + } + rules.push(rule); } catch { warnings.push({ type: 'skipped-annotation', From 2053223d6ecb4062579a38879a1bb545aa2f42d7 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Mon, 12 Oct 2026 21:37:11 -0400 Subject: [PATCH 7/7] Fail the fidelity check on USD validation findings and state the layout yaw scope The script now exits non-zero when UsdValidation reports an error or a warning, not only on mismatches or nondeterminism. warehouse_layout.json gives a yaw only for drones, so the check requires it there and the results and README say that the other elements' yaw is compared in USD and Visual Components. --- benchmarks/README.md | 11 +++++++---- benchmarks/fidelity.py | 21 ++++++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/benchmarks/README.md b/benchmarks/README.md index f6edef6..23f8052 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -43,10 +43,13 @@ stage is opened with pxr; each element's `xformOp:translate`, composed world translation, `xformOp:rotateZ`, `xformOp:scale`, and `spatialDsl:*` attributes are compared with the model after the millimetre-to-metre conversion (tolerance 1e-6). The Visual Components location files and the Omniverse -`warehouse_layout.json` are compared the same way. The first and last composed -stages are checked with the `UsdValidation` framework, and the unedited model -is generated twice to confirm byte-identical outputs. The script exits non-zero -on any mismatch or nondeterminism. +`warehouse_layout.json` are compared the same way; the layout JSON gives a yaw +only for drones (the other groups are axis-aligned footprints for the +simulator), so their yaw is compared in USD and Visual Components only. The +first and last composed stages are checked with the `UsdValidation` framework, +and the unedited model is generated twice to confirm byte-identical outputs. +The script exits non-zero on any mismatch, nondeterminism, or USD validation +error or warning. Outputs: `results/fidelity.json`, `results/fidelity.md`, and the generated bundles in `results/fidelity/`. Expected runtime: under 10 seconds. diff --git a/benchmarks/fidelity.py b/benchmarks/fidelity.py index 78b1389..2061072 100644 --- a/benchmarks/fidelity.py +++ b/benchmarks/fidelity.py @@ -44,6 +44,10 @@ "chargers": {"ChargingStation"}, "drones": {"InspectionDrone"}, } +# warehouse_layout.json gives a yaw only for drones; the other groups are axis-aligned +# footprints for the simulator. Every element's yaw is still compared in the USD stage +# (xformOp:rotateZ) and the Visual Components files (Rz). +LAYOUT_JSON_YAW_GROUPS = {"drones"} def float32(value): @@ -141,8 +145,8 @@ def check_layout_json(edit_dir, edit, expected, tally): tally.compare("omniverse-layout-json", edit, element["name"], "length", row["length"], element["widthMm"]) tally.compare("omniverse-layout-json", edit, element["name"], "width", row["width"], element["heightMm"]) tally.compare("omniverse-layout-json", edit, element["name"], "height", row["height"], element["depthMm"]) - if "yaw" in row: - tally.compare("omniverse-layout-json", edit, element["name"], "yaw", row["yaw"], element["rotationZ"]) + if group in LAYOUT_JSON_YAW_GROUPS: + tally.compare("omniverse-layout-json", edit, element["name"], "yaw", row.get("yaw"), element["rotationZ"]) def compliance(path): @@ -219,8 +223,11 @@ def main(): RESULTS.mkdir(exist_ok=True) (RESULTS / "fidelity.json").write_text(json.dumps(result, indent=2) + "\n") + def compliance_passed(check): + return not check["errors"] and not check["failedChecks"] and not check["warnings"] + def compliance_line(check): - status = "passed" if not check["errors"] and not check["failedChecks"] else "failed" + status = "passed" if compliance_passed(check) else "failed" return (f"| {check['edit']}/{check['layer']} | {check['validators']} | {status} | {len(check['errors'])} | " f"{len(check['warnings'])} |") @@ -240,7 +247,9 @@ def compliance_line(check): "USD values are read from the composed stage `warehouse_simulation.usda` (layout, asset, and physics layers): " "`xformOp:translate` and the composed world translation in metres, `xformOp:rotateZ` (declared `float`, so the expected " "yaw is rounded to single precision), `xformOp:scale`, `spatialDsl:baseElevationMm`, and `spatialDsl:className`. " - "Visual Components location files and the Omniverse `warehouse_layout.json` are compared in millimetres and degrees.", + "Visual Components location files and the Omniverse `warehouse_layout.json` are compared in millimetres and degrees. " + "`warehouse_layout.json` gives a yaw only for drones (the other groups are axis-aligned footprints for the simulator), " + "so the yaw of the other elements is compared in USD and Visual Components only.", "", "## USD validation", "", @@ -266,7 +275,9 @@ def compliance_line(check): lines += [f"Checker messages for {check['edit']}:", "", *[f"- {message}" for message in details], ""] (RESULTS / "fidelity.md").write_text("\n".join(lines)) print("\n".join(lines)) - return 0 if not tally.mismatches and manifest["determinism"]["byteIdentical"] else 1 + passed = (not tally.mismatches and manifest["determinism"]["byteIdentical"] + and all(compliance_passed(check) for check in checks)) + return 0 if passed else 1 if __name__ == "__main__":