From f9b7a20f2fce4816fb1f47fd8147e6de2ae27328 Mon Sep 17 00:00:00 2001 From: Ha Phan Tran Date: Tue, 23 Jun 2026 18:51:31 -0400 Subject: [PATCH] Add Sirius .aird view import (Phase 4) Import Sirius .aird session/representation files into SpatialDSL views. Each DSemanticDiagram becomes a view whose elements resolve against an already-imported model (semantic targets by xmi:id fragment, then unique name) and viewpoint (representation description by id/name). GMF notation:Diagram layout is preserved: node Bounds map to element x/y/width/height and edge waypoints map to points. Unresolved or ambiguous references are reported, never silently dropped. Backend: parseAird/importAird in the Sirius interop service, a POST /sirius/aird/import route, and aird dispatch in /sirius/validate. Frontend: validateAird/importAird service methods plus an "Import .aird View" action in the Viewpoint manager (resolves the metamodel's model and the selected viewpoint). Adds an aird-layout fixture with GMF notation, shared SiriusAird* types, and backend/frontend tests. Updates docs/reference/sirius-file-compatibility.md and the parity roadmap for the new .aird import subset. --- .../routes/interoperability.routes.test.ts | 47 ++ .../services/sirius-interop.service.test.ts | 223 +++++++++- backend/src/routes/interoperability.routes.ts | 20 +- .../src/services/sirius-interop.service.ts | 405 +++++++++++++++++- docs/reference/sirius-file-compatibility.md | 33 +- fixtures/sirius/README.md | 12 + .../aird-layout/description/minimal.odesign | 49 +++ .../sirius/aird-layout/expected-report.json | 15 + .../sirius/aird-layout/model/minimal.ecore | 33 ++ fixtures/sirius/aird-layout/model/sample.xmi | 10 + .../sirius/aird-layout/representations.aird | 53 +++ .../services/sirius-interop.service.test.ts | 36 ++ .../viewpoints/ViewpointManager.tsx | 64 +++ frontend/src/models/types.ts | 19 + frontend/src/services/core/api.client.ts | 1 + .../sirius-interop.service.ts | 39 ++ shared/types/index.d.ts | 16 + shared/types/index.ts | 23 + sirius-desktop-parity-roadmap.md | 5 +- 19 files changed, 1076 insertions(+), 27 deletions(-) create mode 100644 fixtures/sirius/aird-layout/description/minimal.odesign create mode 100644 fixtures/sirius/aird-layout/expected-report.json create mode 100644 fixtures/sirius/aird-layout/model/minimal.ecore create mode 100644 fixtures/sirius/aird-layout/model/sample.xmi create mode 100644 fixtures/sirius/aird-layout/representations.aird 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 @@ + + + + + + +