passerelle-geo-components 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1000 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: scene_manifest.schema.json
3
+ # timestamp: 2026-07-10T13:35:15+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from enum import Enum
8
+ from typing import Any, Literal
9
+ from uuid import UUID
10
+
11
+ from pydantic import (
12
+ AnyUrl,
13
+ AwareDatetime,
14
+ BaseModel,
15
+ ConfigDict,
16
+ Field,
17
+ RootModel,
18
+ constr,
19
+ )
20
+
21
+
22
+ class Projection(RootModel[str]):
23
+ root: str = Field(
24
+ "mercator",
25
+ description="Projection cartographique. 'mercator' (défaut), 'globe' (MapLibre v5+), 'lambert93' (via adapter proj-lambert93). Enum ouverte : accepte x_<projet>_* avec fallback graceful 'mercator'.",
26
+ pattern="^x_[a-z_]+$",
27
+ title="Extension nommée par projet",
28
+ )
29
+
30
+
31
+ class Unit(Enum):
32
+ metric = "metric"
33
+ imperial = "imperial"
34
+ nautical = "nautical"
35
+
36
+
37
+ class Producer(RootModel[str]):
38
+ root: str = Field(
39
+ ...,
40
+ description="Producteur du manifest. Enum ouverte : accepte x_<projet>_* avec fallback graceful 'manual'.",
41
+ pattern="^x_[a-z_]+$",
42
+ )
43
+
44
+
45
+ class RecipeUsed(BaseModel):
46
+ model_config = ConfigDict(
47
+ extra="forbid",
48
+ )
49
+ slug: str | None = None
50
+ version_sha: str | None = None
51
+
52
+
53
+ class Provenance(BaseModel):
54
+ model_config = ConfigDict(
55
+ extra="allow",
56
+ )
57
+ producer: Literal["qgis-sspcloud"] | Literal["cerema-livrables"] | Literal[
58
+ "atlas"
59
+ ] | Literal["manual"] | Producer = Field(
60
+ ...,
61
+ description="Producteur du manifest. Enum ouverte : accepte x_<projet>_* avec fallback graceful 'manual'.",
62
+ )
63
+ producer_version: str
64
+ source_project: str | None = Field(
65
+ None, description="Chemin projet source (ex: .qgz path)."
66
+ )
67
+ qgz_hash: str | None = Field(None, pattern="^sha256:[0-9a-f]{64}$")
68
+ agent_conversation_id: str | None = Field(
69
+ None, description="ID conversation LLM si issu d'un agent."
70
+ )
71
+ recipe_used: RecipeUsed | None = None
72
+ extensions: dict[
73
+ constr(pattern=r"^[a-z][a-z_0-9]*$"), dict[str, Any]
74
+ ] | None = Field(
75
+ None,
76
+ description="Extensions provenance par-projet (ex: atlas.native_payload_hash pour round-trip fidèle).",
77
+ )
78
+
79
+
80
+ class Id(RootModel[str]):
81
+ root: str = Field(
82
+ ...,
83
+ description="ID du fond de carte. Enum ouverte : accepte x_<projet>_* avec fallback graceful 'osm'.",
84
+ pattern="^x_[a-z_]+$",
85
+ title="Extension nommée par projet",
86
+ )
87
+
88
+
89
+ class Toggles(BaseModel):
90
+ model_config = ConfigDict(
91
+ extra="forbid",
92
+ )
93
+ labels: bool = Field(
94
+ True, description="Toggle labels basemap (absorbé de ex-overlays)."
95
+ )
96
+ buildings_3d: bool = Field(
97
+ False, description="Toggle fill-extrusion bâtiments natifs basemap."
98
+ )
99
+
100
+
101
+ class Basemap(BaseModel):
102
+ model_config = ConfigDict(
103
+ extra="forbid",
104
+ )
105
+ id: Literal["osm"] | Literal["plan-ign-v2"] | Literal["plan-ign-v2-gris"] | Literal[
106
+ "ortho-ign"
107
+ ] | Literal["hillshade-ign"] | Literal["dsfr-sobre"] | Literal["etalab"] | Literal[
108
+ "openfreemap-liberty"
109
+ ] | Literal[
110
+ "openfreemap-bright"
111
+ ] | Literal[
112
+ "openfreemap-positron"
113
+ ] | Literal[
114
+ "cartodb-positron-nolabels"
115
+ ] | Literal[
116
+ "custom"
117
+ ] | Id = Field(
118
+ ...,
119
+ description="ID du fond de carte. Enum ouverte : accepte x_<projet>_* avec fallback graceful 'osm'.",
120
+ )
121
+ style: dict[str, Any] | None = Field(
122
+ None,
123
+ description="Style JSON MapLibre v8 (utilisé si id='custom' ou fallback si id inconnu).",
124
+ )
125
+ alternatives: list[str] | None = Field(
126
+ None, description="IDs alternatifs pour switcher UI runtime."
127
+ )
128
+ toggles: Toggles | None = None
129
+
130
+
131
+ class Kind(Enum):
132
+ commune = "commune"
133
+ insee_arm = "insee_arm"
134
+
135
+
136
+ class Zone2(BaseModel):
137
+ model_config = ConfigDict(
138
+ extra="forbid",
139
+ )
140
+ kind: Kind
141
+ insee: str = Field(
142
+ ...,
143
+ description="Code INSEE 5 chars (commune 75001-...; arrondissement 75101-75120, 13201-13216, 69381-69389).",
144
+ pattern="^[0-9AB]{5}$",
145
+ )
146
+ buffer_km: float | None = Field(
147
+ None,
148
+ description="Rayon buffer autour de la commune (facultatif).",
149
+ ge=0.0,
150
+ le=50.0,
151
+ )
152
+ pitch: float | None = Field(None, ge=0.0, le=85.0)
153
+ bearing: float | None = Field(None, ge=-180.0, le=180.0)
154
+ max_pitch: float = Field(60, ge=0.0, le=85.0)
155
+
156
+
157
+ class Zone3(BaseModel):
158
+ model_config = ConfigDict(
159
+ extra="forbid",
160
+ )
161
+ kind: Literal["auto_bounds"]
162
+ pitch: float | None = Field(None, ge=0.0, le=85.0)
163
+ bearing: float | None = Field(None, ge=-180.0, le=180.0)
164
+ max_pitch: float = Field(60, ge=0.0, le=85.0)
165
+
166
+
167
+ class Zone4(BaseModel):
168
+ model_config = ConfigDict(
169
+ extra="allow",
170
+ )
171
+ kind: str = Field(
172
+ ..., description="Extension nommée par projet.", pattern="^x_[a-z_]+$"
173
+ )
174
+
175
+
176
+ class BBox(RootModel[list[float]]):
177
+ root: list[float] = Field(
178
+ ...,
179
+ description="Bounding box [west, south, east, north] EPSG:4326 (GeoJSON RFC 7946 §5).",
180
+ max_length=4,
181
+ min_length=4,
182
+ )
183
+
184
+
185
+ class Coord(RootModel[list[float]]):
186
+ root: list[float] = Field(
187
+ ...,
188
+ description="Coordonnée [longitude, latitude] EPSG:4326.",
189
+ max_length=2,
190
+ min_length=2,
191
+ )
192
+
193
+
194
+ class CameraPreset(BaseModel):
195
+ model_config = ConfigDict(
196
+ extra="forbid",
197
+ )
198
+ id: str = Field(
199
+ ..., description="Identifiant unique du preset (top, 3d, street, ...)."
200
+ )
201
+ pitch: float = Field(..., ge=0.0, le=85.0)
202
+ bearing: float = Field(..., ge=-180.0, le=180.0)
203
+ zoom: float = Field(..., ge=0.0, le=22.0)
204
+
205
+
206
+ class SourceId(RootModel[str]):
207
+ root: str = Field(
208
+ ...,
209
+ description="Source DEM. Enum ouverte : accepte x_* pour custom.",
210
+ pattern="^x_[a-z_]+$",
211
+ )
212
+
213
+
214
+ class Encoding(Enum):
215
+ mapbox = "mapbox"
216
+ terrarium = "terrarium"
217
+
218
+
219
+ class Adapter(Enum):
220
+ ignmnt = "ignmnt"
221
+ NoneType_None = None
222
+
223
+
224
+ class BlendMode(Enum):
225
+ multiply = "multiply"
226
+ normal = "normal"
227
+ overlay = "overlay"
228
+
229
+
230
+ class Hillshade(BaseModel):
231
+ model_config = ConfigDict(
232
+ extra="forbid",
233
+ )
234
+ opacity: float = Field(0.35, ge=0.0, le=1.0)
235
+ blend_mode: BlendMode = "multiply"
236
+
237
+
238
+ class Terrain(BaseModel):
239
+ model_config = ConfigDict(
240
+ extra="forbid",
241
+ )
242
+ source_id: Literal["ign-lidar"] | Literal["terrarium"] | Literal[
243
+ "custom"
244
+ ] | SourceId = Field(
245
+ ..., description="Source DEM. Enum ouverte : accepte x_* pour custom."
246
+ )
247
+ encoding: Encoding = "mapbox"
248
+ exaggeration: float = Field(1.0, ge=0.0, le=10.0)
249
+ adapter: Adapter | None = Field(
250
+ None,
251
+ description="Adapter chargé à la demande (registre ADAPTERS fermé). null si source_id ne nécessite pas d'adapter.",
252
+ )
253
+ custom_tiles_url: AnyUrl | None = Field(
254
+ None, description="URL tiles raster-dem si source_id='custom'."
255
+ )
256
+ hillshade: Hillshade | None = Field(
257
+ None, description="Effet hillshade (absorbe l'ex overlays.hillshade)."
258
+ )
259
+
260
+
261
+ class LocationForSuncalc(BaseModel):
262
+ model_config = ConfigDict(
263
+ extra="forbid",
264
+ )
265
+ lat: float | None = Field(None, ge=-90.0, le=90.0)
266
+ lng: float | None = Field(None, ge=-180.0, le=180.0)
267
+
268
+
269
+ class Sky(BaseModel):
270
+ model_config = ConfigDict(
271
+ extra="forbid",
272
+ )
273
+ enabled: bool = True
274
+ dynamic_sun: bool = Field(
275
+ True,
276
+ description="Recalcul SunCalc auto selon sun_datetime + location_for_suncalc.",
277
+ )
278
+ sun_datetime: AwareDatetime | None = None
279
+ location_for_suncalc: LocationForSuncalc | None = None
280
+
281
+
282
+ class Role(RootModel[str]):
283
+ root: str = Field(
284
+ ...,
285
+ description="Rôle sémantique. Enum ouverte + fallback 'primary'.",
286
+ pattern="^x_[a-z_]+$",
287
+ title="Rôle métier custom (fallback: primary).",
288
+ )
289
+
290
+
291
+ class GeometryType(Enum):
292
+ point = "point"
293
+ line = "line"
294
+ polygon = "polygon"
295
+ multipolygon = "multipolygon"
296
+ raster = "raster"
297
+
298
+
299
+ class AttributeStats(BaseModel):
300
+ model_config = ConfigDict(
301
+ extra="allow",
302
+ )
303
+ min: float | None = None
304
+ max: float | None = None
305
+ null_count: int | None = None
306
+ distinct_count: int | None = None
307
+ distinct_values: list[Any] | None = None
308
+
309
+
310
+ class Data(BaseModel):
311
+ model_config = ConfigDict(
312
+ extra="allow",
313
+ )
314
+ type: Literal["FeatureCollection"]
315
+ features: list[Any]
316
+
317
+
318
+ class LayerSource1(BaseModel):
319
+ model_config = ConfigDict(
320
+ extra="allow",
321
+ )
322
+ type: Literal["geojson"]
323
+ data: AnyUrl | Data = Field(
324
+ ...,
325
+ description="FeatureCollection inline OU URL. AUTORISÉ UNIQUEMENT si n_features ≤ 500 (500 KB) — sinon warning validation, interdit au-delà de 5 MB.",
326
+ )
327
+ crs: Literal["EPSG:4326"] = "EPSG:4326"
328
+
329
+
330
+ class LayerSource2(BaseModel):
331
+ model_config = ConfigDict(
332
+ extra="allow",
333
+ )
334
+ type: Literal["geojson_path"]
335
+ path: str = Field(..., description="Chemin PVC ou URL relative (résolu côté hub).")
336
+ crs: Literal["EPSG:4326"] = "EPSG:4326"
337
+
338
+
339
+ class LayerSource3(BaseModel):
340
+ model_config = ConfigDict(
341
+ extra="allow",
342
+ )
343
+ type: Literal["vector"]
344
+ url: str = Field(
345
+ ..., description="URL template MapLibre : https://.../{z}/{x}/{y}.pbf"
346
+ )
347
+ source_layer: str
348
+ min_zoom: int | None = Field(None, ge=0, le=22)
349
+ max_zoom: int | None = Field(None, ge=0, le=22)
350
+ promote_id: str | None = None
351
+
352
+
353
+ class LayerSource4(BaseModel):
354
+ model_config = ConfigDict(
355
+ extra="allow",
356
+ )
357
+ type: Literal["pmtiles"]
358
+ url: AnyUrl = Field(
359
+ ...,
360
+ description="URL fichier .pmtiles (résolu via pmtiles:// protocol MapLibre).",
361
+ )
362
+ source_layer: str
363
+ promote_id: str | None = None
364
+
365
+
366
+ class Sync(Enum):
367
+ load = "load"
368
+ reactive = "reactive"
369
+ save = "save"
370
+
371
+
372
+ class LayerSource5(BaseModel):
373
+ model_config = ConfigDict(
374
+ extra="allow",
375
+ )
376
+ type: Literal["grist_table"]
377
+ table: str = Field(..., description="Nom table Grist dans le doc courant.")
378
+ id_field: str = "id"
379
+ sync: Sync = Field(
380
+ "load",
381
+ description="load: one-shot fetch. reactive: subscribe onRecords. save: bidirectionnel.",
382
+ )
383
+
384
+
385
+ class LayerSource6(BaseModel):
386
+ model_config = ConfigDict(
387
+ extra="allow",
388
+ )
389
+ type: Literal["wfs"]
390
+ url: AnyUrl
391
+ type_name: str = Field(..., description="WFS TypeName (ex: BATIMENT.BATIMENT).")
392
+
393
+
394
+ class LayerSource7(BaseModel):
395
+ model_config = ConfigDict(
396
+ extra="allow",
397
+ )
398
+ type: Literal["boundary_admin"]
399
+ catalog_id: str = Field(
400
+ ...,
401
+ description="ID catalog IGN admin (arrondissements_marseille, communes, epci, ...).",
402
+ )
403
+ filter: dict[str, Any] | None = Field(
404
+ None, description="Filtre attributaire (ex: {insee_arm: '13204'})."
405
+ )
406
+
407
+
408
+ class LayerSource8(BaseModel):
409
+ model_config = ConfigDict(
410
+ extra="allow",
411
+ )
412
+ type: str = Field(
413
+ ..., description="Extension source nommée par projet.", pattern="^x_[a-z_]+$"
414
+ )
415
+
416
+
417
+ class LayerSource(
418
+ RootModel[
419
+ LayerSource1
420
+ | LayerSource2
421
+ | LayerSource3
422
+ | LayerSource4
423
+ | LayerSource5
424
+ | LayerSource6
425
+ | LayerSource7
426
+ | LayerSource8
427
+ ]
428
+ ):
429
+ root: LayerSource1 | LayerSource2 | LayerSource3 | LayerSource4 | LayerSource5 | LayerSource6 | LayerSource7 | LayerSource8 = Field(
430
+ ...,
431
+ description="Source de données. Discriminated union sur 'type'. Alignée MapLibre style spec (v8+).",
432
+ )
433
+
434
+
435
+ class Extrusion(BaseModel):
436
+ model_config = ConfigDict(
437
+ extra="forbid",
438
+ )
439
+ enabled: bool = Field(
440
+ False, description="Active fill-extrusion 2.5D (dérive dimension='2.5d')."
441
+ )
442
+ height_field: str | None = None
443
+ base_field: str | None = None
444
+ vertical_gradient: bool = True
445
+
446
+
447
+ class Outline(BaseModel):
448
+ model_config = ConfigDict(
449
+ extra="forbid",
450
+ )
451
+ enabled: bool = False
452
+ color: str = Field("#000091", pattern="^#[0-9a-fA-F]{6}$")
453
+ width: float = Field(1, ge=0.0)
454
+
455
+
456
+ class HollowPoint(BaseModel):
457
+ model_config = ConfigDict(
458
+ extra="forbid",
459
+ )
460
+ enabled: bool = False
461
+ stroke_color_from_field: str | None = None
462
+ radius: float = Field(7, ge=0.0)
463
+
464
+
465
+ class Opacity1(BaseModel):
466
+ model_config = ConfigDict(
467
+ extra="forbid",
468
+ )
469
+ kind: Literal["static"]
470
+ value: float = Field(..., ge=0.0, le=1.0)
471
+
472
+
473
+ class Opacity2(BaseModel):
474
+ model_config = ConfigDict(
475
+ extra="forbid",
476
+ )
477
+ kind: Literal["reactive"]
478
+ prop: str = Field(
479
+ ..., description="Event geo:bind prop (time, scenario, filter, ...)."
480
+ )
481
+ matched: float = Field(..., ge=0.0, le=1.0)
482
+ before: float = Field(..., ge=0.0, le=1.0)
483
+ after: float = Field(..., ge=0.0, le=1.0)
484
+ null_value: float | None = Field(None, ge=0.0, le=1.0)
485
+
486
+
487
+ class Opacity(RootModel[Opacity1 | Opacity2]):
488
+ root: Opacity1 | Opacity2 = Field(
489
+ ..., description="Opacité statique ou réactive (bindée sur événement geo:bind)."
490
+ )
491
+
492
+
493
+ class Label(BaseModel):
494
+ model_config = ConfigDict(
495
+ extra="forbid",
496
+ )
497
+ enabled: bool = False
498
+ field: str | None = None
499
+
500
+
501
+ class ColorClassification1(BaseModel):
502
+ model_config = ConfigDict(
503
+ extra="forbid",
504
+ )
505
+ mode: Literal["single"]
506
+ value: str = Field(..., pattern="^#[0-9a-fA-F]{6}$")
507
+ default: str | None = Field(None, pattern="^#[0-9a-fA-F]{6}$")
508
+
509
+
510
+ class Category(BaseModel):
511
+ model_config = ConfigDict(
512
+ extra="forbid",
513
+ )
514
+ value: str | float | bool | None
515
+ color: str = Field(..., pattern="^#[0-9a-fA-F]{6}$")
516
+ count: int | None = Field(None, ge=0)
517
+
518
+
519
+ class ColorClassification2(BaseModel):
520
+ model_config = ConfigDict(
521
+ extra="forbid",
522
+ )
523
+ mode: Literal["categorized"]
524
+ field: str
525
+ categories: list[Category]
526
+ default: str = Field(..., pattern="^#[0-9a-fA-F]{6}$")
527
+
528
+
529
+ class Method(Enum):
530
+ linear = "linear"
531
+ log = "log"
532
+ sqrt = "sqrt"
533
+ jenks = "jenks"
534
+ quantile = "quantile"
535
+ equal_interval = "equal_interval"
536
+ manual = "manual"
537
+
538
+
539
+ class ColorClassification3(BaseModel):
540
+ model_config = ConfigDict(
541
+ extra="allow",
542
+ )
543
+ mode: Literal["graduated"]
544
+ field: str
545
+ method: Method = Field(
546
+ ...,
547
+ description="Méthode. INPUT agent : single/categorized/manual/linear. COMPUTED producteur : + jenks/quantile/equal_interval/log/sqrt (pré-calculés en 'manual').",
548
+ )
549
+ breaks: list[float] = Field(
550
+ ..., description="Bornes intérieures. N breaks = N+1 classes.", min_length=1
551
+ )
552
+ palette: str = Field(..., description="Nom palette (voir §4.3 CONTRACT-V0.3.1.md).")
553
+ default: str = Field(..., pattern="^#[0-9a-fA-F]{6}$")
554
+ field_compiled_expression: list[Any] | None = Field(
555
+ None,
556
+ alias="_compiled_expression",
557
+ description="MapLibre expression compilée. Source de vérité rendu. readOnly (généré producteur).",
558
+ )
559
+
560
+
561
+ class ColorClassification4(BaseModel):
562
+ model_config = ConfigDict(
563
+ extra="forbid",
564
+ )
565
+ mode: Literal["expression"]
566
+ expr: list[Any] = Field(
567
+ ...,
568
+ description="MapLibre expression brute passthrough (non-portable renderers non-MapLibre).",
569
+ )
570
+ default: str | None = Field(None, pattern="^#[0-9a-fA-F]{6}$")
571
+
572
+
573
+ class ColorClassification(
574
+ RootModel[
575
+ ColorClassification1
576
+ | ColorClassification2
577
+ | ColorClassification3
578
+ | ColorClassification4
579
+ ]
580
+ ):
581
+ root: ColorClassification1 | ColorClassification2 | ColorClassification3 | ColorClassification4 = Field(
582
+ ...,
583
+ description="Discriminated union sur 'mode'. Voir §4.4 CONTRACT-V0.3.1.md pour combinaisons valides.",
584
+ )
585
+
586
+
587
+ class SizeClassification1(BaseModel):
588
+ model_config = ConfigDict(
589
+ extra="forbid",
590
+ )
591
+ mode: Literal["single"]
592
+ value: float = Field(..., ge=0.0)
593
+
594
+
595
+ class OutputRangeItem(RootModel[float]):
596
+ root: float = Field(..., ge=0.0)
597
+
598
+
599
+ class Method1(Enum):
600
+ linear = "linear"
601
+ log = "log"
602
+ sqrt = "sqrt"
603
+
604
+
605
+ class SizeClassification2(BaseModel):
606
+ model_config = ConfigDict(
607
+ extra="forbid",
608
+ )
609
+ mode: Literal["graduated"]
610
+ field: str
611
+ output_range: list[OutputRangeItem] = Field(..., max_length=2, min_length=2)
612
+ method: Method1 = "linear"
613
+
614
+
615
+ class SizeClassification(RootModel[SizeClassification1 | SizeClassification2]):
616
+ root: SizeClassification1 | SizeClassification2 = Field(
617
+ ...,
618
+ description="Discriminated union sur 'mode' pour size (cercles proportionnels, extrusion, ...).",
619
+ )
620
+
621
+
622
+ class ModelClassification1(BaseModel):
623
+ model_config = ConfigDict(
624
+ extra="forbid",
625
+ )
626
+ mode: Literal["single"]
627
+ value: str | None = Field(None, description="modelId ou null.")
628
+
629
+
630
+ class Category1(BaseModel):
631
+ model_config = ConfigDict(
632
+ extra="forbid",
633
+ )
634
+ value: str | float | bool
635
+ model_id: str
636
+
637
+
638
+ class ModelClassification2(BaseModel):
639
+ model_config = ConfigDict(
640
+ extra="forbid",
641
+ )
642
+ mode: Literal["categorized"]
643
+ field: str
644
+ categories: list[Category1]
645
+ default_model_id: str | None
646
+
647
+
648
+ class ModelClassification(RootModel[ModelClassification1 | ModelClassification2]):
649
+ root: ModelClassification1 | ModelClassification2 = Field(
650
+ ..., description="Discriminated union pour choix modèle 3D (Atlas)."
651
+ )
652
+
653
+
654
+ class Kind1(Enum):
655
+ handlebars = "handlebars"
656
+ html = "html"
657
+
658
+
659
+ class PopupTemplate(BaseModel):
660
+ model_config = ConfigDict(
661
+ extra="forbid",
662
+ )
663
+ kind: Kind1
664
+ body: str = Field(..., max_length=4000)
665
+
666
+
667
+ class Interactions(BaseModel):
668
+ model_config = ConfigDict(
669
+ extra="forbid",
670
+ )
671
+ popup_template: PopupTemplate | None = None
672
+ tooltip_field: str | None = None
673
+ hover_attributes: list[str] | None = None
674
+ emits_events: bool = Field(
675
+ True,
676
+ description="Si true, émet geo:feature-click/hover canoniques. Noms d'événements fixés par la lib (voir §4.5).",
677
+ )
678
+ feature_state_selected_field: str | None = Field(
679
+ None,
680
+ description="Attribut identifiant pour setFeatureState('selected') MapLibre (bidirectionnel Grist bridge).",
681
+ )
682
+
683
+
684
+ class Kind2(RootModel[str]):
685
+ root: str = Field(
686
+ ...,
687
+ description="Type sémantique du contenu (renderer neutre). Enum ouverte.",
688
+ pattern="^x_[a-z_]+$",
689
+ title="Extension custom layer nommée par projet.",
690
+ )
691
+
692
+
693
+ class Adapter1(RootModel[str]):
694
+ root: str = Field(
695
+ ...,
696
+ description="Adapter registre ADAPTERS de la lib (chargé à la demande).",
697
+ pattern="^x_[a-z_]+$",
698
+ )
699
+
700
+
701
+ class CustomLayer(BaseModel):
702
+ model_config = ConfigDict(
703
+ extra="forbid",
704
+ )
705
+ id: str
706
+ kind: Literal["gltf_instances"] | Literal["custom_3d_layer"] | Kind2 = Field(
707
+ ..., description="Type sémantique du contenu (renderer neutre). Enum ouverte."
708
+ )
709
+ adapter: Literal["geo-3d-scene"] | Adapter1 = Field(
710
+ ..., description="Adapter registre ADAPTERS de la lib (chargé à la demande)."
711
+ )
712
+ source_layer_id: str | None = Field(
713
+ None,
714
+ description="Référence un layers[].id porteur des features instances (P0-K, évite instances[] inline).",
715
+ )
716
+ params: dict[str, Any] | None = Field(
717
+ None, description="Params spécifiques adapter (gltf_base_url, shadow_map, ...)."
718
+ )
719
+ extensions: dict[constr(pattern=r"^[a-z][a-z_0-9]*$"), dict[str, Any]] | None = None
720
+
721
+
722
+ class Item(BaseModel):
723
+ model_config = ConfigDict(
724
+ extra="forbid",
725
+ )
726
+ label: str
727
+ color: str | None = Field(None, pattern="^#[0-9a-fA-F]{6}$")
728
+ count: int | None = Field(None, ge=0)
729
+ size: float | None = Field(None, ge=0.0)
730
+ layer: str | None = None
731
+
732
+
733
+ class LegendFormat(Enum):
734
+ chips = "chips"
735
+ gradient_bar = "gradient_bar"
736
+ proportional = "proportional"
737
+
738
+
739
+ class OverlayPosition(Enum):
740
+ top_left = "top-left"
741
+ top_right = "top-right"
742
+ bottom_left = "bottom-left"
743
+ bottom_right = "bottom-right"
744
+ bottom = "bottom"
745
+ right = "right"
746
+ floating = "floating"
747
+
748
+
749
+ class Bind(RootModel[str]):
750
+ root: str = Field(
751
+ ...,
752
+ description="Source de mise à jour du compteur. Enum ouverte.",
753
+ pattern="^x_[a-z_]+$",
754
+ )
755
+
756
+
757
+ class Expr(RootModel[str]):
758
+ root: str = Field(..., description="Expression du compteur.", pattern="^x_[a-z_]+$")
759
+
760
+
761
+ class Counter(BaseModel):
762
+ model_config = ConfigDict(
763
+ extra="forbid",
764
+ )
765
+ id: str
766
+ bind: Literal["time"] | Literal["scenario"] | Literal["filter"] | Literal[
767
+ "selection"
768
+ ] | Bind = Field(
769
+ ..., description="Source de mise à jour du compteur. Enum ouverte."
770
+ )
771
+ expr: Literal["filtered_count"] | Literal["total"] | Literal[
772
+ "percentage"
773
+ ] | Expr = Field(..., description="Expression du compteur.")
774
+ template: str = Field(
775
+ ..., description="Template Handlebars avec vars {{count}}, {{total}}, {{pct}}."
776
+ )
777
+ position: OverlayPosition | None = "bottom-left"
778
+
779
+
780
+ class Scalebar(BaseModel):
781
+ model_config = ConfigDict(
782
+ extra="forbid",
783
+ )
784
+ position: OverlayPosition | None = "bottom-left"
785
+ unit: Unit = "metric"
786
+
787
+
788
+ class NorthArrow(BaseModel):
789
+ model_config = ConfigDict(
790
+ extra="forbid",
791
+ )
792
+ position: OverlayPosition | None = "bottom-left"
793
+
794
+
795
+ class Zone1(BaseModel):
796
+ model_config = ConfigDict(
797
+ extra="forbid",
798
+ )
799
+ kind: Literal["manual"]
800
+ bbox: BBox | None = None
801
+ center: Coord | None = None
802
+ zoom: float | None = Field(None, ge=0.0, le=22.0)
803
+ pitch: float | None = Field(None, ge=0.0, le=85.0)
804
+ bearing: float | None = Field(None, ge=-180.0, le=180.0)
805
+ max_pitch: float = Field(60, ge=0.0, le=85.0)
806
+
807
+
808
+ class Zone(RootModel[Zone1 | Zone2 | Zone3 | Zone4]):
809
+ root: Zone1 | Zone2 | Zone3 | Zone4 = Field(
810
+ ...,
811
+ description="Zone d'étude / vue caméra initiale. Discriminated union sur 'kind'.",
812
+ )
813
+
814
+
815
+ class Classification(BaseModel):
816
+ model_config = ConfigDict(
817
+ extra="allow",
818
+ )
819
+ color: ColorClassification | None = None
820
+ size: SizeClassification | None = None
821
+ model: ModelClassification | None = None
822
+ label: Label | None = None
823
+
824
+
825
+ class Legend1(BaseModel):
826
+ model_config = ConfigDict(
827
+ extra="forbid",
828
+ )
829
+ mode: Literal["auto"]
830
+ from_layer: str = Field(..., description="layers[].id source de la classification.")
831
+ position: OverlayPosition | None = "bottom-left"
832
+ format: LegendFormat | None = "chips"
833
+ title: str | None = Field(None, max_length=200)
834
+
835
+
836
+ class Legend2(BaseModel):
837
+ model_config = ConfigDict(
838
+ extra="forbid",
839
+ )
840
+ mode: Literal["manual"]
841
+ items: list[Item]
842
+ position: OverlayPosition | None = "bottom-left"
843
+ format: LegendFormat | None = "chips"
844
+ title: str | None = Field(None, max_length=200)
845
+
846
+
847
+ class Legend(RootModel[Legend1 | Legend2]):
848
+ root: Legend1 | Legend2 = Field(
849
+ ..., description="Légende. Discriminated union sur 'mode'."
850
+ )
851
+
852
+
853
+ class LayerStyle(BaseModel):
854
+ model_config = ConfigDict(
855
+ extra="allow",
856
+ )
857
+ visible: bool = True
858
+ z_index: int | None = Field(
859
+ None,
860
+ description="Ordre d'affichage (plus élevé = au-dessus). null = ordre du tableau.",
861
+ )
862
+ opacity: Opacity | None = None
863
+ classification: Classification | None = None
864
+ extrusion: Extrusion | None = None
865
+ outline: Outline | None = Field(
866
+ None,
867
+ description="Ajoute automatiquement un layer 'line' sur la même source (absorbe workaround Livrables).",
868
+ )
869
+ hollow_point: HollowPoint | None = Field(
870
+ None,
871
+ description="Point creux pour features approximatives (absorbe workaround Livrables addPoints).",
872
+ )
873
+ maplibre_paint_override: dict[str, Any] | None = Field(
874
+ None,
875
+ description="Passthrough MapLibre setPaintProperty pour props avancées non-couvertes par classification.",
876
+ )
877
+
878
+
879
+ class Layer(BaseModel):
880
+ model_config = ConfigDict(
881
+ extra="forbid",
882
+ )
883
+ id: str = Field(
884
+ ...,
885
+ description="Identifiant slug unique dans le manifest.",
886
+ pattern="^[a-z][a-z0-9_-]*$",
887
+ )
888
+ name: str = Field(..., description="Nom affiché en légende.", max_length=300)
889
+ role: Literal["primary"] | Literal["context"] | Literal["emprise"] | Literal[
890
+ "members"
891
+ ] | Literal["site"] | Role = Field(
892
+ ..., description="Rôle sémantique. Enum ouverte + fallback 'primary'."
893
+ )
894
+ geometry_type: GeometryType = Field(
895
+ ...,
896
+ description="Type géométrie MapLibre (guide l'auto-split polygon+point si Feature Collection mixte).",
897
+ )
898
+ source: LayerSource
899
+ n_features: int | None = Field(
900
+ None,
901
+ description="Nombre de features (pré-calculé — obligatoire si source.type ≠ geojson).",
902
+ ge=0,
903
+ )
904
+ bbox: BBox | None = None
905
+ attribute_stats: dict[
906
+ constr(pattern=r"^[a-zA-Z_][a-zA-Z0-9_]*$"), AttributeStats
907
+ ] | None = None
908
+ style: LayerStyle
909
+ interactions: Interactions | None = None
910
+ min_zoom: float | None = Field(None, ge=0.0, le=22.0)
911
+ max_zoom: float | None = Field(None, ge=0.0, le=22.0)
912
+ extensions: dict[constr(pattern=r"^[a-z][a-z_0-9]*$"), dict[str, Any]] | None = None
913
+
914
+
915
+ class ScenemanifestV031(BaseModel):
916
+ model_config = ConfigDict(
917
+ extra="forbid",
918
+ )
919
+ field_schema: AnyUrl | None = Field(
920
+ None,
921
+ alias="$schema",
922
+ description="Pointeur JSON Schema self-description (facultatif si absent).",
923
+ )
924
+ manifest_version: str = Field(
925
+ ...,
926
+ description="Version SemVer strict du contract. V0.3.x lit tout 0.3.y avec y ≤ x (compat descendante 2 versions mineures).",
927
+ pattern="^0\\.3\\.[0-9]+$",
928
+ )
929
+ scene_hash: str | None = Field(
930
+ None,
931
+ description="Hash SHA-256 canonique du manifest_body. Calculé UNIQUEMENT côté producteur (au snapshot dans scene_store/). Le consommateur ne recalcule jamais côté main thread. Helper computeSceneHashInWorker() disponible pour vérification offline.",
932
+ pattern="^sha256:[0-9a-f]{64}$",
933
+ )
934
+ manifest_id: UUID | None = Field(
935
+ None, description="UUID4 unique du manifest. Généré au publish."
936
+ )
937
+ produced_at: AwareDatetime = Field(
938
+ ..., description="Timestamp ISO 8601 avec TZ. Généré au publish."
939
+ )
940
+ title: str = Field(
941
+ ...,
942
+ description="Titre principal de la scène (affiché en header carto).",
943
+ max_length=300,
944
+ )
945
+ subtitle: str | None = Field(
946
+ None, description="Sous-titre optionnel.", max_length=300
947
+ )
948
+ description: str | None = Field(
949
+ None, description="Description longue (paragraphe intro).", max_length=2000
950
+ )
951
+ source_text: str | None = Field(
952
+ None,
953
+ description="Citation datée des sources de données (BD TOPO IGN 2024, ...).",
954
+ max_length=500,
955
+ )
956
+ caveat: str | None = Field(
957
+ None,
958
+ description="Disclaimer méthodologique (marge d'erreur, données pédagogiques, ...).",
959
+ max_length=2000,
960
+ )
961
+ provenance: Provenance
962
+ projection: Literal["mercator"] | Literal["globe"] | Literal[
963
+ "lambert93"
964
+ ] | Projection = Field(
965
+ "mercator",
966
+ description="Projection cartographique. 'mercator' (défaut), 'globe' (MapLibre v5+), 'lambert93' (via adapter proj-lambert93). Enum ouverte : accepte x_<projet>_* avec fallback graceful 'mercator'.",
967
+ validate_default=True,
968
+ )
969
+ basemap: Basemap
970
+ basemap_switcher: list[str] | None = Field(
971
+ None,
972
+ description="IDs de basemaps proposés en toggle runtime UI (opt.). Défaut : uniquement basemap.id actuel.",
973
+ )
974
+ zone: Zone
975
+ camera_presets: list[CameraPreset] | None = Field(
976
+ None,
977
+ description="Presets caméra nommés (top, 3d, street, ...). Utilisés par API impérative flyToPreset().",
978
+ )
979
+ terrain: Terrain | None = None
980
+ sky: Sky | None = None
981
+ layers: list[Layer] = Field(
982
+ ...,
983
+ description="Couches cartographiques. Ordre = ordre de rendu (z_index respecté si défini).",
984
+ )
985
+ custom_layers: list[CustomLayer] | None = Field(
986
+ None,
987
+ description="Couches custom Three.js. Trigger dimension='3d' auto (lib dérivée). Chargé uniquement si <geo-3d-scene> composition slot présent.",
988
+ )
989
+ legend: Legend | None = None
990
+ scalebar: Scalebar | None = None
991
+ north_arrow: NorthArrow | None = None
992
+ counters: list[Counter] | None = Field(
993
+ None, description="Compteurs live bindés (nb features filtrés, %, ...)."
994
+ )
995
+ extensions: dict[
996
+ constr(pattern=r"^[a-z][a-z_0-9]*$"), dict[str, Any]
997
+ ] | None = Field(
998
+ None,
999
+ description="Extensions par-projet indexees par prefix (x_livrables, x_atlas, x_sig, x_zebra, ...). Contenu libre (additionalProperties: true).",
1000
+ )