diff --git a/backend/src/__tests__/routes/interoperability.routes.test.ts b/backend/src/__tests__/routes/interoperability.routes.test.ts
index 584ab54..9244159 100644
--- a/backend/src/__tests__/routes/interoperability.routes.test.ts
+++ b/backend/src/__tests__/routes/interoperability.routes.test.ts
@@ -4,7 +4,9 @@ import { errorHandler } from '../../middleware';
const siriusInteropServiceMock = {
validate: jest.fn(),
+ validateAirdView: jest.fn(),
importOdesign: jest.fn(),
+ importAird: jest.fn(),
exportOdesign: jest.fn(),
};
@@ -66,6 +68,51 @@ describe('interoperability Sirius routes', () => {
);
});
+ it('routes .aird validation requests to validateAirdView', async () => {
+ siriusInteropServiceMock.validateAirdView.mockResolvedValue({
+ diagrams: [],
+ report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
+ });
+
+ const res = await request(buildApp())
+ .post('/api/interoperability/sirius/validate')
+ .send({ content: '', sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' });
+
+ expect(res.status).toBe(200);
+ expect(siriusInteropServiceMock.validateAirdView).toHaveBeenCalledWith(
+ expect.objectContaining({ sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' }),
+ 'user-1'
+ );
+ expect(siriusInteropServiceMock.validate).not.toHaveBeenCalled();
+ });
+
+ it('imports Sirius .aird views', async () => {
+ siriusInteropServiceMock.importAird.mockResolvedValue({
+ diagrams: [{ id: 'diagram-1' }],
+ report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
+ });
+
+ const res = await request(buildApp())
+ .post('/api/interoperability/sirius/aird/import')
+ .send({ content: '', modelId: 'model-1', viewpointId: 'viewpoint-1' });
+
+ expect(res.status).toBe(201);
+ expect(siriusInteropServiceMock.importAird).toHaveBeenCalledWith(
+ expect.objectContaining({ modelId: 'model-1', viewpointId: 'viewpoint-1' }),
+ 'user-1',
+ 'DSL_DESIGNER'
+ );
+ });
+
+ it('rejects .aird import requests without a modelId', async () => {
+ const res = await request(buildApp())
+ .post('/api/interoperability/sirius/aird/import')
+ .send({ content: '' });
+
+ expect(res.status).toBe(400);
+ expect(siriusInteropServiceMock.importAird).not.toHaveBeenCalled();
+ });
+
it('imports Sirius .odesign content', async () => {
siriusInteropServiceMock.importOdesign.mockResolvedValue({
viewpoints: [{ id: 'viewpoint-1' }],
diff --git a/backend/src/__tests__/services/sirius-interop.service.test.ts b/backend/src/__tests__/services/sirius-interop.service.test.ts
index 73f9d46..cebb626 100644
--- a/backend/src/__tests__/services/sirius-interop.service.test.ts
+++ b/backend/src/__tests__/services/sirius-interop.service.test.ts
@@ -9,6 +9,14 @@ const viewpointServiceMock = {
getAll: jest.fn(),
};
+const modelServiceMock = {
+ getById: jest.fn(),
+};
+
+const diagramServiceMock = {
+ create: jest.fn(),
+};
+
jest.mock('../../services/metamodel.service', () => ({
metamodelService: metamodelServiceMock,
}));
@@ -17,9 +25,19 @@ jest.mock('../../services/viewpoint.service', () => ({
viewpointService: viewpointServiceMock,
}));
+jest.mock('../../services/model.service', () => ({
+ modelService: modelServiceMock,
+}));
+
+jest.mock('../../services/diagram.service', () => ({
+ diagramService: diagramServiceMock,
+}));
+
import { siriusInteropService } from '../../services/sirius-interop.service';
-import { Metamodel, Viewpoint } from '../../../../shared/types';
+import { Metamodel, Model, Viewpoint } from '../../../../shared/types';
import JSZip from 'jszip';
+import * as fs from 'fs';
+import * as path from 'path';
const mockMetamodel: Metamodel = {
id: 'metamodel-1',
@@ -159,10 +177,12 @@ describe('SiriusInteropService', () => {
).rejects.toThrow(ApiError);
});
- it('recognizes .aird files as deferred Sirius session data', async () => {
- const preview = await siriusInteropService.validate(
+ it('recognizes a Sirius .aird DAnalysis but reports when it has no diagram representations', async () => {
+ modelServiceMock.getById.mockResolvedValue(mockModel);
+ const preview = await siriusInteropService.validateAirdView(
{
sourceFormat: 'aird',
+ modelId: 'model-1',
content: `
{
'user-1'
);
- expect(preview.viewpoints).toHaveLength(0);
- expect(preview.report.supported).toBe(true);
- expect(preview.report.droppedFeatures).toEqual(expect.arrayContaining([
- expect.objectContaining({ code: 'SIRIUS_DEFERRED_AIRD' }),
+ expect(preview.diagrams).toHaveLength(0);
+ expect(preview.report.unresolvedReferences).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'SIRIUS_AIRD_NO_DIAGRAMS' }),
]));
});
@@ -383,3 +402,193 @@ describe('SiriusInteropService', () => {
).rejects.toThrow(ApiError);
});
});
+
+const mockModel: Model = {
+ id: 'model-1',
+ name: 'Demo Model',
+ metamodelId: 'metamodel-1',
+ conformsTo: 'metamodel-1',
+ elements: [
+ { id: 'component-api', name: 'API', modelElementId: 'cls-root', style: {}, references: {} },
+ { id: 'component-db', name: 'Database', modelElementId: 'cls-child', style: {}, references: {} },
+ ],
+};
+
+const mockAirdViewpoint: Viewpoint = {
+ id: 'viewpoint-1',
+ name: 'Minimal Viewpoint',
+ metamodelId: 'metamodel-1',
+ sharedConcreteSyntaxByMetaClassId: {},
+ isDefault: true,
+ representationDescriptions: [
+ {
+ id: 'sirius-diag-main',
+ name: 'Minimal Diagram',
+ viewpointId: 'viewpoint-1',
+ kind: 'diagram',
+ visibleMetaClassIds: ['cls-root', 'cls-child'],
+ creatableMetaClassIds: ['cls-root', 'cls-child'],
+ edgeMappings: [{ id: 'sirius-edge-children', referenceName: 'children' }],
+ isDefault: true,
+ },
+ ],
+};
+
+const airdWithLayout = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+describe('SiriusInteropService .aird import', () => {
+ beforeEach(() => {
+ modelServiceMock.getById.mockResolvedValue(mockModel);
+ viewpointServiceMock.getAll.mockResolvedValue([mockAirdViewpoint]);
+ diagramServiceMock.create.mockImplementation(async (data: any) => ({ id: `diagram-${data.name}`, ...data }));
+ });
+
+ it('validates a Sirius .aird into a SpatialDSL view with resolved targets and GMF layout', async () => {
+ const preview = await siriusInteropService.validateAirdView(
+ { content: airdWithLayout, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
+ 'user-1'
+ );
+
+ expect(preview.report.supported).toBe(true);
+ expect(preview.diagrams).toHaveLength(1);
+
+ const diagram = preview.diagrams[0];
+ expect(diagram).toEqual(expect.objectContaining({
+ name: 'Demo System',
+ modelId: 'model-1',
+ viewpointId: 'viewpoint-1',
+ representationDescriptionId: 'sirius-diag-main',
+ }));
+
+ const nodes = diagram.elements.filter(element => element.type === 'node');
+ const edges = diagram.elements.filter(element => element.type === 'edge');
+ expect(nodes).toHaveLength(2);
+ expect(edges).toHaveLength(1);
+
+ const apiNode = nodes.find(node => node.modelElementId === 'component-api');
+ expect(apiNode).toEqual(expect.objectContaining({ x: 100, y: 50, width: 160, height: 90 }));
+
+ const edge = edges[0];
+ expect(edge.modelElementId).toBe('component-api');
+ expect(edge.sourceId).toBe(nodes.find(node => node.modelElementId === 'component-api')!.id);
+ expect(edge.targetId).toBe(nodes.find(node => node.modelElementId === 'component-db')!.id);
+ expect(edge.points).toEqual([{ x: 180, y: 140 }, { x: 480, y: 260 }]);
+ });
+
+ it('imports a Sirius .aird by creating views through the diagram service', async () => {
+ const result = await siriusInteropService.importAird(
+ { content: airdWithLayout, modelId: 'model-1', viewpointId: 'viewpoint-1' },
+ 'user-1',
+ 'DSL_DESIGNER'
+ );
+
+ expect(diagramServiceMock.create).toHaveBeenCalledTimes(1);
+ expect(diagramServiceMock.create).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'Demo System', modelId: 'model-1', viewpointId: 'viewpoint-1' }),
+ 'user-1',
+ 'DSL_DESIGNER'
+ );
+ expect(result.diagrams).toHaveLength(1);
+ expect(result.report.supported).toBe(true);
+ });
+
+ it('fails .aird import when the model is missing', async () => {
+ modelServiceMock.getById.mockResolvedValue(null);
+ await expect(
+ siriusInteropService.importAird(
+ { content: airdWithLayout, modelId: 'missing', viewpointId: 'viewpoint-1' },
+ 'user-1',
+ 'DSL_DESIGNER'
+ )
+ ).rejects.toThrow(ApiError);
+ expect(diagramServiceMock.create).not.toHaveBeenCalled();
+ });
+
+ it('warns and drops elements whose semantic target cannot be resolved', async () => {
+ const airdUnresolved = airdWithLayout.replace(
+ 'name="Database" target="model/sample.xmi#component-db"',
+ 'name="Ghost" target="model/sample.xmi#missing-component"'
+ );
+
+ const preview = await siriusInteropService.validateAirdView(
+ { content: airdUnresolved, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
+ 'user-1'
+ );
+
+ const diagram = preview.diagrams[0];
+ // The DB node drops, which also drops the edge (its target endpoint is gone).
+ expect(diagram.elements.filter(element => element.type === 'node')).toHaveLength(1);
+ expect(diagram.elements.filter(element => element.type === 'edge')).toHaveLength(0);
+ expect(preview.report.unresolvedReferences.some(warning => warning.code === 'SIRIUS_AIRD_TARGET_UNRESOLVED')).toBe(true);
+ });
+
+ it('parses the aird-layout fixture from disk and preserves GMF node bounds', async () => {
+ const fixturePath = path.resolve(__dirname, '../../../../fixtures/sirius/aird-layout/representations.aird');
+ const content = fs.readFileSync(fixturePath, 'utf8');
+ const fixtureViewpoint: Viewpoint = {
+ ...mockAirdViewpoint,
+ representationDescriptions: [
+ { ...mockAirdViewpoint.representationDescriptions[0], id: 'sirius-diagram-minimal' },
+ ],
+ };
+ viewpointServiceMock.getAll.mockResolvedValue([fixtureViewpoint]);
+
+ const preview = await siriusInteropService.validateAirdView(
+ { content, sourceFormat: 'aird', modelId: 'model-1', viewpointId: 'viewpoint-1' },
+ 'user-1'
+ );
+
+ expect(preview.report.supported).toBe(true);
+ expect(preview.report.unresolvedReferences).toHaveLength(0);
+ const diagram = preview.diagrams[0];
+ expect(diagram.representationDescriptionId).toBe('sirius-diagram-minimal');
+ expect(diagram.elements.filter(element => element.type === 'node')).toHaveLength(2);
+ expect(diagram.elements.filter(element => element.type === 'edge')).toHaveLength(1);
+ expect(diagram.elements.find(element => element.modelElementId === 'component-api')).toEqual(
+ expect.objectContaining({ x: 80, y: 60, width: 160, height: 90 })
+ );
+ });
+
+ it('errors when no model is supplied for an .aird import', async () => {
+ const preview = await siriusInteropService.validateAirdView(
+ { content: airdWithLayout, sourceFormat: 'aird' },
+ 'user-1'
+ );
+
+ expect(preview.report.supported).toBe(false);
+ expect(preview.diagrams).toHaveLength(0);
+ expect(preview.report.unresolvedReferences.some(warning => warning.code === 'SIRIUS_AIRD_MODEL_REQUIRED')).toBe(true);
+ });
+});
diff --git a/backend/src/routes/interoperability.routes.ts b/backend/src/routes/interoperability.routes.ts
index 3cd9514..cc2a612 100644
--- a/backend/src/routes/interoperability.routes.ts
+++ b/backend/src/routes/interoperability.routes.ts
@@ -15,14 +15,32 @@ router.post(
body('content').isString().notEmpty().withMessage('content is required'),
body('sourceFormat').optional().isIn(['ecore', 'xmi', 'odesign', 'aird', 'project-zip']),
body('metamodelId').optional().isString().withMessage('metamodelId must be a string'),
+ body('modelId').optional().isString().withMessage('modelId must be a string'),
+ body('viewpointId').optional().isString().withMessage('viewpointId must be a string'),
body('options').optional().isObject().withMessage('options must be an object'),
]),
asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
- const preview = await siriusInteropService.validate(req.body, req.user!.userId);
+ const preview = req.body.sourceFormat === 'aird'
+ ? await siriusInteropService.validateAirdView(req.body, req.user!.userId)
+ : await siriusInteropService.validate(req.body, req.user!.userId);
res.json({ success: true, data: preview });
})
);
+router.post(
+ '/sirius/aird/import',
+ validate([
+ body('content').isString().notEmpty().withMessage('content is required'),
+ body('modelId').isString().notEmpty().withMessage('modelId is required'),
+ body('viewpointId').optional().isString().withMessage('viewpointId must be a string'),
+ body('options').optional().isObject().withMessage('options must be an object'),
+ ]),
+ asyncHandler(async (req: AuthenticatedRequest, res: Response) => {
+ const result = await siriusInteropService.importAird(req.body, req.user!.userId, req.user!.role);
+ res.status(201).json({ success: true, data: result });
+ })
+);
+
router.post(
'/sirius/import',
validate([
diff --git a/backend/src/services/sirius-interop.service.ts b/backend/src/services/sirius-interop.service.ts
index 8e17a7b..c2ea0b1 100644
--- a/backend/src/services/sirius-interop.service.ts
+++ b/backend/src/services/sirius-interop.service.ts
@@ -5,12 +5,19 @@ import {
ConcreteSyntax,
ConcreteSyntax2D,
ConcreteSyntaxEdge,
+ Diagram,
+ DiagramElement,
MetaClass,
MetaReference,
Metamodel,
+ Model,
+ ModelElement,
RepresentationDescription,
RepresentationEdgeMapping,
RepresentationPinMapping,
+ SiriusAirdDiagramPreview,
+ SiriusAirdImportResult,
+ SiriusAirdPreview,
SiriusCompatibilityReport,
SiriusExportOptions,
SiriusExportResult,
@@ -24,6 +31,8 @@ import {
} from '../../../shared/types';
import { metamodelService } from './metamodel.service';
import { viewpointService } from './viewpoint.service';
+import { modelService } from './model.service';
+import { diagramService } from './diagram.service';
interface XmlNode {
name: string;
@@ -40,6 +49,18 @@ interface ParseContext {
preserveSiriusIds: boolean;
}
+interface AirdBounds {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+interface AirdLayout {
+ bounds?: AirdBounds;
+ points?: Array<{ x: number; y: number }>;
+}
+
interface ImportOdesignInput {
content: string;
metamodelId: string;
@@ -50,6 +71,15 @@ interface ValidateInput {
content: string;
sourceFormat?: SiriusSourceFormat;
metamodelId?: string;
+ modelId?: string;
+ viewpointId?: string;
+ options?: Partial;
+}
+
+interface ImportAirdInput {
+ content: string;
+ modelId: string;
+ viewpointId?: string;
options?: Partial;
}
@@ -118,13 +148,19 @@ class SiriusInteropService {
return this.validateProjectZip(input.content, metamodel, options);
}
- if (sourceFormat === 'aird') {
- return this.validateAird(input.content);
- }
-
return this.validateSemanticXml(input.content, sourceFormat);
}
+ /** Validate a Sirius `.aird` session against an already-imported model and viewpoint. */
+ async validateAirdView(input: ValidateInput, userId: string): Promise {
+ const options = { ...DEFAULT_IMPORT_OPTIONS, importAird: true, ...input.options };
+ const model = input.modelId ? await this.getReadableModel(input.modelId, userId) : undefined;
+ const viewpoint = input.viewpointId
+ ? await this.getViewpointForModel(input.viewpointId, model, userId)
+ : undefined;
+ return this.parseAird(input.content, model, viewpoint, options);
+ }
+
async importOdesign(input: ImportOdesignInput, userId: string, userRole: UserRole): Promise {
const options = { ...DEFAULT_IMPORT_OPTIONS, ...input.options };
if (!options.importOdesign) {
@@ -153,6 +189,43 @@ class SiriusInteropService {
};
}
+ async importAird(input: ImportAirdInput, userId: string, userRole: UserRole): Promise {
+ const options = { ...DEFAULT_IMPORT_OPTIONS, importAird: true, ...input.options };
+ if (!options.importAird) {
+ throw new ApiError(400, 'importAird must be enabled to import .aird content');
+ }
+
+ const model = await this.getReadableModel(input.modelId, userId);
+ const viewpoint = input.viewpointId
+ ? await this.getViewpointForModel(input.viewpointId, model, userId)
+ : undefined;
+
+ const preview = this.parseAird(input.content, model, viewpoint, options);
+ if (!preview.report.supported) {
+ throw new ApiError(400, 'Sirius .aird is not supported for import');
+ }
+ if (options.failOnUnsupportedFeatures && preview.report.droppedFeatures.length > 0) {
+ throw new ApiError(400, 'Sirius .aird contains unsupported features');
+ }
+ if (preview.diagrams.length === 0) {
+ throw new ApiError(400, 'Sirius .aird does not contain any importable diagram representations');
+ }
+
+ const created: Diagram[] = [];
+ for (const diagram of preview.diagrams) {
+ created.push(await diagramService.create({
+ name: diagram.name,
+ modelId: diagram.modelId,
+ viewpointId: diagram.viewpointId,
+ representationDescriptionId: diagram.representationDescriptionId,
+ elements: diagram.elements,
+ includedElementIds: diagram.elements.map(element => element.modelElementId),
+ }, userId, userRole));
+ }
+
+ return { diagrams: created, report: preview.report };
+ }
+
async exportOdesign(input: ExportInput, userId: string): Promise {
const options = { ...DEFAULT_EXPORT_OPTIONS, ...input.options };
if (!options.includeOdesign) {
@@ -198,6 +271,28 @@ class SiriusInteropService {
return metamodel;
}
+ private async getReadableModel(modelId: string, userId: string): Promise {
+ const model = await modelService.getById(modelId, userId);
+ if (!model) {
+ throw new ApiError(404, 'Model not found');
+ }
+ return model;
+ }
+
+ /** Resolve the target viewpoint, preferring one already attached to the model's metamodel. */
+ private async getViewpointForModel(
+ viewpointId: string,
+ model: Model | undefined,
+ userId: string
+ ): Promise {
+ const viewpoints = await viewpointService.getAll(userId, model?.metamodelId);
+ const match = viewpoints.find(viewpoint => viewpoint.id === viewpointId);
+ if (!match) {
+ throw new ApiError(404, 'Viewpoint not found for the supplied model');
+ }
+ return match;
+ }
+
private parseOdesign(
content: string,
metamodel: Metamodel | undefined,
@@ -329,29 +424,309 @@ class SiriusInteropService {
};
}
- private validateAird(content: string): SiriusOdesignPreview {
+ private parseAird(
+ content: string,
+ model: Model | undefined,
+ viewpoint: Viewpoint | undefined,
+ _options: SiriusImportOptions
+ ): SiriusAirdPreview {
const report = this.createReport('aird', 'spatialdsl');
const root = this.parseXml(content);
- const documentRoot = root.children[0];
+ const allNodes = this.walk(root);
- if (!documentRoot || !['DAnalysis', 'analysis'].includes(documentRoot.localName)) {
+ const hasAnalysis = allNodes.some(node => ['DAnalysis', 'analysis'].includes(node.localName));
+ if (!hasAnalysis) {
this.addWarning(report, 'warnings', {
severity: 'warning',
code: 'SIRIUS_AIRD_ROOT_UNEXPECTED',
- message: 'The .aird XML was parsed, but the expected Sirius DAnalysis root was not found.',
+ message: 'The .aird XML was parsed, but no Sirius DAnalysis element was found.',
+ });
+ }
+
+ if (!model) {
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'error',
+ code: 'SIRIUS_AIRD_MODEL_REQUIRED',
+ message: 'Importing a Sirius .aird view requires an already-imported SpatialDSL model to resolve semantic targets.',
});
+ this.finalizeReport(report);
+ return { diagrams: [], report };
}
- this.addWarning(report, 'droppedFeatures', {
+ if (!viewpoint) {
+ this.addWarning(report, 'warnings', {
+ severity: 'warning',
+ code: 'SIRIUS_AIRD_VIEWPOINT_NOT_SUPPLIED',
+ message: 'No viewpoint was supplied; imported views will not be linked to a representation description.',
+ });
+ }
+
+ const layoutByElementId = this.indexAirdLayout(allNodes);
+ const semanticDiagrams = allNodes.filter(node => this.isSemanticDiagram(node));
+ if (semanticDiagrams.length === 0) {
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'error',
+ code: 'SIRIUS_AIRD_NO_DIAGRAMS',
+ message: 'No DSemanticDiagram representations were found in the .aird session.',
+ });
+ }
+
+ const diagrams = semanticDiagrams.map((diagramNode, index) =>
+ this.buildAirdDiagram(diagramNode, index, model, viewpoint, layoutByElementId, report)
+ );
+
+ this.finalizeReport(report);
+ return { diagrams, report };
+ }
+
+ private buildAirdDiagram(
+ diagramNode: XmlNode,
+ index: number,
+ model: Model,
+ viewpoint: Viewpoint | undefined,
+ layoutByElementId: Map,
+ report: SiriusCompatibilityReport
+ ): SiriusAirdDiagramPreview {
+ const sourceRepresentationId = this.getSourceId(diagramNode) || this.getAttr(diagramNode, 'uid') || `diagram-${index + 1}`;
+ const name = this.getAttr(diagramNode, 'name') || `Imported View ${index + 1}`;
+ const representationDescriptionId = this.resolveRepresentationDescriptionId(diagramNode, viewpoint, report);
+
+ const ddeNodes = this.descendantsByLocal(diagramNode, 'ownedDiagramElements');
+ const usedElementIds = new Set();
+ const ddeIdToElementId = new Map();
+ const elements: DiagramElement[] = [];
+
+ // Nodes first so edges can resolve their endpoints to created element ids.
+ ddeNodes
+ .filter(dde => !this.isDiagramElementEdge(dde, viewpoint))
+ .forEach((dde, nodeIndex) => {
+ const ddeId = this.getSourceId(dde) || `node-${nodeIndex + 1}`;
+ const modelElementId = this.resolveSemanticTarget(this.getAttr(dde, 'target'), dde, model, report);
+ if (!modelElementId) return;
+
+ const elementId = this.uniqueId(this.toStableId(ddeId), usedElementIds);
+ ddeIdToElementId.set(ddeId, elementId);
+ const bounds = layoutByElementId.get(ddeId)?.bounds;
+ elements.push({
+ id: elementId,
+ type: 'node',
+ modelElementId,
+ ...(bounds && { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }),
+ style: {},
+ });
+ });
+
+ ddeNodes
+ .filter(dde => this.isDiagramElementEdge(dde, viewpoint))
+ .forEach((dde, edgeIndex) => {
+ const ddeId = this.getSourceId(dde) || `edge-${edgeIndex + 1}`;
+ const modelElementId = this.resolveSemanticTarget(this.getAttr(dde, 'target'), dde, model, report);
+ if (!modelElementId) return;
+
+ const sourceRef = this.fragmentOf(this.getAttr(dde, 'sourceNode') || this.getAttr(dde, 'source'));
+ const targetRef = this.fragmentOf(this.getAttr(dde, 'targetNode'));
+ const sourceId = sourceRef ? ddeIdToElementId.get(sourceRef) : undefined;
+ const targetId = targetRef ? ddeIdToElementId.get(targetRef) : undefined;
+ if (!sourceId || !targetId) {
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'warning',
+ code: 'SIRIUS_AIRD_EDGE_ENDPOINT_UNRESOLVED',
+ message: `Edge "${this.getAttr(dde, 'name') || ddeId}" could not resolve its source/target node and was dropped.`,
+ sourcePath: this.nodePath(dde),
+ sourceElementId: ddeId,
+ });
+ return;
+ }
+
+ const elementId = this.uniqueId(this.toStableId(ddeId), usedElementIds);
+ const points = layoutByElementId.get(ddeId)?.points;
+ elements.push({
+ id: elementId,
+ type: 'edge',
+ modelElementId,
+ sourceId,
+ targetId,
+ ...(points && points.length > 0 && { points }),
+ style: {},
+ });
+ });
+
+ return {
+ name,
+ modelId: model.id,
+ viewpointId: viewpoint?.id,
+ ...(representationDescriptionId && { representationDescriptionId }),
+ elements,
+ sourceRepresentationId,
+ };
+ }
+
+ private resolveRepresentationDescriptionId(
+ diagramNode: XmlNode,
+ viewpoint: Viewpoint | undefined,
+ report: SiriusCompatibilityReport
+ ): string | undefined {
+ if (!viewpoint) return undefined;
+ const ref = this.fragmentOf(this.getAttr(diagramNode, 'description'));
+ const descriptions = viewpoint.representationDescriptions || [];
+ if (!ref) {
+ return descriptions[0]?.id;
+ }
+
+ const normalizedRef = this.normalizeQualifiedName(ref);
+ const match = descriptions.find(description => (
+ description.id === ref
+ || description.id === this.toStableId(ref)
+ || this.normalizeQualifiedName(description.name) === normalizedRef
+ ));
+ if (match) return match.id;
+
+ this.addWarning(report, 'unresolvedReferences', {
severity: 'warning',
- code: 'SIRIUS_DEFERRED_AIRD',
- message: 'Sirius .aird session and representation data is recognized but not imported in this compatibility slice.',
- sourcePath: documentRoot ? this.nodePath(documentRoot) : undefined,
- sourceElementId: documentRoot ? this.getSourceId(documentRoot) : undefined,
+ code: 'SIRIUS_AIRD_REPRESENTATION_UNRESOLVED',
+ message: `Could not resolve Sirius diagram description "${ref}" in viewpoint "${viewpoint.name}".`,
+ sourcePath: this.nodePath(diagramNode),
+ sourceElementId: this.getSourceId(diagramNode),
});
+ return descriptions[0]?.id;
+ }
- this.finalizeReport(report);
- return { viewpoints: [], report };
+ private resolveSemanticTarget(
+ targetRef: string | undefined,
+ node: XmlNode,
+ model: Model,
+ report: SiriusCompatibilityReport
+ ): string | undefined {
+ const frag = this.fragmentOf(targetRef);
+ if (!frag) {
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'warning',
+ code: 'SIRIUS_AIRD_TARGET_MISSING',
+ message: `Diagram element "${this.getAttr(node, 'name') || this.getSourceId(node) || 'unnamed'}" has no semantic target and was dropped.`,
+ sourcePath: this.nodePath(node),
+ sourceElementId: this.getSourceId(node),
+ });
+ return undefined;
+ }
+
+ const byId = model.elements.find((element: ModelElement) => element.id === frag);
+ if (byId) return byId.id;
+
+ const name = this.getAttr(node, 'name');
+ if (name) {
+ const byName = model.elements.filter((element: ModelElement) => element.name === name);
+ if (byName.length === 1) return byName[0].id;
+ if (byName.length > 1) {
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'warning',
+ code: 'SIRIUS_AIRD_TARGET_AMBIGUOUS',
+ message: `Semantic target "${frag}" matched multiple model elements named "${name}"; the element was dropped.`,
+ sourcePath: this.nodePath(node),
+ sourceElementId: this.getSourceId(node),
+ });
+ return undefined;
+ }
+ }
+
+ this.addWarning(report, 'unresolvedReferences', {
+ severity: 'warning',
+ code: 'SIRIUS_AIRD_TARGET_UNRESOLVED',
+ message: `Could not resolve Sirius semantic target "${frag}" in model "${model.name}"; the element was dropped.`,
+ sourcePath: this.nodePath(node),
+ sourceElementId: this.getSourceId(node),
+ });
+ return undefined;
+ }
+
+ private indexAirdLayout(allNodes: XmlNode[]): Map {
+ const layout = new Map();
+ for (const node of allNodes) {
+ const type = this.getAttrByLocal(node, 'type') || node.name;
+ const isGmfNode = /notation:Node|Node$/.test(type) && node.localName !== 'ownedDiagramElements';
+ const isGmfEdge = /notation:Edge|Edge$/.test(type) && node.localName !== 'ownedDiagramElements';
+ if (!isGmfNode && !isGmfEdge) continue;
+
+ const elementRef = this.fragmentOf(this.getAttr(node, 'element'));
+ if (!elementRef) continue;
+
+ const entry = layout.get(elementRef) || {};
+ if (isGmfNode) {
+ const bounds = this.parseAirdBounds(node);
+ if (bounds) entry.bounds = bounds;
+ } else {
+ const points = this.parseAirdWaypoints(node);
+ if (points.length > 0) entry.points = points;
+ }
+ layout.set(elementRef, entry);
+ }
+ return layout;
+ }
+
+ private parseAirdBounds(gmfNode: XmlNode): AirdBounds | undefined {
+ const constraint = this.children(gmfNode).find(child => child.localName === 'layoutConstraint') || gmfNode;
+ const x = this.parseCoordinate(this.getAttr(constraint, 'x'));
+ const y = this.parseCoordinate(this.getAttr(constraint, 'y'));
+ const width = this.parseNumber(this.getAttr(constraint, 'width'));
+ const height = this.parseNumber(this.getAttr(constraint, 'height'));
+ if (x === undefined && y === undefined && width === undefined && height === undefined) {
+ return undefined;
+ }
+ return { x: x ?? 0, y: y ?? 0, width: width ?? 120, height: height ?? 80 };
+ }
+
+ private parseAirdWaypoints(gmfEdge: XmlNode): Array<{ x: number; y: number }> {
+ return this.descendantsByLocal(gmfEdge, 'points')
+ .concat(this.descendantsByLocal(gmfEdge, 'waypoints'))
+ .map(point => ({
+ x: this.parseCoordinate(this.getAttr(point, 'x')),
+ y: this.parseCoordinate(this.getAttr(point, 'y')),
+ }))
+ .filter((point): point is { x: number; y: number } => point.x !== undefined && point.y !== undefined);
+ }
+
+ private isSemanticDiagram(node: XmlNode): boolean {
+ if (node.localName === 'DSemanticDiagram') return true;
+ const type = this.getAttrByLocal(node, 'type');
+ return Boolean(type && type.includes('DSemanticDiagram'));
+ }
+
+ private isDiagramElementEdge(node: XmlNode, viewpoint: Viewpoint | undefined): boolean {
+ if (this.getAttr(node, 'sourceNode') || this.getAttr(node, 'targetNode')) return true;
+ const type = this.getAttrByLocal(node, 'type') || node.name;
+ if (/edge/i.test(type)) return true;
+
+ const mappingFrag = this.fragmentOf(this.getAttr(node, 'mapping'));
+ if (mappingFrag && viewpoint) {
+ return (viewpoint.representationDescriptions || []).some(description =>
+ (description.edgeMappings || []).some(edgeMapping => (
+ edgeMapping.id === mappingFrag
+ || edgeMapping.id === this.toStableId(mappingFrag)
+ || edgeMapping.referenceName === mappingFrag
+ ))
+ );
+ }
+ return false;
+ }
+
+ private fragmentOf(ref?: string): string | undefined {
+ if (!ref) return undefined;
+ const trimmed = ref.trim();
+ if (!trimmed) return undefined;
+ if (trimmed.includes('#')) {
+ const fragment = trimmed.slice(trimmed.lastIndexOf('#') + 1);
+ return fragment || undefined;
+ }
+ if (trimmed.startsWith('//')) {
+ // GMF structural paths (e.g. //@ownedDiagramElements.0) are not resolvable here.
+ return undefined;
+ }
+ return trimmed;
+ }
+
+ private parseCoordinate(value?: string): number | undefined {
+ if (value === undefined || value === '') return undefined;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : undefined;
}
private validateSemanticXml(content: string, sourceFormat: SiriusSourceFormat): SiriusOdesignPreview {
diff --git a/docs/reference/sirius-file-compatibility.md b/docs/reference/sirius-file-compatibility.md
index 0157db8..0d429f5 100644
--- a/docs/reference/sirius-file-compatibility.md
+++ b/docs/reference/sirius-file-compatibility.md
@@ -7,8 +7,9 @@ claim that all listed Sirius formats are implemented today.
SpatialDSL Studio currently supports partial semantic interchange through
Ecore metamodel files and EMF-style XMI model files. The backend Sirius
interoperability API also supports an initial `.odesign` validate/import/export
-subset for diagram Viewpoint Specification Models. Sirius `.aird`
-session/representation files are not implemented yet.
+subset for diagram Viewpoint Specification Models, plus an initial `.aird`
+view-import subset that turns Sirius diagram representations into SpatialDSL
+views. `.aird` export remains deferred.
## Source Concepts
@@ -69,7 +70,7 @@ segments. Exporters must generate stable relative paths.
| `.ecore` | Supported subset | Import and export single-package Ecore metamodels with named classes, attributes, references, containment, inheritance, enums, `nsURI`, and `nsPrefix`. Report unsupported annotations, custom datatype details, unresolved references, and cross-package references. |
| `.xmi` | Supported subset | Import and export semantic model resources against a matching metamodel. Preserve stable `xmi:id` values when present. Report unresolved non-containment references and any presentation data found in semantic resources. |
| `.odesign` | Supported subset | Import and export one or more Viewpoints with diagram Representation Descriptions using the subset below. Report unsupported Sirius specifier features. |
-| `.aird` | Deferred | Do not import or export session/representation data until semantic and `.odesign` compatibility are stable. Validation may detect the file and report that `.aird` handling is deferred. |
+| `.aird` | Supported import subset | Import diagram representations as SpatialDSL views, resolving semantic targets and viewpoint/representation references against an already-imported model and viewpoint, and preserving GMF notation layout. Export remains deferred. See the `.aird` subset below. |
| `.representation/*.srm` | Deferred | Detect references to lazy representation resources and report them as deferred or unresolved if the referenced file is missing. |
| project `.zip` | Supported wrapper | Treat as a relative-path bundle containing the files above. Validate paths and sizes before parsing XML. |
@@ -97,6 +98,31 @@ Simple expressions are limited to direct feature access such as
requires Java services, complex AQL/OCL evaluation, multi-step operation chains,
or runtime interpreter state is unsupported in the first implementation.
+## `.aird` Supported Import Subset
+
+`.aird` import requires the semantic model and viewpoint to be imported into
+SpatialDSL first; it resolves the session's references against them rather than
+auto-creating new resources. Each Sirius `DSemanticDiagram` becomes one
+SpatialDSL view.
+
+| Sirius Element | SpatialDSL Mapping | Compatibility |
+|---|---|---|
+| `DAnalysis` | session container | Recognized for traceability; `semanticResources` are informational. |
+| `DView` (`ownedViews`) | viewpoint context | The target viewpoint is supplied by the caller; the `viewpoint` reference is not re-resolved. |
+| `DSemanticDiagram` | View (`Diagram`) | Supported. `name` becomes the view name; `description` resolves to a representation description in the supplied viewpoint. |
+| Diagram element `target` | model element (`modelElementId`) | Resolved by `xmi:id` fragment first, then by a unique element name. Unresolved or ambiguous targets are dropped with a report entry. |
+| Node element | view node | Supported. |
+| Edge element (`sourceNode`/`targetNode`, or edge mapping) | view edge | Supported when both endpoints resolve to imported nodes; otherwise dropped with a report entry. |
+| GMF `notation:Node` `Bounds` | node `x`/`y`/`width`/`height` | Supported (layout fidelity). |
+| GMF `notation:Edge` waypoints | edge `points` | Supported when present. |
+| Container nesting, lazy `.srm`, conditional styles, filters, layers | none | Deferred or unsupported; reported, never silently imported. |
+
+Warning codes specific to `.aird` import: `SIRIUS_AIRD_MODEL_REQUIRED`,
+`SIRIUS_AIRD_NO_DIAGRAMS`, `SIRIUS_AIRD_TARGET_UNRESOLVED`,
+`SIRIUS_AIRD_TARGET_AMBIGUOUS`, `SIRIUS_AIRD_TARGET_MISSING`,
+`SIRIUS_AIRD_EDGE_ENDPOINT_UNRESOLVED`, and
+`SIRIUS_AIRD_REPRESENTATION_UNRESOLVED`.
+
## Unsupported Sirius Features
These constructs must never be silently imported or exported:
@@ -178,6 +204,7 @@ Phase 0 fixtures live in `fixtures/sirius/`.
|---|---|
| `minimal-diagram` | Small supported project with one `.ecore`, one semantic `.xmi`, one `.odesign`, and one `.aird` placeholder. |
| `unsupported-features` | Small project that intentionally includes unsupported Sirius features and an expected compatibility report. |
+| `aird-layout` | `.aird` import path: a `DSemanticDiagram` with two nodes and one edge plus GMF `notation:Diagram` layout (node `Bounds` and edge waypoints). |
Fixtures are synthetic and intentionally small. They are meant to drive parser
and compatibility-report behavior before any application code is changed.
diff --git a/fixtures/sirius/README.md b/fixtures/sirius/README.md
index c3ba228..f085273 100644
--- a/fixtures/sirius/README.md
+++ b/fixtures/sirius/README.md
@@ -19,8 +19,20 @@ fixtures/sirius/
model/sample.xmi
representations.aird
expected-report.json
+ aird-layout/
+ description/minimal.odesign
+ model/minimal.ecore
+ model/sample.xmi
+ representations.aird
+ expected-report.json
```
Use `minimal-diagram` for the first supported import/export path. Use
`unsupported-features` to verify that unsupported constructs are reported
explicitly rather than dropped silently.
+
+Use `aird-layout` for the `.aird` view import path: its `representations.aird`
+carries a `DSemanticDiagram` plus GMF `notation:Diagram` layout (node `Bounds`
+and edge waypoints). `.aird` import resolves diagram elements against an
+already-imported model and viewpoint, so import the `.ecore`/`.xmi` and
+`.odesign` first, then the `.aird`.
diff --git a/fixtures/sirius/aird-layout/description/minimal.odesign b/fixtures/sirius/aird-layout/description/minimal.odesign
new file mode 100644
index 0000000..9abc947
--- /dev/null
+++ b/fixtures/sirius/aird-layout/description/minimal.odesign
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fixtures/sirius/aird-layout/expected-report.json b/fixtures/sirius/aird-layout/expected-report.json
new file mode 100644
index 0000000..1ec200c
--- /dev/null
+++ b/fixtures/sirius/aird-layout/expected-report.json
@@ -0,0 +1,15 @@
+{
+ "sourceFormat": "aird",
+ "targetFormat": "spatialdsl",
+ "supported": true,
+ "warnings": [
+ {
+ "severity": "info",
+ "code": "SIRIUS_SYNTHETIC_FIXTURE",
+ "message": "Fixture is synthetic and covers the minimal supported .aird subset: one DSemanticDiagram, two nodes, one edge, with GMF notation layout.",
+ "sourcePath": "fixtures/sirius/aird-layout"
+ }
+ ],
+ "droppedFeatures": [],
+ "unresolvedReferences": []
+}
diff --git a/fixtures/sirius/aird-layout/model/minimal.ecore b/fixtures/sirius/aird-layout/model/minimal.ecore
new file mode 100644
index 0000000..17a232e
--- /dev/null
+++ b/fixtures/sirius/aird-layout/model/minimal.ecore
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/fixtures/sirius/aird-layout/model/sample.xmi b/fixtures/sirius/aird-layout/model/sample.xmi
new file mode 100644
index 0000000..1a764f5
--- /dev/null
+++ b/fixtures/sirius/aird-layout/model/sample.xmi
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/fixtures/sirius/aird-layout/representations.aird b/fixtures/sirius/aird-layout/representations.aird
new file mode 100644
index 0000000..319eb19
--- /dev/null
+++ b/fixtures/sirius/aird-layout/representations.aird
@@ -0,0 +1,53 @@
+
+
+
+ model/sample.xmi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/__tests__/services/sirius-interop.service.test.ts b/frontend/src/__tests__/services/sirius-interop.service.test.ts
index b92365f..0e147f2 100644
--- a/frontend/src/__tests__/services/sirius-interop.service.test.ts
+++ b/frontend/src/__tests__/services/sirius-interop.service.test.ts
@@ -9,6 +9,7 @@ jest.mock('../../services/core', () => ({
API_ENDPOINTS: {
SIRIUS_VALIDATE: '/interoperability/sirius/validate',
SIRIUS_IMPORT: '/interoperability/sirius/import',
+ SIRIUS_AIRD_IMPORT: '/interoperability/sirius/aird/import',
SIRIUS_EXPORT: '/interoperability/sirius/export',
},
}));
@@ -91,4 +92,39 @@ describe('siriusInteropService', () => {
]));
expect(result.blob.size).toBeGreaterThan(0);
});
+
+ it('validates an .aird view with the model and viewpoint context', async () => {
+ mockedApiClient.post.mockResolvedValue({
+ diagrams: [],
+ report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
+ });
+
+ await siriusInteropService.validateAird('', 'model-1', 'viewpoint-1');
+
+ expect(mockedApiClient.post).toHaveBeenCalledWith('/interoperability/sirius/validate', {
+ content: '',
+ sourceFormat: 'aird',
+ modelId: 'model-1',
+ viewpointId: 'viewpoint-1',
+ options: undefined,
+ });
+ });
+
+ it('imports an .aird view through the dedicated endpoint', async () => {
+ mockedApiClient.post.mockResolvedValue({
+ diagrams: [{ id: 'diagram-1' }],
+ report: { supported: true, warnings: [], droppedFeatures: [], unresolvedReferences: [] },
+ });
+
+ const file = { text: () => Promise.resolve('') } as unknown as File;
+ const result = await siriusInteropService.importAirdFile(file, 'model-1', 'viewpoint-1');
+
+ expect(result.diagrams).toHaveLength(1);
+ expect(mockedApiClient.post).toHaveBeenCalledWith('/interoperability/sirius/aird/import', {
+ content: '',
+ modelId: 'model-1',
+ viewpointId: 'viewpoint-1',
+ options: undefined,
+ });
+ });
});
diff --git a/frontend/src/components/viewpoints/ViewpointManager.tsx b/frontend/src/components/viewpoints/ViewpointManager.tsx
index a43f6e4..e97f1b8 100644
--- a/frontend/src/components/viewpoints/ViewpointManager.tsx
+++ b/frontend/src/components/viewpoints/ViewpointManager.tsx
@@ -51,6 +51,7 @@ import {
import { useAuth } from '../../contexts/AuthContext';
import { metamodelService } from '../../services/metamodel';
import { diagramService } from '../../services/diagram';
+import { modelService } from '../../services/model';
import viewpointService from '../../services/viewpoint.service';
import { siriusInteropService } from '../../services/interoperability';
import ColorSwatchField from '../common/ColorSwatchField';
@@ -211,6 +212,7 @@ const ViewpointManager: React.FC = () => {
const [isSiriusReportOpen, setIsSiriusReportOpen] = useState(false);
const isCreatingViewpointRef = useRef(false);
const siriusFileInputRef = useRef(null);
+ const airdFileInputRef = useRef(null);
const jsonFileInputRef = useRef(null);
const siriusFileActionRef = useRef('import');
@@ -415,6 +417,50 @@ const ViewpointManager: React.FC = () => {
}
};
+ const handleOpenAirdFilePicker = () => {
+ if (!canEditMetamodel) return;
+ airdFileInputRef.current?.click();
+ };
+
+ const handleAirdFileChange = async (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file || !metamodelId || !canEditMetamodel) return;
+
+ setIsSiriusBusy(true);
+ setSiriusStatus('');
+ setJsonImportStatus('');
+ setSiriusReport(null);
+ setError('');
+
+ try {
+ if (!selectedViewpointId) {
+ throw new Error('Select a viewpoint before importing a Sirius .aird view.');
+ }
+ const models = modelService.getModelsByMetamodelId(metamodelId);
+ if (models.length === 0) {
+ throw new Error('Import a model for this metamodel before importing a Sirius .aird view.');
+ }
+ // .aird import resolves semantic targets against an already-imported model.
+ const model = models[0];
+
+ const result = await siriusInteropService.importAirdFile(file, model.id, selectedViewpointId);
+ setSiriusReport(result.report);
+ setIsSiriusReportOpen(true);
+ const intoModel = models.length > 1 ? ` into model "${model.name}"` : '';
+ setSiriusStatus(
+ `Imported ${result.diagrams.length} Sirius view(s)${intoModel} from ${file.name}. Reopen the Views list to see them.`
+ );
+ result.diagrams.forEach(diagram => window.dispatchEvent(
+ new CustomEvent('view:changed', { detail: { diagramId: diagram.id } })
+ ));
+ } catch (error: any) {
+ setError(error.message || 'Failed to import Sirius .aird view');
+ } finally {
+ setIsSiriusBusy(false);
+ }
+ };
+
const handleStartCreateViewpoint = () => {
if (!canEditMetamodel) return;
isCreatingViewpointRef.current = true;
@@ -661,6 +707,13 @@ const ViewpointManager: React.FC = () => {
)}
Sirius Compatibility
+
+ {canEditMetamodel && (
+ }
+ disabled={isSiriusBusy || !selectedViewpointId}
+ onClick={handleOpenAirdFilePicker}
+ >
+ Import .aird View
+
+ )}
{canEditMetamodel && (