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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ dev = [
"ruff>=0.6.0",
"black>=24.3.0",
"mypy>=1.8.0",
# Validates the shared canonical `2.0` layout schema (drift guard with optiverse-web).
"jsonschema>=4.0",
]

[project.urls]
Expand Down
147 changes: 147 additions & 0 deletions schema/optiverse-2.0.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://optiverse.dev/schema/optiverse-2.0.schema.json",
"title": "Optiverse Layout 2.0",
"description": "The canonical Optiverse project layout format shared by the desktop app and the web project-zip interchange. This schema is the drift guard: both repos validate the same golden fixtures against it in CI so the two apps cannot silently diverge.",
"type": "object",
"required": ["version", "items"],
"properties": {
"version": { "const": "2.0" },
"items": { "type": "array", "items": { "$ref": "#/$defs/item" } },
"rulers": { "type": "array", "items": { "$ref": "#/$defs/ruler" } },
"texts": { "type": "array", "items": { "$ref": "#/$defs/text" } },
"angle_measures": {
"description": "Optiverse-web interchange EXTENSION (not emitted by the desktop app, which does not persist angle measurements). Carried so web angle annotations survive a web -> zip -> web round-trip; the desktop ignores this key.",
"type": "array",
"items": { "$ref": "#/$defs/angle_measure" }
},
"rectangles": { "type": "array" },
"path_measures": { "type": "array" },
"layer_state": { "$ref": "#/$defs/layer_state" }
},
"$defs": {
"item": {
"type": "object",
"required": ["_type"],
"properties": {
"_type": { "enum": ["component", "source"] },
"x_mm": { "type": "number" },
"y_mm": { "type": "number" },
"angle_deg": { "type": "number" },
"item_uuid": { "type": "string" },
"z_value": { "type": "number" }
},
"allOf": [
{
"if": { "properties": { "_type": { "const": "component" } } },
"then": {
"required": ["x_mm", "y_mm", "angle_deg", "image_path", "interfaces"],
"properties": {
"image_path": { "type": "string" },
"object_height_mm": { "type": "number" },
"interfaces": { "type": "array", "items": { "$ref": "#/$defs/interface" } }
}
}
},
{
"if": { "properties": { "_type": { "const": "source" } } },
"then": {
"required": ["x_mm", "y_mm", "angle_deg"],
"properties": {
"wavelength_nm": { "type": "number" },
"n_rays": { "type": "number" }
}
}
}
]
},
"interface": {
"type": "object",
"required": ["element_type", "x1_mm", "y1_mm", "x2_mm", "y2_mm"],
"properties": {
"element_type": {
"enum": [
"beam_block",
"lens",
"mirror",
"beam_splitter",
"dichroic",
"polarizing_interface",
"polarizer",
"waveplate",
"pbs",
"detector",
"refractive",
"faraday"
]
},
"x1_mm": { "type": "number" },
"y1_mm": { "type": "number" },
"x2_mm": { "type": "number" },
"y2_mm": { "type": "number" },
"name": { "type": "string" },
"efl_mm": { "type": "number" },
"reflectivity": { "type": "number" },
"clear_aperture_mm": { "type": "number" },
"split_T": { "type": "number" },
"split_R": { "type": "number" },
"is_polarizing": { "type": "boolean" },
"pbs_transmission_axis_deg": { "type": "number" }
}
},
"ruler": {
"type": "object",
"required": ["type", "points"],
"properties": {
"type": { "const": "ruler" },
"points": {
"type": "array",
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2 }
},
"item_uuid": { "type": "string" }
}
},
"angle_measure": {
"type": "object",
"required": ["type", "vertex", "point1", "point2"],
"properties": {
"type": { "const": "angle_measure" },
"vertex": { "type": "array", "items": { "type": "number" }, "minItems": 2 },
"point1": { "type": "array", "items": { "type": "number" }, "minItems": 2 },
"point2": { "type": "array", "items": { "type": "number" }, "minItems": 2 },
"item_uuid": { "type": "string" }
}
},
"text": {
"type": "object",
"required": ["type", "text", "x", "y"],
"properties": {
"type": { "const": "text" },
"text": { "type": "string" },
"x": { "type": "number" },
"y": { "type": "number" },
"point_size": { "type": "number" },
"item_uuid": { "type": "string" }
}
},
"layer_state": {
"type": "object",
"required": ["nodes"],
"properties": {
"version": { "type": "integer" },
"nodes": { "type": "array", "items": { "$ref": "#/$defs/node" } }
}
},
"node": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["group", "item"] },
"uuid": { "type": "string" },
"name": { "type": "string" },
"collapsed": { "type": "boolean" },
"children": { "type": "array", "items": { "$ref": "#/$defs/node" } }
}
}
}
}
128 changes: 128 additions & 0 deletions tests/fixtures/minimal_layout_2_0.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
{
"version": "2.0",
"items": [
{
"_type": "source",
"x_mm": -100.0,
"y_mm": 0.0,
"angle_deg": 0.0,
"size_mm": 5.0,
"n_rays": 9,
"ray_length_mm": 600.0,
"spread_deg": 0.0,
"color_hex": "#22cc55",
"wavelength_nm": 780.0,
"polarization_type": "horizontal",
"polarization_angle_deg": 0.0,
"source_type": "ray",
"beam_waist_mm": 0.5,
"item_uuid": "src-0001",
"locked": false,
"z_value": 0.0
},
{
"_type": "component",
"x_mm": 0.0,
"y_mm": 0.0,
"angle_deg": 45.0,
"object_height_mm": 25.4,
"mm_per_pixel": 0.05,
"name": "Beamsplitter 50/50",
"image_path": "@component/bs_5050/images/bs_5050.png",
"reference_line_mm": [-12.7, 0.0, 12.7, 0.0],
"interfaces": [
{
"x1_mm": -12.7,
"y1_mm": 0.0,
"x2_mm": 12.7,
"y2_mm": 0.0,
"element_type": "beam_splitter",
"name": "",
"split_T": 50.0,
"split_R": 50.0,
"is_polarizing": false
}
],
"category": "beamsplitters",
"notes": "",
"item_uuid": "cmp-0001",
"locked": false,
"z_value": 1.0
},
{
"_type": "component",
"x_mm": 120.0,
"y_mm": 0.0,
"angle_deg": 90.0,
"object_height_mm": 25.4,
"mm_per_pixel": 0.05,
"name": "Mirror",
"image_path": "@component/flat_mirror/images/flat_mirror.png",
"reference_line_mm": [-12.7, 0.0, 12.7, 0.0],
"interfaces": [
{
"x1_mm": -12.7,
"y1_mm": 0.0,
"x2_mm": 12.7,
"y2_mm": 0.0,
"element_type": "mirror",
"name": "",
"reflectivity": 100.0
}
],
"category": "mirrors",
"notes": "",
"item_uuid": "cmp-0002",
"locked": false,
"z_value": 2.0
}
],
"rulers": [
{
"type": "ruler",
"points": [[-100.0, 0.0], [0.0, 0.0]],
"item_uuid": "rul-0001",
"z_value": 0.0
}
],
"texts": [
{
"type": "text",
"text": "Input beam",
"x": -90.0,
"y": -10.0,
"color": "#0a0a28",
"point_size": 11.0,
"item_uuid": "txt-0001",
"z_value": 0.0
}
],
"angle_measures": [
{
"type": "angle_measure",
"vertex": [0.0, 0.0],
"point1": [120.0, 0.0],
"point2": [0.0, 120.0],
"item_uuid": "ang-0001",
"z_value": 0.0
}
],
"rectangles": [],
"path_measures": [],
"layer_state": {
"version": 1,
"nodes": [
{
"uuid": "grp-0001",
"type": "group",
"name": "Beam path",
"collapsed": false,
"children": [
{ "uuid": "src-0001", "type": "item" },
{ "uuid": "cmp-0001", "type": "item" }
]
},
{ "uuid": "cmp-0002", "type": "item" }
]
}
}
47 changes: 47 additions & 0 deletions tests/test_layout_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Drift guard: the canonical `2.0` layout schema (shared with optiverse-web).

The desktop app is the source of the `2.0` project file format. This test pins it
against the same JSON Schema + golden fixture that optiverse-web validates in its
own CI, so the two apps cannot silently diverge. Keep
``schema/optiverse-2.0.schema.json`` and ``tests/fixtures/minimal_layout_2_0.json``
byte-identical to the optiverse-web copies.
"""

from __future__ import annotations

import importlib.util
import json
from pathlib import Path

import pytest

_REPO = Path(__file__).resolve().parents[1]
_SCHEMA = _REPO / "schema" / "optiverse-2.0.schema.json"
_FIXTURE = _REPO / "tests" / "fixtures" / "minimal_layout_2_0.json"
_CONSTANTS = _REPO / "src" / "optiverse" / "core" / "constants.py"


def _file_format_version() -> str:
"""Read FILE_FORMAT_VERSION straight from the (import-free) constants module.

Loading by file path avoids dragging in the Qt-heavy package import chain, so
the drift guard runs in any environment that has the source tree.
"""
spec = importlib.util.spec_from_file_location("optiverse_constants", _CONSTANTS)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.FILE_FORMAT_VERSION


def test_file_format_version_matches_schema() -> None:
schema = json.loads(_SCHEMA.read_text())
assert schema["properties"]["version"]["const"] == _file_format_version()


def test_golden_fixture_matches_schema() -> None:
jsonschema = pytest.importorskip("jsonschema")
jsonschema.validate(
instance=json.loads(_FIXTURE.read_text()),
schema=json.loads(_SCHEMA.read_text()),
)
Loading