diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f20abc --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MICSS Lab, University of Antwerp + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e022c8f..d147525 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ You can: - Build code generation projects with templates - Manage access with roles and resource sharing -SpatialDSL Studio is Sirius-inspired, not currently file-compatible with Sirius Desktop projects. Ecore/XMI semantic interchange is supported with limits; Sirius `.odesign` and `.aird` import/export are not implemented yet. See [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md). +SpatialDSL Studio is Sirius-inspired, not a drop-in replacement for Sirius Desktop projects. Ecore/XMI semantic interchange is supported with limits; an initial `.odesign` validate/import/export subset covers basic diagram descriptions and returns compatibility reports, and an initial `.aird` import subset turns Sirius diagram representations into SpatialDSL views (`.aird` export is not implemented). See [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md). ## Multi-Layer Architecture @@ -75,7 +75,7 @@ The tool follows a four-layer architecture: docker compose up --build ``` -The Docker backend applies Prisma migrations before it starts. Development Docker also runs the seed script by default; set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the root `.env` if you want that seed to create or promote an admin account. +The Docker backend applies Prisma migrations before it starts. Development Docker also runs the seed script by default; set `ADMIN_EMAIL` and `ADMIN_PASSWORD` in the root `.env` if you want that seed to create or promote an admin account. The seed can also create a pre-verified demo account (no email verification step) via `DEMO_EMAIL` and `DEMO_PASSWORD`, with an optional `DEMO_ROLE` (defaults to `DSL_DESIGNER`). Seeded accounts are marked email-verified, so they work without a reachable mailbox; this is the intended path for reviewers and offline demos. Then open: @@ -128,3 +128,7 @@ For complete setup requirements (database, env vars, migrations), see the gettin - [Data Model](docs/reference/data-model.md) - [Architecture](docs/reference/architecture.md) - [Sirius Desktop Compatibility](docs/reference/sirius-compatibility.md) + +## License + +This project is licensed under the MIT License, see [LICENSE](LICENSE). diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 4972e9b..0e29473 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, UserRole } from '@prisma/client'; import bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); @@ -6,40 +6,65 @@ const prisma = new PrismaClient(); const ADMIN_EMAIL = process.env.ADMIN_EMAIL; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; -async function main() { - if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { - console.log('ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin seed.'); - return; - } +const DEMO_EMAIL = process.env.DEMO_EMAIL; +const DEMO_PASSWORD = process.env.DEMO_PASSWORD; +const DEMO_ROLE_RAW = process.env.DEMO_ROLE; +const DEMO_ROLE: UserRole = + DEMO_ROLE_RAW && Object.values(UserRole).includes(DEMO_ROLE_RAW as UserRole) + ? (DEMO_ROLE_RAW as UserRole) + : UserRole.DSL_DESIGNER; + +// Seeded accounts are pre-verified so reviewers and operators can log in +// without access to the account's mailbox. +async function seedUser(email: string, password: string, role: UserRole, label: string) { const existing = await prisma.user.findUnique({ - where: { email: ADMIN_EMAIL.toLowerCase() }, + where: { email: email.toLowerCase() }, }); if (existing) { - if (existing.role !== 'ADMIN') { + const updates: { role?: UserRole; emailVerified?: boolean } = {}; + if (existing.role !== role) updates.role = role; + if (!existing.emailVerified) updates.emailVerified = true; + + if (Object.keys(updates).length > 0) { await prisma.user.update({ where: { id: existing.id }, - data: { role: 'ADMIN' }, + data: updates, }); - console.log(`Promoted ${ADMIN_EMAIL} to ADMIN.`); + console.log(`Updated ${label} user ${email}: ${Object.keys(updates).join(', ')}.`); } else { - console.log(`${ADMIN_EMAIL} is already ADMIN.`); + console.log(`${email} is already a verified ${role}.`); } return; } - const hashedPassword = await bcrypt.hash(ADMIN_PASSWORD, 12); + const hashedPassword = await bcrypt.hash(password, 12); await prisma.user.create({ data: { - email: ADMIN_EMAIL.toLowerCase(), + email: email.toLowerCase(), password: hashedPassword, - role: 'ADMIN', + role, + emailVerified: true, }, }); - console.log(`Created admin user: ${ADMIN_EMAIL}`); + console.log(`Created ${label} user: ${email}`); +} + +async function main() { + if (ADMIN_EMAIL && ADMIN_PASSWORD) { + await seedUser(ADMIN_EMAIL, ADMIN_PASSWORD, 'ADMIN', 'admin'); + } else { + console.log('ADMIN_EMAIL or ADMIN_PASSWORD not set, skipping admin seed.'); + } + + if (DEMO_EMAIL && DEMO_PASSWORD) { + await seedUser(DEMO_EMAIL, DEMO_PASSWORD, DEMO_ROLE, 'demo'); + } else { + console.log('DEMO_EMAIL or DEMO_PASSWORD not set, skipping demo seed.'); + } } main() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 2a1044d..cd891db 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,6 +35,9 @@ services: JWT_EXPIRES_IN: 7d ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + DEMO_EMAIL: ${DEMO_EMAIL:-} + DEMO_PASSWORD: ${DEMO_PASSWORD:-} + DEMO_ROLE: ${DEMO_ROLE:-DSL_DESIGNER} RESEND_API_KEY: ${RESEND_API_KEY:-} RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@example.com} APP_URL: ${APP_URL:-https://dsl-studio.micss-lab.be} diff --git a/docker-compose.yml b/docker-compose.yml index ad6f1d7..1502e7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,9 @@ services: JWT_EXPIRES_IN: 7d ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + DEMO_EMAIL: ${DEMO_EMAIL:-} + DEMO_PASSWORD: ${DEMO_PASSWORD:-} + DEMO_ROLE: ${DEMO_ROLE:-DSL_DESIGNER} SEED_DATABASE: ${SEED_DATABASE:-true} command: ["sh", "-c", "npm run prisma:migrate:prod && if [ \"$$SEED_DATABASE\" = \"true\" ]; then npm run prisma:seed; fi && npm start"] ports: diff --git a/docs/getting-started/docker-setup.md b/docs/getting-started/docker-setup.md index 9c24fac..df5ed23 100644 --- a/docs/getting-started/docker-setup.md +++ b/docs/getting-started/docker-setup.md @@ -34,7 +34,8 @@ Set at least: Optional seed variables: - `SEED_DATABASE=true` runs the backend seed script during backend startup. Development Docker defaults this to `true`; production Docker defaults it to `false`. -- `ADMIN_EMAIL` and `ADMIN_PASSWORD` let the current seed script create or promote an admin user. If either value is missing, the seed step exits without changing data. +- `ADMIN_EMAIL` and `ADMIN_PASSWORD` let the current seed script create or promote an admin user. If either value is missing, that seed step is skipped without changing data. +- `DEMO_EMAIL` and `DEMO_PASSWORD` let the seed script create a pre-verified demo account for reviewers and offline demos. `DEMO_ROLE` picks its role (`ADMIN`, `DSL_DESIGNER`, `MODELER`, or `VIEWER`; defaults to `DSL_DESIGNER`). Seeded accounts are marked email-verified, so they can log in without receiving a verification code. ## 2) Build and start all services @@ -78,7 +79,7 @@ For production compose: docker compose -f docker-compose.prod.yml up --build ``` -Production does not run the seed script by default. To intentionally run it, set `SEED_DATABASE=true` in the production environment together with `ADMIN_EMAIL` and `ADMIN_PASSWORD`, start the backend once, then set `SEED_DATABASE=false` again for normal restarts. +Production does not run the seed script by default. To intentionally run it, set `SEED_DATABASE=true` in the production environment together with `ADMIN_EMAIL`/`ADMIN_PASSWORD` (and optionally `DEMO_EMAIL`/`DEMO_PASSWORD`), start the backend once, then set `SEED_DATABASE=false` again for normal restarts. ## 5) Verify stack health diff --git a/docs/reference/sirius-compatibility.md b/docs/reference/sirius-compatibility.md index bb25248..0d45d40 100644 --- a/docs/reference/sirius-compatibility.md +++ b/docs/reference/sirius-compatibility.md @@ -9,7 +9,7 @@ SpatialDSL Studio now uses Sirius-style terminology and supports a web specifier | Ecore metamodel import/export (`.ecore`) | Partial | Common single-package Ecore models round-trip. Cross-package references and custom datatypes are limited. | | EMF XMI model import/export (`.xmi`) | Partial | Works for semantic instance data when a matching metamodel already exists. Layout and view data are not part of semantic XMI. | | Sirius Viewpoint Specification Model (`.odesign`) import/export | Initial API slice | Backend validate/import/export endpoints cover a basic diagram subset and return compatibility reports. | -| Sirius session/representation files (`.aird`) import/export | Not implemented | SpatialDSL views are stored as app resources, not Sirius session artifacts. | +| Sirius session/representation files (`.aird`) import/export | Initial import subset | `.aird` diagram representations import as SpatialDSL views against an already-imported model and viewpoint; GMF notation layout (node bounds, edge waypoints) is preserved and unresolved references are reported. `.aird` export is not implemented; SpatialDSL views are stored as app resources, not Sirius session artifacts. | | Sirius diagram mapping parity | Partial | Visible/creatable metaclasses, notation overrides, edge styling, and pin mappings exist. Full Sirius mappings, layers, filters, conditional styles, and tool operations are not complete. | | Sirius table/tree editors | Not implemented | `table` and `tree` are reserved representation kinds. Only `diagram` is executable today. | @@ -38,10 +38,11 @@ What moves through the initial Sirius compatibility API: - Basic Sirius `.odesign` files with Viewpoints and Diagram Descriptions can be validated. - Supported node, container, bordered-node/pin, edge, basic style, and simple tool data can be imported as SpatialDSL viewpoint records. - SpatialDSL diagram representation descriptions can be exported as generated `.odesign` XML for the supported subset. +- Sirius `.aird` diagram representations can be validated and imported as SpatialDSL views: semantic targets resolve against an already-imported model (xmi:id fragment, then unique name), representation descriptions resolve against the selected viewpoint, GMF node bounds and edge waypoints are preserved, and unresolved or ambiguous references are reported rather than silently dropped. What does not move today: -- Sirius `.aird` representations do not import into SpatialDSL. +- SpatialDSL views do not export to Sirius `.aird`; table and tree representations, layers, and filters inside `.aird` files are not imported. - SpatialDSL view membership, layout, presentation overrides, pins, and bend points are not serialized into standard semantic XMI. - Full Sirius `.odesign` fidelity is not implemented; unsupported layers, filters, conditional styles, Java services, and complex tool operations are reported. @@ -65,7 +66,7 @@ Implemented or partially implemented: Not yet on par with Sirius Desktop: - full-fidelity `.odesign` import/export -- `.aird` session/representation import/export +- `.aird` export and full session fidelity (import covers a diagram-view subset) - full node/container/edge mapping editor - representation-specific creation/delete/direct-edit/reconnect tools - operation/action language @@ -80,4 +81,4 @@ Not yet on par with Sirius Desktop: Use SpatialDSL as a Sirius-inspired web modeling workbench today. Use Ecore/XMI to exchange semantic language/model data with EMF-based tooling. -Do not assume Sirius Desktop project files are fully interchangeable with SpatialDSL project data yet. Use the `.odesign` interoperability endpoints for the documented supported subset, and treat `.aird` compatibility as a future interoperability feature. +Do not assume Sirius Desktop project files are fully interchangeable with SpatialDSL project data yet. Use the `.odesign` interoperability endpoints and the `.aird` view import for the documented supported subsets, and treat fuller `.aird` compatibility (export, complete sessions) as a future interoperability feature. diff --git a/frontend/src/__tests__/services/diagram-position-writethrough.test.ts b/frontend/src/__tests__/services/diagram-position-writethrough.test.ts new file mode 100644 index 0000000..10d45d5 --- /dev/null +++ b/frontend/src/__tests__/services/diagram-position-writethrough.test.ts @@ -0,0 +1,181 @@ +import { Model } from '../../models/types'; + +jest.mock('../../services/diagram/diagram-crud.service', () => ({ + diagramCrudService: { + getDiagramById: jest.fn(), + getDiagramsRef: jest.fn(() => []) + } +})); + +jest.mock('../../services/diagram/view-projection.service', () => ({ + viewProjectionService: { + getIncludedElementIds: jest.fn(() => []) + } +})); + +jest.mock('../../services/model', () => ({ + modelService: { + getModelById: jest.fn(), + updateModelElementPresentation: jest.fn(() => true), + updateModelElementProperties: jest.fn(() => true) + } +})); + +jest.mock('../../services/diagram/diagram-api-sync.service', () => ({ + diagramApiSyncService: { + updateModelElementPresentation: jest.fn(() => + Promise.resolve({ id: 'diagram-1', elements: [] }) + ) + } +})); + +import { diagramService } from '../../services/diagram/diagram.service'; +import { diagramCrudService } from '../../services/diagram/diagram-crud.service'; +import { viewProjectionService } from '../../services/diagram/view-projection.service'; +import { modelService } from '../../services/model'; +import { diagramApiSyncService } from '../../services/diagram/diagram-api-sync.service'; + +const diagram = { id: 'diagram-1', modelId: 'model-1', elements: [] }; + +const makeModel = (presentation?: Record): Model => + ({ + id: 'model-1', + name: 'Warehouse', + metamodelId: 'mm-1', + conformsTo: 'mm-1', + elements: [ + { + id: 'el-1', + modelElementId: 'class-rack', + style: { name: 'Rack A' }, + references: {}, + presentation + } + ] + } as Model); + +beforeEach(() => { + (diagramCrudService.getDiagramById as jest.Mock).mockReturnValue(diagram); + (diagramCrudService.getDiagramsRef as jest.Mock).mockReturnValue([]); + (viewProjectionService.getIncludedElementIds as jest.Mock).mockReturnValue(['el-1']); + (modelService.updateModelElementPresentation as jest.Mock).mockReturnValue(true); + (modelService.updateModelElementProperties as jest.Mock).mockReturnValue(true); + (diagramApiSyncService.updateModelElementPresentation as jest.Mock).mockResolvedValue({ + id: 'diagram-1', + elements: [] + }); +}); + +describe('2D/3D position write-through', () => { + describe('updateElement (3D editor persist path)', () => { + it('mirrors a 3D move to position2D with the same values', () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 } }) + ); + + diagramService.updateElement('diagram-1', 'el-1', { + style: { position3D: { x: 500, y: 700 } } + }); + + expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith( + 'model-1', + 'el-1', + expect.objectContaining({ + position3D: { x: 500, y: 700 }, + position2D: { x: 500, y: 700 } + }) + ); + }); + + it('mirrors a 2D x/y move to position3D when the element already has a world-space position', () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } }) + ); + + diagramService.updateElement('diagram-1', 'el-1', { x: 30, y: 40 }); + + expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith( + 'model-1', + 'el-1', + expect.objectContaining({ + position2D: { x: 30, y: 40 }, + position3D: { x: 30, y: 40 } + }) + ); + }); + + it('does not create position3D for elements without a world-space position', () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 } }) + ); + + diagramService.updateElement('diagram-1', 'el-1', { x: 30, y: 40 }); + + const persisted = (modelService.updateModelElementPresentation as jest.Mock).mock + .calls[0][2]; + expect(persisted.position2D).toEqual({ x: 30, y: 40 }); + expect(persisted.position3D).toBeUndefined(); + }); + }); + + describe('updateModelElementPresentationInView (2D editor persist path)', () => { + it('mirrors a 2D move to position3D when the element already has a world-space position', async () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } }) + ); + + await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', { + position2D: { x: 10, y: 20 } + }); + + const expected = expect.objectContaining({ + position2D: { x: 10, y: 20 }, + position3D: { x: 10, y: 20 } + }); + expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith( + 'model-1', + 'el-1', + expected + ); + expect(diagramApiSyncService.updateModelElementPresentation).toHaveBeenCalledWith( + 'diagram-1', + 'el-1', + expected + ); + }); + + it('leaves pure 2D notations untouched (no position3D is ever created)', async () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 } }) + ); + + await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', { + position2D: { x: 10, y: 20 } + }); + + const persisted = (modelService.updateModelElementPresentation as jest.Mock).mock + .calls[0][2]; + expect(persisted.position2D).toEqual({ x: 10, y: 20 }); + expect(persisted.position3D).toBeUndefined(); + }); + + it('mirrors a 3D-only presentation update back to position2D', async () => { + (modelService.getModelById as jest.Mock).mockReturnValue( + makeModel({ position2D: { x: 1, y: 2 }, position3D: { x: 1, y: 2 } }) + ); + + await diagramService.updateModelElementPresentationInView('diagram-1', 'el-1', { + position3D: { x: 900, y: 450 } + }); + + expect(modelService.updateModelElementPresentation).toHaveBeenCalledWith( + 'model-1', + 'el-1', + expect.objectContaining({ + position3D: { x: 900, y: 450 }, + position2D: { x: 900, y: 450 } + }) + ); + }); + }); +}); diff --git a/frontend/src/__tests__/services/js-sandbox-presentation.test.ts b/frontend/src/__tests__/services/js-sandbox-presentation.test.ts new file mode 100644 index 0000000..557ce50 --- /dev/null +++ b/frontend/src/__tests__/services/js-sandbox-presentation.test.ts @@ -0,0 +1,131 @@ +import { + prepareContextForJS, + createJSSandbox, + evaluateInSandbox +} from '../../services/constraint/js/js.sandbox'; +import { Model, ModelElement, Metamodel } from '../../models/types'; + +const metamodel = { + id: 'mm-1', + name: 'WarehouseMM', + classes: [ + { id: 'class-rack', name: 'Rack', attributes: [], references: [], constraints: [] } + ], + constraints: [] +} as unknown as Metamodel; + +const placedElement: ModelElement = { + id: 'el-1', + modelElementId: 'class-rack', + style: { name: 'Rack A' }, + references: {}, + presentation: { + position2D: { x: 120, y: 80 }, + position3D: { x: 1200, y: 800 }, + size3D: { widthMm: 1000, heightMm: 400, depthMm: 2000 }, + rotationZ: 90 + } +}; + +const unplacedElement: ModelElement = { + id: 'el-2', + modelElementId: 'class-rack', + style: { name: 'Rack B' }, + references: {} +}; + +const makeModel = (elements: ModelElement[]): Model => + ({ + id: 'model-1', + name: 'Warehouse', + metamodelId: 'mm-1', + conformsTo: 'mm-1', + elements + } as Model); + +describe('prepareContextForJS placement exposure', () => { + it('leaves spatial fields undefined for elements without a presentation record', () => { + const context = prepareContextForJS(unplacedElement, makeModel([unplacedElement]), metamodel); + + expect(context.self.position3D).toBeUndefined(); + expect(context.self.position2D).toBeUndefined(); + expect(context.self.name).toBe('Rack B'); + }); + + it('exposes the presentation record on self', () => { + const context = prepareContextForJS(placedElement, makeModel([placedElement]), metamodel); + + expect(context.self.position3D).toEqual({ x: 1200, y: 800 }); + expect(context.self.position2D).toEqual({ x: 120, y: 80 }); + expect(context.self.size3D).toEqual({ widthMm: 1000, heightMm: 400, depthMm: 2000 }); + expect(context.self.rotationZ).toBe(90); + }); + + it('gives style values precedence over presentation values', () => { + const overridden: ModelElement = { + ...placedElement, + style: { name: 'Rack A', position3D: { x: 5, y: 6 } } + }; + const context = prepareContextForJS(overridden, makeModel([overridden]), metamodel); + + expect(context.self.position3D).toEqual({ x: 5, y: 6 }); + }); + + it('exposes placement on peer elements via model.elements', () => { + const context = prepareContextForJS( + unplacedElement, + makeModel([unplacedElement, placedElement]), + metamodel + ); + + const peer = context.model.elements.find((e: any) => e.id === 'el-1'); + expect(peer.position3D).toEqual({ x: 1200, y: 800 }); + expect(peer.name).toBe('Rack A'); + }); + + it('exposes placement on referenced elements', () => { + const referencing: ModelElement = { + id: 'el-3', + modelElementId: 'class-rack', + style: { name: 'Rack C' }, + references: { neighbor: 'el-1', shelves: ['el-1'] } + }; + const context = prepareContextForJS( + referencing, + makeModel([referencing, placedElement]), + metamodel + ); + + expect(context.self.neighbor.position3D).toEqual({ x: 1200, y: 800 }); + expect(context.self.shelves[0].position3D).toEqual({ x: 1200, y: 800 }); + }); + + it('evaluates a spatial constraint against real placement values end-to-end', () => { + const context = prepareContextForJS(placedElement, makeModel([placedElement]), metamodel); + const sandbox = createJSSandbox(context, makeModel([placedElement])); + + const result = evaluateInSandbox( + 'self.position3D && self.position3D.x >= 0 && self.position3D.y >= 0', + sandbox + ); + + expect(result).toBe(true); + }); + + it('fails a spatial constraint for out-of-bounds placement', () => { + const outOfBounds: ModelElement = { + ...placedElement, + id: 'el-4', + presentation: { position3D: { x: -50, y: 800 } } + }; + const context = prepareContextForJS(outOfBounds, makeModel([outOfBounds]), metamodel); + const sandbox = createJSSandbox(context, makeModel([outOfBounds])); + + const result = evaluateInSandbox( + '!self.position3D || (self.position3D.x >= 0 && self.position3D.y >= 0)', + sandbox + ); + + expect(result).not.toBe(true); + }); +}); diff --git a/frontend/src/examples/data/smart-warehouse-project.json b/frontend/src/examples/data/smart-warehouse-project.json index 745b3cc..96c13eb 100644 --- a/frontend/src/examples/data/smart-warehouse-project.json +++ b/frontend/src/examples/data/smart-warehouse-project.json @@ -40,7 +40,7 @@ "id": "40000000-0000-4000-8000-000000000205", "name": "ConveyorLocation", "language": "java", - "templateContent": "[\n{{#each elementsByClassName.Conveyor}}\n {\n \"Name\": \"{{name}}\",\n \"X\": {{X}},\n \"Y\": {{Y}},\n \"Rz\": {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n }{{#unless @last}},{{/unless}}\n{{/each}}\n]\n", + "templateContent": "[\n{{#each elementsByClassName.Conveyor}}\n {\n \"Name\": \"{{name}}\",\n \"X\": {{X}},\n \"Y\": {{Y}},\n \"Rz\": {{#unless RZ}}0{{else}}{{RZ}}{{/unless}},\n \"Length\": {{Length}},\n \"Width\": {{Width}},\n \"Height\": {{Height}}\n }{{#unless @last}},{{/unless}}\n{{/each}}\n]\n", "targetMetamodelId": "10000000-0000-4000-8000-000000000100", "outputPattern": "ConveyorLocation.json" }, diff --git a/frontend/src/examples/data/warehouse-project.json b/frontend/src/examples/data/warehouse-project.json index 6c39e81..aaa0b4c 100644 --- a/frontend/src/examples/data/warehouse-project.json +++ b/frontend/src/examples/data/warehouse-project.json @@ -40,7 +40,7 @@ "id": "fc608b1f-ffaa-4dd3-8193-588e9b86a54e", "name": "ConveyorLocation", "language": "java", - "templateContent": "[\n{{#each elementsByClassName.Conveyor}}\n {\n \"Name\": \"{{name}}\",\n \"X\": {{X}},\n \"Y\": {{Y}},\n \"Rz\": {{#unless RZ}}0{{else}}{{RZ}}{{/unless}}\n }{{#unless @last}},{{/unless}}\n{{/each}}\n]\n", + "templateContent": "[\n{{#each elementsByClassName.Conveyor}}\n {\n \"Name\": \"{{name}}\",\n \"X\": {{X}},\n \"Y\": {{Y}},\n \"Rz\": {{#unless RZ}}0{{else}}{{RZ}}{{/unless}},\n \"Length\": {{Length}},\n \"Width\": {{Width}},\n \"Height\": {{Height}}\n }{{#unless @last}},{{/unless}}\n{{/each}}\n]\n", "targetMetamodelId": "225d08e9-881b-46d3-9dfc-293c0c7800df", "outputPattern": "ConveyorLocation.json" }, diff --git a/frontend/src/examples/js-constraint-examples.ts b/frontend/src/examples/js-constraint-examples.ts index fd03415..f29a5d8 100644 --- a/frontend/src/examples/js-constraint-examples.ts +++ b/frontend/src/examples/js-constraint-examples.ts @@ -226,6 +226,45 @@ return true; ); }; +/** + * Example 6: Spatial Placement Validation + * + * This constraint reads the element's persisted placement record + * (exposed on self alongside its attribute values) and checks that + * every placed element has a world-space position inside the + * warehouse bounds. Spatial data such as position2D, position3D, + * size3D and rotationZ are available to constraints directly. + */ +export const createSpatialPlacementConstraint = ( + metamodelId: string, + contextClassId: string +): JSConstraint | null => { + const name = 'Placement Inside Warehouse Bounds'; + const expression = ` +// Elements that have not been placed in world space yet are skipped +if (!self.position3D) { + return true; +} + +// The world-space position must lie inside the warehouse bounds +if (self.position3D.x < 0 || self.position3D.y < 0) { + return { valid: false, message: "Element '" + (self.name || self.id) + "' is placed outside the warehouse bounds at (" + self.position3D.x + ", " + self.position3D.y + ")" }; +} + +return true; +`; + const description = 'Validates that every placed element has a world-space position inside the warehouse bounds, using the spatial placement data exposed to constraints'; + + return jsService.createConstraint( + metamodelId, + contextClassId, + name, + expression, + description, + 'error' + ); +}; + /** * Helper function to run all examples */ @@ -238,8 +277,9 @@ export const createAllExampleConstraints = ( createCollectionConstraint(metamodelId, contextClassId), createDateConstraint(metamodelId, contextClassId), createConditionalConstraint(metamodelId, contextClassId), - createUtilityConstraint(metamodelId, contextClassId) + createUtilityConstraint(metamodelId, contextClassId), + createSpatialPlacementConstraint(metamodelId, contextClassId) ]; - + return constraints.filter(c => c !== null) as JSConstraint[]; -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/frontend/src/services/constraint/js/js.sandbox.ts b/frontend/src/services/constraint/js/js.sandbox.ts index 159d609..d209168 100644 --- a/frontend/src/services/constraint/js/js.sandbox.ts +++ b/frontend/src/services/constraint/js/js.sandbox.ts @@ -12,9 +12,13 @@ export function prepareContextForJS( model: Model, metamodel: Metamodel ): Record { - // Create a clean context + // Create a clean context. + // Placement data (position2D/position3D/size3D/rotationZ/attachment fields) lives in + // element.presentation; expose it to constraints with style taking precedence, mirroring + // the codegen context builder. const context: Record = { self: { + ...element.presentation, ...element.style, id: element.id, type: element.modelElementId @@ -23,9 +27,10 @@ export function prepareContextForJS( id: model.id, name: model.name, elements: model.elements.map(e => ({ + ...e.presentation, + ...e.style, id: e.id, - type: e.modelElementId, - ...e.style + type: e.modelElementId })) }, metamodel: { @@ -44,6 +49,7 @@ export function prepareContextForJS( const refElement = model.elements.find(e => e.id === refId); if (refElement) { return { + ...refElement.presentation, ...refElement.style, id: refElement.id, type: refElement.modelElementId @@ -56,6 +62,7 @@ export function prepareContextForJS( const refElement = model.elements.find(e => e.id === refValue); if (refElement) { context.self[refName] = { + ...refElement.presentation, ...refElement.style, id: refElement.id, type: refElement.modelElementId diff --git a/frontend/src/services/constraint/ocl/ocl.context.ts b/frontend/src/services/constraint/ocl/ocl.context.ts index edf4886..1ac3b22 100644 --- a/frontend/src/services/constraint/ocl/ocl.context.ts +++ b/frontend/src/services/constraint/ocl/ocl.context.ts @@ -19,8 +19,10 @@ export function prepareContextForOCL( throw new Error(`MetaClass not found for element ${element.id}`); } - // Create the base context with type information and attribute values + // Create the base context with type information and attribute values. + // Placement data from element.presentation is exposed too, with style taking precedence. const context: any = { + ...element.presentation, ...element.style, id: element.id, _type: metaClass.name, // Used by the typeDeterminer function diff --git a/frontend/src/services/diagram/diagram.service.ts b/frontend/src/services/diagram/diagram.service.ts index 869bb05..213bc6b 100644 --- a/frontend/src/services/diagram/diagram.service.ts +++ b/frontend/src/services/diagram/diagram.service.ts @@ -228,6 +228,35 @@ class DiagramService { ); } + /** + * Spatial languages persist one position per projection (2D canvas vs 3D world) that + * share the same millimeter ground-plane values. Keep the two fields in sync: a 3D move + * always mirrors to position2D, while a 2D move mirrors to position3D only when the + * element already carries a world-space position, so pure 2D notations never gain one. + */ + private applyPositionWriteThrough( + modelElement: ModelElement | undefined, + presentation: ModelElementPresentation + ): ModelElementPresentation { + if (presentation.position3D && !presentation.position2D) { + return { + ...presentation, + position2D: { x: presentation.position3D.x, y: presentation.position3D.y }, + }; + } + if ( + presentation.position2D && + !presentation.position3D && + modelElement?.presentation?.position3D + ) { + return { + ...presentation, + position3D: { x: presentation.position2D.x, y: presentation.position2D.y }, + }; + } + return presentation; + } + updateElement(diagramId: string, elementId: string, updates: Partial): boolean { const diagram = diagramCrudService.getDiagramById(diagramId); if (!diagram) return false; @@ -292,11 +321,12 @@ class DiagramService { ...modelStyleUpdates } = styleUpdates; - const hasPresentationChanges = Object.keys(presentation).length > 0; + const syncedPresentation = this.applyPositionWriteThrough(modelElement, presentation); + const hasPresentationChanges = Object.keys(syncedPresentation).length > 0; const hasStyleChanges = Object.keys(modelStyleUpdates).length > 0; if (hasPresentationChanges) { - modelService.updateModelElementPresentation(diagram.modelId, elementId, presentation); + modelService.updateModelElementPresentation(diagram.modelId, elementId, syncedPresentation); } if (hasStyleChanges) { modelService.updateModelElementProperties(diagram.modelId, elementId, modelStyleUpdates); @@ -372,10 +402,15 @@ class DiagramService { const diagram = diagramCrudService.getDiagramById(diagramId); if (!diagram) return false; + const modelElement = modelService + .getModelById(diagram.modelId) + ?.elements.find(element => element.id === modelElementId); + const syncedPresentation = this.applyPositionWriteThrough(modelElement, presentation); + const updatedLocally = modelService.updateModelElementPresentation( diagram.modelId, modelElementId, - presentation + syncedPresentation ); if (!updatedLocally) return false; @@ -383,7 +418,7 @@ class DiagramService { const updatedDiagram = await diagramApiSyncService.updateModelElementPresentation( diagramId, modelElementId, - presentation + syncedPresentation ); const diagrams = diagramCrudService.getDiagramsRef(); const diagramIndex = diagrams.findIndex(candidate => candidate.id === updatedDiagram.id);