clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: build representative Atlas worlds

Dami committed 3bfa973
commit
3bfa9738ee9d1be90709172b83d5c3230ff643dd
parent
91c37e3e5625f656f077686570dbc3c13c6b0f56
author
Dami <dami.etoile@gmail.com>
32 files changed, +1387 -110
Mdocs/authoring.md11++-
Aexamples/sample-region-areas.geojson17+++
Aexamples/sample-region-raster.ppm7+
Aexamples/sample-region-routes.geojson17+++
Aexamples/sample-region-symbol.svg4+
Mexamples/sample-region.atlas-project136++++++++++++++++++++-
Mexamples/sample-region.geojson22+++-
Minternal/app/events.go49++++++--
Minternal/app/events_test.go27++++
Minternal/app/hostenv/wailshost/wailshost.go15++
Minternal/app/hostenv/wailshost/wailshost_test.go25++++
Minternal/app/templates/shell.tmpl2+-
Ainternal/app/vnext_geometry_kind_test.go73+++++++++++
Minternal/app/vnext_projection_test.go17+++-
Minternal/app/vnext_runtime.go45++++++-
Minternal/authoring/assemble.go73+++++++++--
Ainternal/authoring/assets.go35+++++
Minternal/authoring/build.go26++++-
Minternal/authoring/build_test.go5+
Minternal/authoring/manifest.go184++++++++++++++++++++++++++---
Minternal/authoring/manifest_test.go71+++++++++++-
Minternal/authoring/observe.go14++-
Minternal/authoring/plan.go33+++++-
Ainternal/authoring/raster.go125++++++++++++++++++++
Ainternal/authoring/representative_test.go192++++++++++++++++++++++++++++++
Ainternal/authoring/transform.go93+++++++++++++++
Ainternal/authoring/transform_test.go60+++++++++
Minternal/workbench/operations_test.go35+++++-
Minternal/workbench/project.go24+++-
Minternal/workbench/templates/project.tmpl31+++--
Mrender/test/vnext.test.ts6+
Mrender/world/model.ts23+++-
M docs/authoring.md +10 -1
@@ -39,6 +39,10 @@ Their defaults are the application-owned cache and library.
3939 4040 ## Contract4141 42+- `atlas-project/v2` is an intentional hard break. Every feature source declares43+ one homogeneous `point`, `path`, or `area` FeatureSet plus an executable44+ source-to-world transform. Semantic types remain domain language rather than45+ geometry labels.4246 - The YAML decoder is strict. Unknown keys, duplicate identities, incomplete4347 mappings, broken presentation references, and invalid raster plans fail.4448 - Adapters describe acquisition; declarative mappings describe meaning.
@@ -51,7 +55,12 @@ Their defaults are the application-owned cache and library.
5155 evidence.5256 - Observations retain source-native identity and evidence. Assembly merges only5357 canonical identities declared by the manifest; earlier sources win conflicts54- and every contributor remains in provenance.58+ and every contributor remains in provenance. Property contracts retain bool,59+ int64, float64, string, bytes, and stable-ID kinds; relationships name their60+ target FeatureSet and canonical feature identity.61+- A local raster image is deterministically expanded into the declared native62+ pyramid. Explicit XYZ sources retain authored windows. Authored supporting63+ assets are captured, packed, and may be referenced directly by styles.5564 - Presentation is compiled directly into native styles and layers. There is no5665 legacy JSON document or enrichment projection between authored intent and the5766 packed schema.
A examples/sample-region-areas.geojson +17 -0
A examples/sample-region-raster.ppm +7 -0
A examples/sample-region-routes.geojson +17 -0
A examples/sample-region-symbol.svg +4 -0
M examples/sample-region.atlas-project +131 -5
@@ -1,5 +1,5 @@
1-schema: atlas-project/v12-schema-namespace: example.invalid/atlas/sample-region/v11+schema: atlas-project/v22+schema-namespace: example.invalid/atlas/sample-region/v233 id: sample-region44 title: Sample Region55
@@ -14,35 +14,161 @@ target:
1414 extent: [0, 0, 100, 100]1515 1616 sources:17- - id: sample-features17+ - id: sample-places1818 adapter: geojson1919 locator: sample-region.geojson2020 media-type: application/geo+json2121 license: Example data2222 attribution: Sample Region contributors23- estimate-bytes: 102423+ estimate-bytes: 20482424 mapping:2525 feature-set: places2626 title: Sample Places2727 semantic-type: place2828 identity: id2929 feature-title: properties.name30+ geometry:31+ family: point32+ source-space: sample-source-grid33+ transform:34+ kind: affine35+ matrix: [10, 0, 0, 0, 10, 0]36+ fields:37+ - id: kind38+ name: Kind39+ source: properties.kind40+ type: string41+ - id: featured42+ name: Featured43+ source: properties.featured44+ type: bool45+ - id: rank46+ name: Rank47+ source: properties.rank48+ type: int6449+ - id: score50+ name: Score51+ source: properties.score52+ type: float6453+ - id: badge54+ name: Badge55+ source: properties.badge56+ type: bytes57+ - id: category58+ name: Category59+ source: properties.category60+ type: id61+ relationships:62+ - predicate: within63+ feature-set: areas64+ target: properties.area65+66+ - id: sample-routes67+ adapter: geojson68+ locator: sample-region-routes.geojson69+ media-type: application/geo+json70+ license: Example data71+ attribution: Sample Region contributors72+ estimate-bytes: 102473+ mapping:74+ feature-set: routes75+ title: Sample Routes76+ semantic-type: route77+ identity: id78+ feature-title: properties.name79+ geometry:80+ family: path81+ source-space: sample-grid82+ transform:83+ kind: identity84+ fields:85+ - id: kind86+ name: Kind87+ source: properties.kind88+ type: string89+90+ - id: sample-areas91+ adapter: geojson92+ locator: sample-region-areas.geojson93+ media-type: application/geo+json94+ license: Example data95+ attribution: Sample Region contributors96+ estimate-bytes: 102497+ mapping:98+ feature-set: areas99+ title: Sample Areas100+ semantic-type: region101+ identity: id102+ feature-title: properties.name103+ geometry:104+ family: area105+ source-space: sample-grid106+ transform:107+ kind: identity30108 fields:31109 - id: kind32110 name: Kind33111 source: properties.kind34112 type: string35113 114+rasters:115+ - id: background116+ name: Sample Background117+ adapter: raster-file118+ locator: sample-region-raster.ppm119+ media-type: image/x-portable-pixmap120+ license: Example data121+ attribution: Sample Region contributors122+ estimate-bytes: 256123+ tile-size: 32124+ max-zoom: 1125+ full-zoom: 1126+ interpolate: true127+128+assets:129+ - id: sample-symbol130+ locator: sample-region-symbol.svg131+ media-type: image/svg+xml132+ provenance: Sample Region contributors133+ license: Example data134+ attribution: Sample Region contributors135+ estimate-bytes: 512136+36137 presentation:37138 id: default38139 title: Default39140 styles:141+ - id: area142+ render-as: geometry143+ stroke: "#6d91ad"144+ fill: "#d9e6dd"40145 - id: place41146 symbol: circle147+ icon-asset: sample-symbol148+ icon-picture: true42149 render-as: geometry43150 stroke: "#d9e7f5"44151 fill: "#5ba4d7"152+ - id: route153+ render-as: geometry154+ stroke: "#d98255"45155 layers:156+ - id: areas157+ feature-set: areas158+ style: area159+ label: Sample Areas160+ group: Sample Region161+ label-policy: quiet162+ visible: true163+ order: 1164+ - id: routes165+ feature-set: routes166+ style: route167+ label: Sample Routes168+ group: Sample Region169+ label-policy: automatic170+ visible: true171+ order: 246172 - id: places47173 feature-set: places48174 style: place
@@ -50,7 +176,7 @@ presentation:
50176 group: Sample Region51177 label-policy: automatic52178 visible: true53- order: 1179+ order: 354180 55181 release:56182 revision: 1
M examples/sample-region.geojson +16 -6
M internal/app/events.go +37 -12
@@ -125,19 +125,9 @@ func (a *App) announce(changed []string) {
125125 // A volume whose serving build moved cannot be patched: every URL under126126 // the old stamp is gone. The page is told to re-fetch itself.127127 for _, slug := range changed {128- volume, serving := held.bySlug[slug]129- if !serving {130- continue128+ if refresh, ok := a.refreshMessage(held, slug); ok {129+ a.events.publish(refresh)131130 }132- info := volume.Info()133- world := a.session(slug).World134- if _, ok := worldEntry(info, world); !ok {135- world = info.Worlds[0].Slug136- }137- where := partialTargets["shell"]138- directive := []byte("<hx-partial hx-target=\"" + where.target + "\" hx-swap=\"" + where.swap +139- "\" hx-get=\"/v/" + slug + "/" + world + "\"></hx-partial>")140- a.events.publish(message{name: eventRefresh, body: directive, volume: slug})141131 }142132 slog.Info("library changed", logging.Op("events"),143133 slog.Int("volumes", len(changed)), slog.Any("changed", changed))
@@ -150,6 +140,7 @@ func (a *App) announce(changed []string) {
150140 // catalog events and no refresh directives.151141 func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {152142 watching := r.URL.Query().Get("volume")143+ stamp := r.URL.Query().Get("stamp")153144 if watching != "" && vnext.ValidSlug(watching) != nil {154145 http.Error(w, "that is not a volume", http.StatusBadRequest)155146 return
@@ -173,6 +164,24 @@ func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
173164 if err := control.Flush(); err != nil {174165 return175166 }167+ // A native file may arrive while the desktop window is still opening,168+ // before this stream subscribes. The page includes the stamp it rendered;169+ // comparing it after subscription closes that startup race without event170+ // history or a polling loop.171+ if watching != "" && stamp != "" {172+ held := a.library()173+ if volume, ok := held.bySlug[watching]; ok && vnext.ShortStamp(volume.Info().Stamp) != stamp {174+ refresh, ok := a.refreshMessage(held, watching)175+ if ok {176+ if _, err := w.Write(encodeEvent(refresh)); err != nil {177+ return178+ }179+ if err := control.Flush(); err != nil {180+ return181+ }182+ }183+ }184+ }176185 177186 for {178187 select {
@@ -192,6 +201,22 @@ func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
192201 }193202 }194203 204+func (a *App) refreshMessage(held library, slug string) (message, bool) {205+ volume, serving := held.bySlug[slug]206+ if !serving {207+ return message{}, false208+ }209+ info := volume.Info()210+ world := a.session(slug).World211+ if _, ok := worldEntry(info, world); !ok {212+ world = info.Worlds[0].Slug213+ }214+ where := partialTargets["shell"]215+ directive := []byte("<hx-partial hx-target=\"" + where.target + "\" hx-swap=\"" + where.swap +216+ "\" hx-get=\"/v/" + slug + "/" + world + "\"></hx-partial>")217+ return message{name: eventRefresh, body: directive, volume: slug}, true218+}219+195220 // encodeEvent writes one message in the wire format: a name, then the body one196221 // data line per line of HTML, then a blank line. HTML is written as it is --197222 // the line splitting is the protocol's, not an escape.
M internal/app/events_test.go +27 -0
M internal/app/hostenv/wailshost/wailshost.go +15 -0
M internal/app/hostenv/wailshost/wailshost_test.go +25 -0
M internal/app/templates/shell.tmpl +1 -1
A internal/app/vnext_geometry_kind_test.go +73 -0
@@ -0,0 +1,73 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/atlas/format/semconv"7+ "github.com/FelineStateMachine/atlas/format/vnext"8+)9+10+func TestFeatureSetGeometryKindUsesStableClaim(t *testing.T) {11+ t.Parallel()12+13+ tests := []struct {14+ name string15+ claim string16+ semanticType string17+ geometry vnext.GeometryKind18+ }{19+ {name: "point", claim: semconv.GeometryPoint, semanticType: "transport.station", geometry: vnext.GeometryPolygon},20+ {name: "path", claim: semconv.GeometryPath, semanticType: "place.landmark", geometry: vnext.GeometryPoint},21+ {name: "area", claim: semconv.GeometryArea, semanticType: "network.route", geometry: vnext.GeometryLineString},22+ }23+ for _, test := range tests {24+ test := test25+ t.Run(test.name, func(t *testing.T) {26+ t.Parallel()27+28+ set := vnext.FeatureSet{29+ SemanticType: test.semanticType,30+ Claims: []vnext.Property{{31+ Field: vnext.Field{32+ Name: semconv.KeyGeometryKind,33+ Kind: vnext.KindString,34+ },35+ Value: vnext.StringValue(test.claim),36+ }},37+ Features: []vnext.Feature{{Geometry: vnext.Geometry{Kind: test.geometry}}},38+ }39+40+ if got := featureSetGeometryKind(set); got != test.claim {41+ t.Fatalf("feature set geometry kind = %q, want stable claim %q", got, test.claim)42+ }43+ })44+ }45+}46+47+func TestFeatureSetGeometryKindSniffsGeometryWithoutClaim(t *testing.T) {48+ t.Parallel()49+50+ tests := []struct {51+ name string52+ geometry vnext.GeometryKind53+ want string54+ }{55+ {name: "point", geometry: vnext.GeometryPoint, want: semconv.GeometryPoint},56+ {name: "path", geometry: vnext.GeometryLineString, want: semconv.GeometryPath},57+ {name: "area", geometry: vnext.GeometryPolygon, want: semconv.GeometryArea},58+ }59+ for _, test := range tests {60+ test := test61+ t.Run(test.name, func(t *testing.T) {62+ t.Parallel()63+64+ set := vnext.FeatureSet{65+ SemanticType: "geometry.unrelated",66+ Features: []vnext.Feature{{Geometry: vnext.Geometry{Kind: test.geometry}}},67+ }68+ if got := featureSetGeometryKind(set); got != test.want {69+ t.Fatalf("feature set geometry kind = %q, want sniffed %q", got, test.want)70+ }71+ })72+ }73+}
M internal/app/vnext_projection_test.go +16 -1
M internal/app/vnext_runtime.go +36 -9
@@ -6,6 +6,7 @@ import (
66 "math"77 "sort"88 "strconv"9+ "strings"910 "sync"1011 1112 "github.com/FelineStateMachine/atlas/format/semconv"
@@ -66,7 +67,7 @@ func semanticWorld(volume vnext.Volume, slug string) (vnext.World, bool) {
6667 // compatibility object is created along the way.6768 func buildVNextWorld(world vnext.World, assets []vnext.Asset) (*worldModel, error) {6869 space := world.CoordinateSpace69- grid := tileGrid{SourceZoom: int(space.SourceZoom), FirstTile: int(space.FirstTile), TileSize: int(space.TileSize), Size: int(space.Size)}70+ grid := nativeGrid(space)7071 model := &worldModel{7172 Slug: world.ID, Space: space, Lenses: nativeRasters(world.RasterPyramids), Attrs: propertiesToAttrs(world.Claims),7273 Origin: worldOrigin(world), Grid: grid,
@@ -95,7 +96,7 @@ func buildVNextWorld(world vnext.World, assets []vnext.Asset) (*worldModel, erro
9596 if !ok {9697 return nil, fmt.Errorf("layer %s refers to missing style %s", layer.ID, layer.Style)9798 }98- kind := semanticGeometryKind(set.SemanticType)99+ kind := featureSetGeometryKind(set)99100 attrs := propertiesToAttrs(set.Claims)100101 collection := &collectionModel{101102 ID: layer.ID, Title: legendLabel(world.Presentation.Legend, layer.ID, set.Title), Kind: kind,
@@ -161,7 +162,7 @@ func addNativeFeature(
161162 // reading; a native projected or synthetic space shows its own x/y values and162163 // unit instead of pretending those values are Web Mercator.163164 func coordinateLabel(space vnext.CoordinateSpace, grid tileGrid, position vnext.Position) string {164- if grid.SourceZoom > 0 && grid.TileSize > 0 && grid.Size > 0 {165+ if tilePlane(space.Definition) && grid.SourceZoom > 0 && grid.TileSize > 0 && grid.Size > 0 {165166 lat, lng := grid.unproject(position[0], position[1])166167 return strconv.FormatFloat(lat, 'f', 6, 64) + ", " +167168 strconv.FormatFloat(lng, 'f', 6, 64)
@@ -174,6 +175,23 @@ func coordinateLabel(space vnext.CoordinateSpace, grid tileGrid, position vnext.
174175 return label175176 }176177 178+func nativeGrid(space vnext.CoordinateSpace) tileGrid {179+ grid := tileGrid{SourceZoom: int(space.SourceZoom), FirstTile: int(space.FirstTile), TileSize: int(space.TileSize), Size: int(space.Size)}180+ if grid.TileSize == 0 {181+ grid.TileSize = 256182+ }183+ if grid.Size == 0 {184+ width := space.Extent[2] - space.Extent[0]185+ height := space.Extent[3] - space.Extent[1]186+ grid.Size = int(math.Ceil(math.Max(width, height)))187+ }188+ return grid189+}190+191+func tilePlane(definition string) bool {192+ return definition == "atlas:tile-plane" || strings.HasPrefix(definition, "atlas:v3/tile-grid")193+}194+177195 func buildNativeShape(feature *vnext.Feature, collection *collectionModel) (*shapeModel, error) {178196 shape := &shapeModel{179197 ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Shard: feature.Shard,
@@ -269,14 +287,23 @@ func nativeValue(value vnext.Value) string {
269287 }270288 }271289 272-func semanticGeometryKind(value string) string {273- if len(value) > len("geometry.") && value[:len("geometry.")] == "geometry." {274- return value[len("geometry."):]290+func featureSetGeometryKind(set vnext.FeatureSet) string {291+ for _, claim := range set.Claims {292+ if claim.Field.Name == semconv.KeyGeometryKind {293+ return claim.Value.String294+ }275295 }276- if value == "" {277- return semconv.GeometryPoint296+ for _, feature := range set.Features {297+ switch feature.Geometry.Kind {298+ case vnext.GeometryPoint:299+ return semconv.GeometryPoint300+ case vnext.GeometryLineString:301+ return semconv.GeometryPath302+ case vnext.GeometryPolygon:303+ return semconv.GeometryArea304+ }278305 }279- return value306+ return semconv.GeometryPoint280307 }281308 282309 func relationshipTarget(relationships []vnext.Relationship, predicate string) string {
M internal/authoring/assemble.go +58 -15
@@ -6,6 +6,7 @@ import (
66 "fmt"77 "sort"88 9+ "github.com/FelineStateMachine/atlas/format/semconv"910 "github.com/FelineStateMachine/atlas/format/vnext"1011 )1112
@@ -34,21 +35,27 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
3435 }3536 return observations[i].SourceOrder < observations[j].SourceOrder3637 })37- sets := make(map[string]*vnext.FeatureSet)38+ sets := make(map[string]int)39+ families := make(map[string]string)3840 features := make(map[string]map[string]*assembledFeature)3941 for _, item := range observations {40- set := sets[item.FeatureSet]41- if set == nil {42- world.FeatureSets = append(world.FeatureSets, vnext.FeatureSet{ID: setID(world.ID, item.FeatureSet), Title: item.SetTitle, SemanticType: item.Semantic})43- set = &world.FeatureSets[len(world.FeatureSets)-1]44- sets[item.FeatureSet] = set42+ setIndex, exists := sets[item.FeatureSet]43+ if !exists {44+ world.FeatureSets = append(world.FeatureSets, vnext.FeatureSet{45+ ID: setID(world.ID, item.FeatureSet), Title: item.SetTitle, SemanticType: item.Semantic,46+ Claims: []vnext.Property{geometryClaim(item.Family)},47+ })48+ setIndex = len(world.FeatureSets) - 149+ sets[item.FeatureSet] = setIndex50+ families[item.FeatureSet] = item.Family4551 features[item.FeatureSet] = make(map[string]*assembledFeature)4652 }47- if set.Title != item.SetTitle || set.SemanticType != item.Semantic {53+ set := &world.FeatureSets[setIndex]54+ if set.Title != item.SetTitle || set.SemanticType != item.Semantic || families[item.FeatureSet] != item.Family || familyOf(item.Geometry.Kind) != item.Family {4855 return vnext.Volume{}, fmt.Errorf("feature set %s has incompatible observations", item.FeatureSet)4956 }5057 for key, field := range item.Fields {51- if current, held := fieldByID(set.Properties, field.ID); held && current.Kind != field.Kind {58+ if current, held := fieldByID(set.Properties, field.ID); held && (current.Kind != field.Kind || current.Name != field.Name || current.Optional != field.Optional) {5259 return vnext.Volume{}, fmt.Errorf("feature set %s field %s changes type", item.FeatureSet, key)5360 } else if !held {5461 set.Properties = append(set.Properties, field)
@@ -65,7 +72,7 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
6572 }6673 features[item.FeatureSet][item.NativeID] = held6774 }68- held.feature.Provenance = append(held.feature.Provenance, vnext.Provenance{75+ held.feature.Provenance = appendProvenance(held.feature.Provenance, vnext.Provenance{6976 Source: item.Source, NativeID: item.NativeID, CapturedAt: item.Evidence.CapturedAt,7077 })7178 for key, value := range item.Values {
@@ -75,14 +82,14 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
7582 }7683 }7784 for _, relation := range item.Relations {78- held.feature.Relationships = append(held.feature.Relationships, vnext.Relationship{85+ held.feature.Relationships = appendRelationship(held.feature.Relationships, vnext.Relationship{7986 Predicate: relation.Predicate,8087 Target: featureID(world.ID, relation.FeatureSet, relation.NativeID),8188 })8289 }8390 }8491 for _, setName := range sortedSetNames(sets) {85- set := sets[setName]92+ set := &world.FeatureSets[sets[setName]]8693 sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })8794 for _, nativeID := range sortedFeatureNames(features[setName]) {8895 held := features[setName][nativeID]
@@ -108,7 +115,7 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
108115 for _, style := range project.Presentation.Styles {109116 world.Presentation.Styles = append(world.Presentation.Styles, vnext.Style{110117 ID: style.ID, Symbol: style.Symbol, Icon: style.Icon, RenderAs: style.RenderAs,111- Stroke: style.Stroke, Fill: style.Fill,118+ IconAsset: style.IconAsset, IconPicture: style.IconPicture, Stroke: style.Stroke, Fill: style.Fill,112119 })113120 }114121 for _, layer := range project.Presentation.Layers {
@@ -124,14 +131,50 @@ func assemble(project Project, observations []observation) (vnext.Volume, error)
124131 world.Presentation.Legend = append(world.Presentation.Legend, vnext.LegendEntry{Layer: layer.ID, Label: layer.Label, Order: layer.Order})125132 }126133 sort.Slice(world.Presentation.Styles, func(i, j int) bool { return world.Presentation.Styles[i].ID < world.Presentation.Styles[j].ID })127- sort.Slice(world.Presentation.Layers, func(i, j int) bool { return world.Presentation.Layers[i].Order < world.Presentation.Layers[j].Order })128- sort.Slice(world.Presentation.Legend, func(i, j int) bool { return world.Presentation.Legend[i].Order < world.Presentation.Legend[j].Order })134+ sort.Slice(world.Presentation.Layers, func(i, j int) bool {135+ if world.Presentation.Layers[i].Order != world.Presentation.Layers[j].Order {136+ return world.Presentation.Layers[i].Order < world.Presentation.Layers[j].Order137+ }138+ return world.Presentation.Layers[i].ID < world.Presentation.Layers[j].ID139+ })140+ sort.Slice(world.Presentation.Legend, func(i, j int) bool {141+ if world.Presentation.Legend[i].Order != world.Presentation.Legend[j].Order {142+ return world.Presentation.Legend[i].Order < world.Presentation.Legend[j].Order143+ }144+ return world.Presentation.Legend[i].Layer < world.Presentation.Legend[j].Layer145+ })129146 if err := relationshipClosure(world); err != nil {130147 return vnext.Volume{}, err131148 }132149 return vnext.Volume{ID: project.ID, Title: project.Title, Worlds: []vnext.World{world}}, nil133150 }134151 152+func geometryClaim(family string) vnext.Property {153+ field := vnext.Field{154+ ID: vnext.IDFromName("dev.atlas.attribute.featureSet", semconv.KeyGeometryKind),155+ Name: semconv.KeyGeometryKind, Kind: vnext.KindString,156+ }157+ return vnext.Property{FieldID: field.ID, Field: field, Value: vnext.StringValue(family)}158+}159+160+func appendProvenance(values []vnext.Provenance, item vnext.Provenance) []vnext.Provenance {161+ for _, value := range values {162+ if value == item {163+ return values164+ }165+ }166+ return append(values, item)167+}168+169+func appendRelationship(values []vnext.Relationship, item vnext.Relationship) []vnext.Relationship {170+ for _, value := range values {171+ if value == item {172+ return values173+ }174+ }175+ return append(values, item)176+}177+135178 func fieldByID(fields []vnext.Field, id vnext.ID) (vnext.Field, bool) {136179 for _, field := range fields {137180 if field.ID == id {
@@ -167,7 +210,7 @@ func featureID(world, set, native string) string {
167210 return setID(world, set) + "/feature/" + hex.EncodeToString(digest[:12])168211 }169212 170-func sortedSetNames(values map[string]*vnext.FeatureSet) []string {213+func sortedSetNames(values map[string]int) []string {171214 out := make([]string, 0, len(values))172215 for key := range values {173216 out = append(out, key)
A internal/authoring/assets.go +35 -0
M internal/authoring/build.go +24 -2
@@ -88,6 +88,9 @@ func Build(ctx context.Context, options BuildOptions) (BuildResult, error) {
8888 if err != nil {8989 return result, err9090 }91+ if err := attachAssets(project, plan, captures, cache, &volume); err != nil {92+ return result, err93+ }9194 rasterBlobs, err := attachRasters(project, plan, captures, cache, &volume.Worlds[0])9295 if err != nil {9396 return result, err
@@ -147,6 +150,17 @@ func attachRasters(project Project, plan Plan, captures map[string]Capture, cach
147150 if len(requests) == 0 {148151 return nil, fmt.Errorf("raster %s has no planned requests", source.ID)149152 }153+ if source.Adapter == "raster-file" {154+ capture := captures[requests[0].ID]155+ pyramid, derived, err := deriveRasterFile(source, capture, cache)156+ if err != nil {157+ return nil, err158+ }159+ pyramid.ID = world.ID + "/raster/" + source.ID160+ world.RasterPyramids = append(world.RasterPyramids, pyramid)161+ blobs = append(blobs, derived...)162+ continue163+ }150164 sort.Slice(requests, func(i, j int) bool {151165 if requests[i].Zoom != requests[j].Zoom {152166 return requests[i].Zoom < requests[j].Zoom
@@ -162,6 +176,9 @@ func attachRasters(project Project, plan Plan, captures map[string]Capture, cach
162176 for _, request := range requests {163177 capture := captures[request.ID]164178 format := formatOf(capture.Body.MediaType, request.IdentityLocator)179+ if held := formats[request.Zoom-minZoom]; held != "" && held != format {180+ return nil, fmt.Errorf("raster %s mixes formats at zoom %d", source.ID, request.Zoom)181+ }165182 formats[request.Zoom-minZoom] = format166183 codec = capture.Body.MediaType167184 blobs = append(blobs, vnext.Blob{
@@ -174,11 +191,16 @@ func attachRasters(project Project, plan Plan, captures map[string]Capture, cach
174191 return nil, fmt.Errorf("raster %s omits zoom %d", source.ID, int64(index)+minZoom)175192 }176193 }194+ fullZoom := source.FullZoom195+ if fullZoom == 0 {196+ fullZoom = maxZoom197+ }177198 world.RasterPyramids = append(world.RasterPyramids, vnext.RasterPyramid{178199 ID: world.ID + "/raster/" + source.ID, Name: source.Name, Codec: codec,179- TileSize: source.TileSize, MinZoom: minZoom, MaxZoom: maxZoom, FullZoom: maxZoom,200+ TileSize: source.TileSize, MinZoom: minZoom, MaxZoom: maxZoom, FullZoom: fullZoom,180201 SourceZoom: source.SourceZoom, Template: "tiles/" + source.ID + "/{z}/{x}/{y}.{format}",181- Formats: formats, Interpolate: source.Interpolate, Background: source.Background,202+ Formats: formats, Bounds: nativeRect(source.Bounds), Surface: nativeRect(source.Surface),203+ Interpolate: source.Interpolate, Background: source.Background, Shard: source.Shard,182204 })183205 }184206 sort.Slice(world.RasterPyramids, func(i, j int) bool { return world.RasterPyramids[i].ID < world.RasterPyramids[j].ID })
M internal/authoring/build_test.go +5 -0
M internal/authoring/manifest.go +165 -19
@@ -9,6 +9,7 @@ import (
99 "errors"1010 "fmt"1111 "io"12+ "math"1213 "os"1314 "path/filepath"1415 "strings"
@@ -18,7 +19,7 @@ import (
1819 )1920 2021 const (21- ProjectSchema = "atlas-project/v1"22+ ProjectSchema = "atlas-project/v2"2223 ProjectExtension = ".atlas-project"2324 )2425
@@ -31,6 +32,7 @@ type Project struct {
3132 Target Target `yaml:"target"`3233 Sources []Source `yaml:"sources,omitempty"`3334 Rasters []Raster `yaml:"rasters,omitempty"`35+ Assets []Asset `yaml:"assets,omitempty"`3436 Presentation Presentation `yaml:"presentation"`3537 Release Release `yaml:"release,omitempty"`3638
@@ -87,10 +89,25 @@ type Mapping struct {
8789 SemanticType string `yaml:"semantic-type"`8890 Identity string `yaml:"identity"`8991 FeatureTitle string `yaml:"feature-title"`92+ Geometry GeometryMap `yaml:"geometry"`9093 Fields []Field `yaml:"fields,omitempty"`9194 Relations []RelationMap `yaml:"relationships,omitempty"`9295 }9396 97+// GeometryMap is the source-to-world geometry contract for one FeatureSet.98+// Atlas intentionally executes only explicit identity and affine transforms;99+// a CRS label alone never implies executable projection behavior.100+type GeometryMap struct {101+ Family string `yaml:"family"`102+ SourceSpace string `yaml:"source-space"`103+ Transform CoordinateTransform `yaml:"transform"`104+}105+106+type CoordinateTransform struct {107+ Kind string `yaml:"kind"`108+ Matrix [6]float64 `yaml:"matrix,omitempty"`109+}110+94111 type Field struct {95112 ID string `yaml:"id"`96113 Name string `yaml:"name,omitempty"`
@@ -103,10 +120,12 @@ type RelationMap struct {
103120 Predicate string `yaml:"predicate"`104121 FeatureSet string `yaml:"feature-set,omitempty"`105122 Target string `yaml:"target"`123+ Optional bool `yaml:"optional,omitempty"`106124 }107125 108-// Raster is a configured raster adapter. A file is one tile at zoom zero; an109-// xyz source names explicit tile windows so planning never probes the network.126+// Raster is a configured raster adapter. A file is deterministically expanded127+// through MaxZoom; an xyz source names explicit tile windows so planning never128+// probes the network.110129 type Raster struct {111130 ID string `yaml:"id"`112131 Name string `yaml:"name"`
@@ -118,11 +137,23 @@ type Raster struct {
118137 EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`119138 TileSize int64 `yaml:"tile-size"`120139 SourceZoom int64 `yaml:"source-zoom,omitempty"`140+ MaxZoom int64 `yaml:"max-zoom,omitempty"`141+ FullZoom int64 `yaml:"full-zoom,omitempty"`121142 Interpolate bool `yaml:"interpolate,omitempty"`122143 Background string `yaml:"background,omitempty"`144+ Bounds *RasterRect `yaml:"bounds,omitempty"`145+ Surface *RasterRect `yaml:"surface,omitempty"`146+ Shard int64 `yaml:"shard,omitempty"`123147 Levels []RasterLevel `yaml:"levels,omitempty"`124148 }125149 150+type RasterRect struct {151+ X float64 `yaml:"x"`152+ Y float64 `yaml:"y"`153+ Width float64 `yaml:"width"`154+ Height float64 `yaml:"height"`155+}156+126157 type RasterLevel struct {127158 Zoom int64 `yaml:"zoom"`128159 MinX int64 `yaml:"min-x"`
@@ -138,13 +169,26 @@ type Presentation struct {
138169 Layers []Layer `yaml:"layers,omitempty"`139170 }140171 172+// Asset is authored supporting content packed into the immutable Atlas file.173+type Asset struct {174+ ID string `yaml:"id"`175+ Locator string `yaml:"locator"`176+ MediaType string `yaml:"media-type"`177+ Provenance string `yaml:"provenance,omitempty"`178+ License string `yaml:"license,omitempty"`179+ Attribution string `yaml:"attribution,omitempty"`180+ EstimateBytes int64 `yaml:"estimate-bytes,omitempty"`181+}182+141183 type Style struct {142- ID string `yaml:"id"`143- Symbol string `yaml:"symbol,omitempty"`144- Icon string `yaml:"icon,omitempty"`145- RenderAs string `yaml:"render-as,omitempty"`146- Stroke string `yaml:"stroke,omitempty"`147- Fill string `yaml:"fill,omitempty"`184+ ID string `yaml:"id"`185+ Symbol string `yaml:"symbol,omitempty"`186+ Icon string `yaml:"icon,omitempty"`187+ IconAsset string `yaml:"icon-asset,omitempty"`188+ IconPicture bool `yaml:"icon-picture,omitempty"`189+ RenderAs string `yaml:"render-as,omitempty"`190+ Stroke string `yaml:"stroke,omitempty"`191+ Fill string `yaml:"fill,omitempty"`148192 }149193 150194 type Layer struct {
@@ -226,7 +270,7 @@ func (project Project) Validate() error {
226270 sets := make(map[string]Mapping)227271 sourceIDs := make(map[string]bool)228272 for _, source := range project.Sources {229- if err := validateSource(source); err != nil {273+ if err := validateSource(source, project.Target.CoordinateSpace); err != nil {230274 return err231275 }232276 if sourceIDs[source.ID] {
@@ -234,13 +278,24 @@ func (project Project) Validate() error {
234278 }235279 sourceIDs[source.ID] = true236280 if held, ok := sets[source.Mapping.FeatureSet]; ok {237- if held.Title != source.Mapping.Title || held.SemanticType != source.Mapping.SemanticType {281+ if err := compatibleMapping(held, source.Mapping); err != nil {238282 return fmt.Errorf("feature set %s has conflicting declarations", source.Mapping.FeatureSet)239283 }240284 } else {241285 sets[source.Mapping.FeatureSet] = source.Mapping242286 }243287 }288+ for _, source := range project.Sources {289+ for _, relation := range source.Mapping.Relations {290+ target := relation.FeatureSet291+ if target == "" {292+ target = source.Mapping.FeatureSet293+ }294+ if _, ok := sets[target]; !ok {295+ return fmt.Errorf("source %s relationship %s targets unknown feature set %s", source.ID, relation.Predicate, target)296+ }297+ }298+ }244299 rasterIDs := make(map[string]bool)245300 for _, raster := range project.Rasters {246301 if err := validateRaster(raster); err != nil {
@@ -251,7 +306,17 @@ func (project Project) Validate() error {
251306 }252307 rasterIDs[raster.ID] = true253308 }254- return validatePresentation(project.Presentation, sets)309+ assetIDs := make(map[string]bool)310+ for _, asset := range project.Assets {311+ if err := validateAsset(asset); err != nil {312+ return err313+ }314+ if assetIDs[asset.ID] {315+ return fmt.Errorf("asset %s is configured twice", asset.ID)316+ }317+ assetIDs[asset.ID] = true318+ }319+ return validatePresentation(project.Presentation, sets, assetIDs)255320 }256321 257322 func validateTarget(target Target) error {
@@ -271,7 +336,7 @@ func validateTarget(target Target) error {
271336 return nil272337 }273338 274-func validateSource(source Source) error {339+func validateSource(source Source, target CoordinateSpace) error {275340 if err := vnext.ValidSlug(source.ID); err != nil {276341 return fmt.Errorf("source identity: %w", err)277342 }
@@ -288,6 +353,9 @@ func validateSource(source Source) error {
288353 if mapping.Title == "" || mapping.SemanticType == "" || mapping.Identity == "" || mapping.FeatureTitle == "" {289354 return fmt.Errorf("source %s has an incomplete mapping", source.ID)290355 }356+ if err := validateGeometryMap(mapping.Geometry, target); err != nil {357+ return fmt.Errorf("source %s geometry: %w", source.ID, err)358+ }291359 fields := make(map[string]string)292360 for _, field := range mapping.Fields {293361 if field.ID == "" || field.Source == "" || field.Type == "" {
@@ -296,8 +364,8 @@ func validateSource(source Source) error {
296364 if _, err := kindOf(field.Type); err != nil {297365 return fmt.Errorf("source %s field %s: %w", source.ID, field.ID, err)298366 }299- if held := fields[field.ID]; held != "" && held != field.Type {300- return fmt.Errorf("source %s field %s has conflicting types", source.ID, field.ID)367+ if held := fields[field.ID]; held != "" {368+ return fmt.Errorf("source %s maps field %s twice", source.ID, field.ID)301369 }302370 fields[field.ID] = field.Type303371 }
@@ -309,16 +377,68 @@ func validateSource(source Source) error {
309377 return nil310378 }311379 380+func validateGeometryMap(mapping GeometryMap, target CoordinateSpace) error {381+ switch mapping.Family {382+ case "point", "path", "area":383+ default:384+ return fmt.Errorf("unknown geometry family %q", mapping.Family)385+ }386+ if strings.TrimSpace(mapping.SourceSpace) == "" {387+ return fmt.Errorf("geometry has no source space")388+ }389+ switch mapping.Transform.Kind {390+ case "identity":391+ if mapping.SourceSpace != target.ID && mapping.SourceSpace != target.Definition {392+ return fmt.Errorf("identity transform source space %q is not target %q", mapping.SourceSpace, target.ID)393+ }394+ case "affine":395+ for _, value := range mapping.Transform.Matrix {396+ if math.IsNaN(value) || math.IsInf(value, 0) {397+ return fmt.Errorf("affine transform has a non-finite coefficient")398+ }399+ }400+ matrix := mapping.Transform.Matrix401+ if matrix[0]*matrix[4]-matrix[1]*matrix[3] == 0 {402+ return fmt.Errorf("affine transform is singular")403+ }404+ default:405+ return fmt.Errorf("unknown transform %q", mapping.Transform.Kind)406+ }407+ return nil408+}409+410+func compatibleMapping(left, right Mapping) error {411+ if left.Title != right.Title || left.SemanticType != right.SemanticType || left.Geometry.Family != right.Geometry.Family {412+ return fmt.Errorf("feature set metadata differs")413+ }414+ fields := make(map[string]Field, len(left.Fields))415+ for _, field := range left.Fields {416+ fields[field.ID] = field417+ }418+ for _, field := range right.Fields {419+ if held, ok := fields[field.ID]; ok && (held.Name != field.Name || held.Type != field.Type || held.Optional != field.Optional) {420+ return fmt.Errorf("field %s contract differs", field.ID)421+ }422+ }423+ return nil424+}425+312426 func validateRaster(raster Raster) error {313427 if err := vnext.ValidSlug(raster.ID); err != nil {314428 return fmt.Errorf("raster identity: %w", err)315429 }316- if raster.Name == "" || raster.Adapter == "" || raster.Locator == "" || raster.TileSize <= 0 || raster.EstimateBytes < 0 {430+ if raster.Name == "" || raster.Adapter == "" || raster.Locator == "" || raster.TileSize <= 0 || raster.EstimateBytes < 0 || raster.MaxZoom < 0 || raster.FullZoom < 0 || raster.Shard < 0 {317431 return fmt.Errorf("raster %s is incomplete", raster.ID)318432 }433+ if raster.Bounds != nil && !validRasterRect(*raster.Bounds) {434+ return fmt.Errorf("raster %s has invalid bounds", raster.ID)435+ }436+ if raster.Surface != nil && !validRasterRect(*raster.Surface) {437+ return fmt.Errorf("raster %s has invalid surface", raster.ID)438+ }319439 switch raster.Adapter {320440 case "raster-file":321- if len(raster.Levels) != 0 {441+ if len(raster.Levels) != 0 || raster.MaxZoom > 6 || (raster.FullZoom != 0 && raster.FullZoom > raster.MaxZoom) {322442 return fmt.Errorf("raster-file %s must not declare xyz levels", raster.ID)323443 }324444 case "xyz":
@@ -327,7 +447,11 @@ func validateRaster(raster Raster) error {
327447 }328448 previous := int64(-1)329449 for _, level := range raster.Levels {330- if level.Zoom <= previous || level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY {450+ if level.Zoom != previous+1 || level.Zoom < 0 || level.Zoom > 30 {451+ return fmt.Errorf("xyz raster %s has an invalid or unordered level", raster.ID)452+ }453+ maximum := int64(1)<<level.Zoom - 1454+ if level.MinX < 0 || level.MinY < 0 || level.MaxX < level.MinX || level.MaxY < level.MinY || level.MaxX > maximum || level.MaxY > maximum {331455 return fmt.Errorf("xyz raster %s has an invalid or unordered level", raster.ID)332456 }333457 previous = level.Zoom
@@ -338,7 +462,26 @@ func validateRaster(raster Raster) error {
338462 return nil339463 }340464 341-func validatePresentation(presentation Presentation, sets map[string]Mapping) error {465+func validRasterRect(rect RasterRect) bool {466+ return finite(rect.X) && finite(rect.Y) && finite(rect.Width) && finite(rect.Height) && rect.Width > 0 && rect.Height > 0467+}468+469+func finite(value float64) bool { return !math.IsNaN(value) && !math.IsInf(value, 0) }470+471+func validateAsset(asset Asset) error {472+ if err := vnext.ValidSlug(asset.ID); err != nil {473+ return fmt.Errorf("asset identity: %w", err)474+ }475+ if asset.ID == "build-receipt" {476+ return fmt.Errorf("asset build-receipt is reserved")477+ }478+ if asset.Locator == "" || asset.MediaType == "" || asset.EstimateBytes < 0 {479+ return fmt.Errorf("asset %s is incomplete", asset.ID)480+ }481+ return nil482+}483+484+func validatePresentation(presentation Presentation, sets map[string]Mapping, assets map[string]bool) error {342485 if presentation.ID == "" || presentation.Title == "" {343486 return fmt.Errorf("presentation requires an ID and title")344487 }
@@ -347,6 +490,9 @@ func validatePresentation(presentation Presentation, sets map[string]Mapping) er
347490 if style.ID == "" || styles[style.ID] {348491 return fmt.Errorf("presentation has an invalid or duplicate style")349492 }493+ if style.IconAsset != "" && !assets[style.IconAsset] {494+ return fmt.Errorf("presentation style %s refers to unknown asset %s", style.ID, style.IconAsset)495+ }350496 styles[style.ID] = true351497 }352498 layers := make(map[string]bool)
M internal/authoring/manifest_test.go +70 -1
@@ -7,7 +7,7 @@ import (
77 "testing"88 )99 10-const sampleProject = `schema: atlas-project/v110+const sampleProject = `schema: atlas-project/v21111 schema-namespace: example.invalid/atlas/sample-region1212 id: sample-region1313 title: Sample Region
@@ -34,6 +34,11 @@ sources:
3434 semantic-type: sample.place3535 identity: properties.id3636 feature-title: properties.name37+ geometry:38+ family: point39+ source-space: sample-region/space40+ transform:41+ kind: identity3742 fields:3843 - id: sample.kind3944 source: properties.kind
@@ -109,3 +114,67 @@ func TestProjectRejectsConflictingFeatureSets(t *testing.T) {
109114 t.Fatalf("Validate = %v, want feature-set conflict", err)110115 }111116 }117+118+func TestProjectRejectsGeometryAndRelationshipContractErrors(t *testing.T) {119+ tests := []struct {120+ name string121+ change func(*Project)122+ mention string123+ }{124+ {125+ name: "unknown geometry family",126+ change: func(project *Project) {127+ project.Sources[0].Mapping.Geometry.Family = "mesh"128+ },129+ mention: "unknown geometry family",130+ },131+ {132+ name: "identity crosses spaces",133+ change: func(project *Project) {134+ project.Sources[0].Mapping.Geometry.SourceSpace = "another-space"135+ },136+ mention: "identity transform source space",137+ },138+ {139+ name: "singular affine",140+ change: func(project *Project) {141+ project.Sources[0].Mapping.Geometry.SourceSpace = "source-space"142+ project.Sources[0].Mapping.Geometry.Transform = CoordinateTransform{Kind: "affine"}143+ },144+ mention: "singular",145+ },146+ {147+ name: "unknown relationship feature set",148+ change: func(project *Project) {149+ project.Sources[0].Mapping.Relations = []RelationMap{{Predicate: "within", FeatureSet: "missing", Target: "properties.parent"}}150+ },151+ mention: "unknown feature set",152+ },153+ {154+ name: "unknown style asset",155+ change: func(project *Project) {156+ project.Presentation.Styles[0].IconAsset = "missing"157+ },158+ mention: "unknown asset",159+ },160+ }161+ for _, test := range tests {162+ t.Run(test.name, func(t *testing.T) {163+ project, err := LoadProject(writeSampleProject(t, sampleProject))164+ if err != nil {165+ t.Fatal(err)166+ }167+ test.change(&project)168+ if err := project.Validate(); err == nil || !strings.Contains(err.Error(), test.mention) {169+ t.Fatalf("Validate = %v, want %q", err, test.mention)170+ }171+ })172+ }173+}174+175+func TestProjectV1IsAHardBreak(t *testing.T) {176+ legacy := strings.Replace(sampleProject, ProjectSchema, "atlas-project/v1", 1)177+ if _, err := LoadProject(writeSampleProject(t, legacy)); err == nil || !strings.Contains(err.Error(), ProjectSchema) {178+ t.Fatalf("LoadProject = %v, want v2 hard break", err)179+ }180+}
M internal/authoring/observe.go +11 -3
@@ -20,6 +20,7 @@ type observation struct {
2020 FeatureSet string2121 SetTitle string2222 Semantic string23+ Family string2324 Title string2425 Geometry vnext.Geometry2526 Fields map[string]vnext.Field
@@ -82,10 +83,14 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
8283 if err != nil {8384 return nil, fmt.Errorf("source %s feature %s: %w", source.ID, nativeID, err)8485 }86+ geometry, err = transformGeometry(geometry, source.Mapping.Geometry, project.Target.CoordinateSpace)87+ if err != nil {88+ return nil, fmt.Errorf("source %s feature %s: %w", source.ID, nativeID, err)89+ }8590 item := observation{8691 SourceOrder: sourceOrder, Source: source.ID, NativeID: nativeID,8792 FeatureSet: source.Mapping.FeatureSet, SetTitle: source.Mapping.Title,88- Semantic: source.Mapping.SemanticType, Title: scalarString(titleValue),93+ Semantic: source.Mapping.SemanticType, Family: source.Mapping.Geometry.Family, Title: scalarString(titleValue),8994 Geometry: geometry, Fields: make(map[string]vnext.Field), Values: make(map[string]vnext.Value),9095 Evidence: capture,9196 }
@@ -107,7 +112,7 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
107112 name = mapping.ID108113 }109114 item.Fields[mapping.ID] = vnext.Field{110- ID: vnext.IDFromName(project.SchemaNamespace, "feature."+mapping.ID),115+ ID: vnext.IDFromName(project.SchemaNamespace, "feature."+source.Mapping.FeatureSet+"."+mapping.ID),111116 Name: name, Kind: kind, Optional: mapping.Optional,112117 }113118 item.Values[mapping.ID] = value
@@ -115,7 +120,10 @@ func observe(project Project, sourceOrder int, source Source, capture Capture, b
115120 for _, mapping := range source.Mapping.Relations {116121 raw, held := featureValue(feature, mapping.Target)117122 if !held || scalarString(raw) == "" {118- continue123+ if mapping.Optional {124+ continue125+ }126+ return nil, fmt.Errorf("source %s feature %s omits relationship %s", source.ID, nativeID, mapping.Predicate)119127 }120128 set := mapping.FeatureSet121129 if set == "" {
M internal/authoring/plan.go +29 -4
@@ -17,6 +17,7 @@ type RequestKind string
1717 const (1818 RequestFeatures RequestKind = "features"1919 RequestRaster RequestKind = "raster"20+ RequestAsset RequestKind = "asset"2021 )2122 2223 // Request is one exact acquisition planned without touching the network.
@@ -31,6 +32,7 @@ type Request struct {
3132 License string `json:"license,omitempty"`3233 Attribution string `json:"attribution,omitempty"`3334 Raster string `json:"raster,omitempty"`35+ Asset string `json:"asset,omitempty"`3436 Zoom int64 `json:"zoom,omitempty"`3537 X int64 `json:"x,omitempty"`3638 Y int64 `json:"y,omitempty"`
@@ -102,6 +104,14 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
102104 plan.Estimated += raster.EstimateBytes103105 plan.Licenses = append(plan.Licenses, Obligation{Source: raster.ID, License: raster.License, Attribution: raster.Attribution})104106 }107+ for _, asset := range project.Assets {108+ request := assetRequest(project, asset)109+ planned := PlannedRequest{Request: request, EstimateBytes: asset.EstimateBytes}110+ planned.Cached = cache != nil && cache.Has(request.ID)111+ plan.Requests = append(plan.Requests, planned)112+ plan.Estimated += asset.EstimateBytes113+ plan.Licenses = append(plan.Licenses, Obligation{Source: asset.ID, License: asset.License, Attribution: asset.Attribution})114+ }105115 sort.Slice(plan.Requests, func(i, j int) bool { return plan.Requests[i].ID < plan.Requests[j].ID })106116 for _, request := range plan.Requests {107117 if request.Cached {
@@ -112,6 +122,17 @@ func PlanProject(project Project, cache *Cache, output string) (Plan, error) {
112122 return plan, nil113123 }114124 125+func assetRequest(project Project, asset Asset) Request {126+ request := Request{127+ Kind: RequestAsset, Source: asset.ID, Adapter: "asset-file",128+ Locator: project.ResolveLocator(asset.Locator), IdentityLocator: asset.Locator,129+ MediaType: asset.MediaType, License: asset.License, Attribution: asset.Attribution,130+ Asset: asset.ID,131+ }132+ request.ID = requestID(request)133+ return request134+}135+115136 func featureRequest(project Project, source Source) (Request, error) {116137 identity := source.Locator117138 locator := project.ResolveLocator(identity)
@@ -151,7 +172,11 @@ func featureRequest(project Project, source Source) (Request, error) {
151172 }152173 parsed.Path = strings.TrimSuffix(parsed.Path, "/") + "/collections/" + url.PathEscape(source.Query.Collection) + "/items"153174 query := parsed.Query()154- query.Set("bbox", extentString(project.Target.CoordinateSpace.Extent))175+ extent, err := sourceExtent(source.Mapping.Geometry, project.Target.CoordinateSpace)176+ if err != nil {177+ return Request{}, fmt.Errorf("source %s query extent: %w", source.ID, err)178+ }179+ query.Set("bbox", extentString(extent))155180 query.Set("f", "json")156181 if source.Query.Limit > 0 {157182 query.Set("limit", strconv.Itoa(source.Query.Limit))
@@ -198,9 +223,9 @@ func rasterRequests(project Project, raster Raster) ([]Request, error) {
198223 199224 func requestID(request Request) string {200225 canonical := struct {201- Kind, Source, Adapter, Locator, Raster string202- Zoom, X, Y int64203- }{string(request.Kind), request.Source, request.Adapter, request.IdentityLocator, request.Raster, request.Zoom, request.X, request.Y}226+ Kind, Source, Adapter, Locator, Raster, Asset string227+ Zoom, X, Y int64228+ }{string(request.Kind), request.Source, request.Adapter, request.IdentityLocator, request.Raster, request.Asset, request.Zoom, request.X, request.Y}204229 data, _ := json.Marshal(canonical)205230 digest := sha256.Sum256(data)206231 return hex.EncodeToString(digest[:])
A internal/authoring/raster.go +125 -0
@@ -0,0 +1,125 @@
1+package authoring2+3+import (4+ "bufio"5+ "bytes"6+ "fmt"7+ "image"8+ "image/color"9+ _ "image/gif"10+ _ "image/jpeg"11+ "image/png"12+ "os"13+14+ "github.com/FelineStateMachine/atlas/format/vnext"15+)16+17+func deriveRasterFile(source Raster, capture Capture, cache *Cache) (vnext.RasterPyramid, []vnext.Blob, error) {18+ data, err := os.ReadFile(cache.BlobPath(capture.Body))19+ if err != nil {20+ return vnext.RasterPyramid{}, nil, fmt.Errorf("read raster %s: %w", source.ID, err)21+ }22+ decoded, err := decodeRaster(data)23+ if err != nil {24+ return vnext.RasterPyramid{}, nil, fmt.Errorf("decode raster %s: %w", source.ID, err)25+ }26+ formats := make([]string, source.MaxZoom+1)27+ for index := range formats {28+ formats[index] = "png"29+ }30+ fullZoom := source.FullZoom31+ if fullZoom == 0 {32+ fullZoom = source.MaxZoom33+ }34+ pyramid := vnext.RasterPyramid{35+ ID: "sample", Name: source.Name, Codec: "image/png", TileSize: source.TileSize,36+ MinZoom: 0, MaxZoom: source.MaxZoom, FullZoom: fullZoom, SourceZoom: source.SourceZoom,37+ Template: "tiles/" + source.ID + "/{z}/{x}/{y}.{format}", Formats: formats,38+ Bounds: nativeRect(source.Bounds), Surface: nativeRect(source.Surface),39+ Interpolate: source.Interpolate, Background: source.Background, Shard: source.Shard,40+ }41+ var blobs []vnext.Blob42+ for zoom := int64(0); zoom <= source.MaxZoom; zoom++ {43+ tiles := int64(1) << zoom44+ for y := int64(0); y < tiles; y++ {45+ for x := int64(0); x < tiles; x++ {46+ data, err := renderRasterTile(decoded, source.TileSize, zoom, x, y)47+ if err != nil {48+ return vnext.RasterPyramid{}, nil, err49+ }50+ blobs = append(blobs, vnext.Blob{51+ Name: fmt.Sprintf("tiles/%s/%d/%d/%d.png", source.ID, zoom, x, y), Data: data,52+ })53+ }54+ }55+ }56+ return pyramid, blobs, nil57+}58+59+func decodeRaster(data []byte) (image.Image, error) {60+ decoded, _, err := image.Decode(bytes.NewReader(data))61+ if err == nil {62+ return decoded, nil63+ }64+ return decodeP3(data)65+}66+67+func decodeP3(data []byte) (image.Image, error) {68+ reader := bufio.NewReader(bytes.NewReader(data))69+ var magic string70+ var width, height, maximum int71+ if _, err := fmt.Fscan(reader, &magic, &width, &height, &maximum); err != nil {72+ return nil, err73+ }74+ if magic != "P3" || width <= 0 || height <= 0 || maximum <= 0 || maximum > 65535 {75+ return nil, fmt.Errorf("unsupported raster encoding")76+ }77+ out := image.NewNRGBA(image.Rect(0, 0, width, height))78+ for y := 0; y < height; y++ {79+ for x := 0; x < width; x++ {80+ var red, green, blue int81+ if _, err := fmt.Fscan(reader, &red, &green, &blue); err != nil {82+ return nil, fmt.Errorf("read pixel %d,%d: %w", x, y, err)83+ }84+ if red < 0 || red > maximum || green < 0 || green > maximum || blue < 0 || blue > maximum {85+ return nil, fmt.Errorf("pixel %d,%d exceeds raster range", x, y)86+ }87+ out.SetNRGBA(x, y, color.NRGBA{88+ R: uint8(red * 255 / maximum), G: uint8(green * 255 / maximum), B: uint8(blue * 255 / maximum), A: 255,89+ })90+ }91+ }92+ return out, nil93+}94+95+func renderRasterTile(source image.Image, tileSize, zoom, tileX, tileY int64) ([]byte, error) {96+ tile := image.NewNRGBA(image.Rect(0, 0, int(tileSize), int(tileSize)))97+ bounds := source.Bounds()98+ span := tileSize * (int64(1) << zoom)99+ for y := int64(0); y < tileSize; y++ {100+ for x := int64(0); x < tileSize; x++ {101+ sourceX := bounds.Min.X + int((tileX*tileSize+x)*int64(bounds.Dx())/span)102+ sourceY := bounds.Min.Y + int((tileY*tileSize+y)*int64(bounds.Dy())/span)103+ if sourceX >= bounds.Max.X {104+ sourceX = bounds.Max.X - 1105+ }106+ if sourceY >= bounds.Max.Y {107+ sourceY = bounds.Max.Y - 1108+ }109+ tile.Set(int(x), int(y), source.At(sourceX, sourceY))110+ }111+ }112+ var out bytes.Buffer113+ encoder := png.Encoder{CompressionLevel: png.BestCompression}114+ if err := encoder.Encode(&out, tile); err != nil {115+ return nil, fmt.Errorf("encode canonical raster tile: %w", err)116+ }117+ return out.Bytes(), nil118+}119+120+func nativeRect(source *RasterRect) *vnext.RasterRect {121+ if source == nil {122+ return nil123+ }124+ return &vnext.RasterRect{X: source.X, Y: source.Y, Width: source.Width, Height: source.Height}125+}
A internal/authoring/representative_test.go +192 -0
@@ -0,0 +1,192 @@
1+package authoring2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "image/png"8+ "os"9+ "path/filepath"10+ "strings"11+ "testing"12+13+ "github.com/FelineStateMachine/atlas/format/vnext"14+)15+16+func TestBuildRepresentativeWorldExercisesTheNativeContract(t *testing.T) {17+ projectPath := filepath.Join("..", "..", "examples", "sample-region"+ProjectExtension)18+ cacheDir := filepath.Join(t.TempDir(), "cache")19+ libraryDir := filepath.Join(t.TempDir(), "library")20+ result, err := Build(context.Background(), BuildOptions{21+ ProjectPath: projectPath,22+ CacheDir: cacheDir,23+ LibraryDir: libraryDir,24+ })25+ if err != nil {26+ t.Fatal(err)27+ }28+ reader, err := vnext.OpenFile(result.Path, vnext.StandardSchema())29+ if err != nil {30+ t.Fatal(err)31+ }32+ defer reader.Close()33+ volume, err := reader.Volume()34+ if err != nil {35+ t.Fatal(err)36+ }37+ if len(volume.Worlds) != 1 {38+ t.Fatalf("world count = %d, want 1", len(volume.Worlds))39+ }40+ world := volume.Worlds[0]41+ if len(world.FeatureSets) != 3 {42+ t.Fatalf("feature set count = %d, want point/path/area", len(world.FeatureSets))43+ }44+ wantKinds := map[vnext.GeometryKind]bool{45+ vnext.GeometryPoint: false,46+ vnext.GeometryLineString: false,47+ vnext.GeometryPolygon: false,48+ }49+ wantPropertyKinds := map[vnext.Kind]bool{50+ vnext.KindBool: false, vnext.KindInt64: false, vnext.KindFloat64: false,51+ vnext.KindString: false, vnext.KindBytes: false, vnext.KindID: false,52+ }53+ relationships, provenance := 0, 054+ featureIDs := make(map[string]bool)55+ transformed := make(map[vnext.Position]bool)56+ for _, set := range world.FeatureSets {57+ if len(set.Features) == 0 {58+ t.Fatalf("feature set %s is empty", set.ID)59+ }60+ kind := set.Features[0].Geometry.Kind61+ wantKinds[kind] = true62+ if !hasGeometryClaim(set.Claims, familyOf(kind)) {63+ t.Errorf("feature set %s omits geometry claim %s", set.ID, familyOf(kind))64+ }65+ for _, feature := range set.Features {66+ featureIDs[feature.ID] = true67+ if feature.Geometry.Kind != kind {68+ t.Fatalf("feature set %s mixes geometry %d and %d", set.ID, kind, feature.Geometry.Kind)69+ }70+ if len(feature.Provenance) == 0 {71+ t.Fatalf("feature %s has no provenance", feature.ID)72+ }73+ provenance += len(feature.Provenance)74+ relationships += len(feature.Relationships)75+ if feature.Geometry.Kind == vnext.GeometryPoint {76+ transformed[feature.Geometry.Parts[0].Rings[0][0]] = true77+ }78+ for _, property := range feature.Properties {79+ wantPropertyKinds[property.Value.Kind] = true80+ }81+ }82+ }83+ for _, position := range []vnext.Position{{40, 60}, {75, 35}} {84+ if !transformed[position] {85+ t.Errorf("affine transform omits point %v", position)86+ }87+ }88+ for kind, seen := range wantKinds {89+ if !seen {90+ t.Errorf("geometry kind %d is not represented", kind)91+ }92+ }93+ for kind, seen := range wantPropertyKinds {94+ if !seen {95+ t.Errorf("property kind %d is not represented", kind)96+ }97+ }98+ if relationships == 0 || provenance < 4 {99+ t.Fatalf("relationships=%d provenance=%d, want a related evidence graph", relationships, provenance)100+ }101+ for _, set := range world.FeatureSets {102+ for _, feature := range set.Features {103+ for _, relationship := range feature.Relationships {104+ if !featureIDs[relationship.Target] {105+ t.Errorf("feature %s targets missing %s", feature.ID, relationship.Target)106+ }107+ }108+ }109+ }110+ if len(world.RasterPyramids) != 1 {111+ t.Fatalf("raster pyramid count = %d, want 1", len(world.RasterPyramids))112+ }113+ raster := world.RasterPyramids[0]114+ if raster.MinZoom != 0 || raster.MaxZoom != 1 || raster.Formats[0] != "png" || raster.Formats[1] != "png" {115+ t.Fatalf("raster pyramid = %+v, want canonical PNG zooms 0..1", raster)116+ }117+ blobNames := reader.BlobNames()118+ for _, name := range []string{"tiles/background/0/0/0.png", "tiles/background/1/0/0.png", "tiles/background/1/1/1.png"} {119+ if !containsString(blobNames, name) {120+ t.Errorf("native Atlas omits raster blob %s", name)121+ }122+ }123+ tile, err := reader.Blob("tiles/background/1/1/1.png")124+ if err != nil {125+ t.Fatal(err)126+ }127+ image, err := png.Decode(bytes.NewReader(tile))128+ if err != nil || image.Bounds().Dx() != 32 || image.Bounds().Dy() != 32 {129+ t.Fatalf("canonical tile = %v, %v", image, err)130+ }131+ if len(world.Presentation.Styles) != 3 || len(world.Presentation.Layers) != 3 || len(world.Presentation.Legend) != 3 {132+ t.Fatalf("presentation = %d styles, %d layers, %d legend entries", len(world.Presentation.Styles), len(world.Presentation.Layers), len(world.Presentation.Legend))133+ }134+ assets := make(map[string]vnext.Asset, len(volume.Assets))135+ for _, asset := range volume.Assets {136+ assets[asset.ID] = asset137+ }138+ if len(assets["sample-symbol"].Data) == 0 || len(assets["build-receipt"].Data) == 0 {139+ t.Fatalf("assets = %v, want authored symbol and build receipt", sortedAssetIDs(assets))140+ }141+ var receipt struct {142+ Captures []struct {143+ SHA256 string `json:"sha256"`144+ } `json:"captures"`145+ }146+ if err := json.Unmarshal(assets["build-receipt"].Data, &receipt); err != nil || len(receipt.Captures) != 5 {147+ t.Fatalf("build receipt captures = %d, %v", len(receipt.Captures), err)148+ }149+ firstBytes, err := os.ReadFile(result.Path)150+ if err != nil {151+ t.Fatal(err)152+ }153+ rebuilt, err := Build(context.Background(), BuildOptions{154+ ProjectPath: projectPath, CacheDir: cacheDir, LibraryDir: libraryDir, Offline: true,155+ })156+ if err != nil {157+ t.Fatal(err)158+ }159+ rebuiltBytes, err := os.ReadFile(rebuilt.Path)160+ if err != nil {161+ t.Fatal(err)162+ }163+ if rebuilt.Path != result.Path || !rebuilt.Present || !bytes.Equal(firstBytes, rebuiltBytes) {164+ t.Fatalf("offline rebuild differs: first=%s rebuilt=%s present=%v", result.Path, rebuilt.Path, rebuilt.Present)165+ }166+}167+168+func hasGeometryClaim(properties []vnext.Property, family string) bool {169+ for _, property := range properties {170+ if property.Field.Name == "atlas.geometry.kind" && property.Value.String == family {171+ return true172+ }173+ }174+ return false175+}176+177+func containsString(values []string, want string) bool {178+ for _, value := range values {179+ if value == want {180+ return true181+ }182+ }183+ return false184+}185+186+func sortedAssetIDs(values map[string]vnext.Asset) string {187+ ids := make([]string, 0, len(values))188+ for id := range values {189+ ids = append(ids, id)190+ }191+ return strings.Join(ids, ", ")192+}
A internal/authoring/transform.go +93 -0
@@ -0,0 +1,93 @@
1+package authoring2+3+import (4+ "fmt"5+ "math"6+7+ "github.com/FelineStateMachine/atlas/format/vnext"8+)9+10+func transformGeometry(geometry vnext.Geometry, mapping GeometryMap, target CoordinateSpace) (vnext.Geometry, error) {11+ if familyOf(geometry.Kind) != mapping.Family {12+ return vnext.Geometry{}, fmt.Errorf("geometry is %s, feature set requires %s", familyOf(geometry.Kind), mapping.Family)13+ }14+ out := vnext.Geometry{Kind: geometry.Kind, Parts: make([]vnext.GeometryPart, len(geometry.Parts))}15+ for partIndex, part := range geometry.Parts {16+ out.Parts[partIndex].Rings = make([][]vnext.Position, len(part.Rings))17+ for ringIndex, ring := range part.Rings {18+ positions := make([]vnext.Position, len(ring))19+ for positionIndex, position := range ring {20+ transformed := transformPosition(position, mapping.Transform)21+ if !inside(transformed, target.Extent) {22+ return vnext.Geometry{}, fmt.Errorf("transformed position %v falls outside target extent", transformed)23+ }24+ positions[positionIndex] = transformed25+ }26+ if geometry.Kind == vnext.GeometryPolygon && (len(positions) < 4 || positions[0] != positions[len(positions)-1]) {27+ return vnext.Geometry{}, fmt.Errorf("polygon ring is not closed")28+ }29+ out.Parts[partIndex].Rings[ringIndex] = positions30+ }31+ }32+ return out, nil33+}34+35+func sourceExtent(mapping GeometryMap, target CoordinateSpace) ([4]float64, error) {36+ if mapping.Transform.Kind == "identity" {37+ return target.Extent, nil38+ }39+ matrix := mapping.Transform.Matrix40+ determinant := matrix[0]*matrix[4] - matrix[1]*matrix[3]41+ if determinant == 0 {42+ return [4]float64{}, fmt.Errorf("affine transform is singular")43+ }44+ inverse := CoordinateTransform{Kind: "affine", Matrix: [6]float64{45+ matrix[4] / determinant,46+ -matrix[1] / determinant,47+ (matrix[1]*matrix[5] - matrix[4]*matrix[2]) / determinant,48+ -matrix[3] / determinant,49+ matrix[0] / determinant,50+ (matrix[3]*matrix[2] - matrix[0]*matrix[5]) / determinant,51+ }}52+ corners := []vnext.Position{53+ {target.Extent[0], target.Extent[1]}, {target.Extent[0], target.Extent[3]},54+ {target.Extent[2], target.Extent[1]}, {target.Extent[2], target.Extent[3]},55+ }56+ extent := [4]float64{math.Inf(1), math.Inf(1), math.Inf(-1), math.Inf(-1)}57+ for _, corner := range corners {58+ position := transformPosition(corner, inverse)59+ extent[0], extent[1] = math.Min(extent[0], position[0]), math.Min(extent[1], position[1])60+ extent[2], extent[3] = math.Max(extent[2], position[0]), math.Max(extent[3], position[1])61+ }62+ return extent, nil63+}64+65+func transformPosition(position vnext.Position, transform CoordinateTransform) vnext.Position {66+ if transform.Kind == "identity" {67+ return position68+ }69+ matrix := transform.Matrix70+ return vnext.Position{71+ matrix[0]*position[0] + matrix[1]*position[1] + matrix[2],72+ matrix[3]*position[0] + matrix[4]*position[1] + matrix[5],73+ }74+}75+76+func inside(position vnext.Position, extent [4]float64) bool {77+ return finite(position[0]) && finite(position[1]) &&78+ position[0] >= extent[0] && position[0] <= extent[2] &&79+ position[1] >= extent[1] && position[1] <= extent[3]80+}81+82+func familyOf(kind vnext.GeometryKind) string {83+ switch kind {84+ case vnext.GeometryPoint:85+ return "point"86+ case vnext.GeometryLineString:87+ return "path"88+ case vnext.GeometryPolygon:89+ return "area"90+ default:91+ return "unknown"92+ }93+}
A internal/authoring/transform_test.go +60 -0
@@ -0,0 +1,60 @@
1+package authoring2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/atlas/format/vnext"7+)8+9+func TestTransformGeometryAppliesAffineToEveryNestedPosition(t *testing.T) {10+ geometry := vnext.Geometry{Kind: vnext.GeometryPolygon, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{11+ {{1, 1}, {3, 1}, {3, 2}, {1, 1}},12+ }}}}13+ mapping := GeometryMap{14+ Family: "area", SourceSpace: "source",15+ Transform: CoordinateTransform{Kind: "affine", Matrix: [6]float64{10, 0, 5, 0, 20, 7}},16+ }17+ target := CoordinateSpace{Extent: [4]float64{0, 0, 100, 100}}18+ got, err := transformGeometry(geometry, mapping, target)19+ if err != nil {20+ t.Fatal(err)21+ }22+ want := []vnext.Position{{15, 27}, {35, 27}, {35, 47}, {15, 27}}23+ for index, position := range got.Parts[0].Rings[0] {24+ if position != want[index] {25+ t.Fatalf("position %d = %v, want %v", index, position, want[index])26+ }27+ }28+}29+30+func TestTransformGeometryRefusesFamilyExtentAndRingViolations(t *testing.T) {31+ target := CoordinateSpace{Extent: [4]float64{0, 0, 10, 10}}32+ tests := []struct {33+ name string34+ geometry vnext.Geometry35+ mapping GeometryMap36+ }{37+ {38+ name: "family mismatch",39+ geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{1, 1}}}}}},40+ mapping: GeometryMap{Family: "path", Transform: CoordinateTransform{Kind: "identity"}},41+ },42+ {43+ name: "outside extent",44+ geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{11, 1}}}}}},45+ mapping: GeometryMap{Family: "point", Transform: CoordinateTransform{Kind: "identity"}},46+ },47+ {48+ name: "open polygon",49+ geometry: vnext.Geometry{Kind: vnext.GeometryPolygon, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{1, 1}, {2, 1}, {2, 2}, {1, 2}}}}}},50+ mapping: GeometryMap{Family: "area", Transform: CoordinateTransform{Kind: "identity"}},51+ },52+ }53+ for _, test := range tests {54+ t.Run(test.name, func(t *testing.T) {55+ if _, err := transformGeometry(test.geometry, test.mapping, target); err == nil {56+ t.Fatal("transform accepted invalid geometry")57+ }58+ })59+ }60+}
M internal/workbench/operations_test.go +32 -3
@@ -19,7 +19,7 @@ func writeSampleProject(t *testing.T, dir string) string {
1919 t.Fatal(err)2020 }2121 project := filepath.Join(dir, "sample-region.atlas-project")22- manifest := `schema: atlas-project/v122+ manifest := `schema: atlas-project/v22323 schema-namespace: example.invalid/atlas/sample-region/v12424 id: sample-region2525 title: Sample Region
@@ -45,6 +45,11 @@ sources:
4545 semantic-type: place4646 identity: id4747 feature-title: properties.name48+ geometry:49+ family: point50+ source-space: local51+ transform:52+ kind: identity4853 presentation:4954 id: default5055 title: Default
@@ -158,9 +163,9 @@ func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {
158163 if err := os.MkdirAll(targets.Registry, 0o755); err != nil {159164 t.Fatal(err)160165 }161- artifact := filepath.Join(targets.Registry, "sample-region-abc.atlas")166+ artifact := bundleSpec{slug: "sample-region", worlds: []worldSpec{{slug: "sample-region"}}}.write(t, targets.Registry)162167 binary := filepath.Join(targets.Dir, "fake-atlas")163- script := "#!/bin/sh\nsleep 0.05\ntouch '" + artifact + "'\nprintf '%s\\n' '{\"time\":\"2026-01-01T00:00:00Z\",\"level\":\"INFO\",\"msg\":\"native build installed\",\"artifact\":\"" + artifact + "\"}' >&2\n"168+ script := "#!/bin/sh\nsleep 0.05\nprintf '%s\\n' '{\"time\":\"2026-01-01T00:00:00Z\",\"level\":\"INFO\",\"msg\":\"native build installed\",\"artifact\":\"" + artifact + "\"}' >&2\n"164169 if err := os.WriteFile(binary, []byte(script), 0o755); err != nil {165170 t.Fatal(err)166171 }
@@ -186,6 +191,11 @@ func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {
186191 if run.Running || run.Failed || run.Artifact != artifact {187192 t.Fatalf("run = %+v", run)188193 }194+ _, runBody := get(t, server, "/project/run")195+ wants(t, "completed run fragment", runBody, "Build complete", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))196+ if strings.Contains(runBody, `hx-get="/project/run"`) {197+ t.Fatal("completed run fragment kept polling")198+ }189199 _, body := get(t, server, "/project")190200 wants(t, "completed project", body, "Build complete", "Open in Atlas", "Drag this file onto an Atlas window", filepath.Base(artifact))191201 response, _ = postForm(t, noRedirectClient(server.Client()), server.URL+"/project/open", url.Values{})
@@ -198,6 +208,25 @@ func TestBuildSurvivesNavigationAndHandsOffTheArtifact(t *testing.T) {
198208 }199209 }200210 211+func TestArtifactHandoffRefusesAnInvalidAtlasFile(t *testing.T) {212+ targets := projectTargets(t)213+ if err := os.MkdirAll(targets.Registry, 0o755); err != nil {214+ t.Fatal(err)215+ }216+ artifact := filepath.Join(targets.Registry, "sample-region-invalid.atlas")217+ if err := os.WriteFile(artifact, []byte("not an Atlas file"), 0o644); err != nil {218+ t.Fatal(err)219+ }220+ held, err := New(Options{Targets: targets})221+ if err != nil {222+ t.Fatal(err)223+ }224+ held.supervisor.last = supervisedRun{Name: "build", Artifact: artifact}225+ if _, err := held.currentArtifact(); err == nil || !strings.Contains(err.Error(), "open completed Atlas artifact") {226+ t.Fatalf("currentArtifact = %v, want native validation failure", err)227+ }228+}229+201230 func TestProjectMutationsRefuseForeignOrigins(t *testing.T) {202231 held, err := New(Options{Targets: projectTargets(t)})203232 if err != nil {
M internal/workbench/project.go +16 -8
@@ -1,12 +1,14 @@
11 package workbench22 33 import (4+ "bytes"45 "fmt"56 "net/http"67 "os"78 "path/filepath"89 "strings"910 11+ "github.com/FelineStateMachine/atlas/format/vnext"1012 "github.com/FelineStateMachine/atlas/internal/authoring"1113 "github.com/FelineStateMachine/atlas/internal/workbench/oprunner"1214 )
@@ -155,13 +157,16 @@ func (w *Workbench) handleProjectBuild(rw http.ResponseWriter, r *http.Request)
155157 }156158 157159 func (w *Workbench) handleProjectRun(rw http.ResponseWriter, _ *http.Request) {160+ run := w.supervisor.Snapshot()161+ page := projectPage{Run: run, ArtifactName: filepath.Base(run.Artifact)}162+ var body bytes.Buffer163+ if err := w.pages["project"].ExecuteTemplate(&body, "project-run", page); err != nil {164+ http.Error(rw, err.Error(), http.StatusInternalServerError)165+ return166+ }158167 rw.Header().Set("Content-Type", "text/html; charset=utf-8")159168 rw.Header().Set("Cache-Control", "no-store")160- for _, row := range w.supervisor.Snapshot().Rows {161- if err := w.writeRow(rw, row); err != nil {162- return163- }164- }169+ rw.Write(body.Bytes())165170 }166171 167172 func (w *Workbench) handleProjectOpen(rw http.ResponseWriter, r *http.Request) {
@@ -212,10 +217,13 @@ func (w *Workbench) currentArtifact() (string, error) {
212217 if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.Ext(absArtifact) != ".atlas" {213218 return "", fmt.Errorf("build artifact is outside the Atlas library")214219 }215- file, err := os.Open(absArtifact)220+ file, err := vnext.OpenFile(absArtifact, vnext.StandardSchema())216221 if err != nil {217- return "", err222+ return "", fmt.Errorf("open completed Atlas artifact: %w", err)223+ }224+ defer file.Close()225+ if err := file.Validate(); err != nil {226+ return "", fmt.Errorf("validate completed Atlas artifact: %w", err)218227 }219- file.Close()220228 return absArtifact, nil221229 }
M internal/workbench/templates/project.tmpl +18 -13
@@ -47,12 +47,28 @@
4747 <button>Build native Atlas</button>4848 <label><input type="checkbox" name="offline" value="true"> offline / cached only</label>4949 </form>50+ {{template "project-run" .}}51+</section>52+</div>53+{{end}}54+55+<section class="card manifest-editor">56+ <h2>Portable manifest</h2>57+ <p class="dim">Configuration lives here. Captured evidence stays in the shared cache; completed volumes stay in the Atlas library.</p>58+ <form method="post" action="/project/save">59+ <textarea name="manifest" spellcheck="false" aria-label="Atlas project manifest">{{.Manifest}}</textarea>60+ <button>Validate and save</button>61+ </form>62+</section>63+{{end}}64+65+{{define "project-run"}}66+<div id="project-run"{{if .Run.Running}} hx-get="/project/run" hx-trigger="load, every 1s" hx-swap="outerHTML"{{end}}>5067 {{if .Run.Name}}5168 <p class="run-state {{if .Run.Failed}}failed{{else if .Run.Running}}running{{else}}done{{end}}">5269 {{if .Run.Running}}Building… safe to leave this page{{else if .Run.Failed}}Build failed{{else}}Build complete{{end}}5370 </p>54- <div id="op-log" class="console" aria-live="polite"55- hx-get="/project/run" hx-trigger="load, every 1s" hx-swap="innerHTML">71+ <div id="op-log" class="console" aria-live="polite">5672 {{range .Run.Rows}}{{template "op-row" .}}{{end}}5773 </div>5874 {{end}}
@@ -63,16 +79,5 @@
6379 <form method="post" action="/project/open"><button>Open in Atlas</button></form>6480 </div>6581 {{end}}66-</section>6782 </div>6883 {{end}}69-70-<section class="card manifest-editor">71- <h2>Portable manifest</h2>72- <p class="dim">Configuration lives here. Captured evidence stays in the shared cache; completed volumes stay in the Atlas library.</p>73- <form method="post" action="/project/save">74- <textarea name="manifest" spellcheck="false" aria-label="Atlas project manifest">{{.Manifest}}</textarea>75- <button>Validate and save</button>76- </form>77-</section>78-{{end}}
M render/test/vnext.test.ts +6 -0
M render/world/model.ts +16 -7
@@ -5,6 +5,7 @@
55 // a v3 document, numeric compatibility identity, or GeoJSON translation.66 77 import type { Ground } from "@atlas/analysis";8+import { KEY_GEOMETRY_KIND } from "@atlas/analysis/semconv/keys";89 import type { Collection, Lens, TileGrid, WorldPayload } from "../data/payload.ts";910 import type { OpenWorld } from "../data/plane.ts";1011 import type {
@@ -119,7 +120,8 @@ export class WorldModel {
119120 // Meaning and shape are separate. A feature set may be semantically a120121 // place, trailhead, quest, or anything a schema declares; geometry is121122 // read from its native features rather than smuggled through that name.122- const kind = presentedKind(set.features, set.semanticType);123+ const attrs = propertiesToAttrs(set.claims);124+ const kind = presentedKind(set.features, set.semanticType, attrs[KEY_GEOMETRY_KIND]);123125 const asset = style.iconAsset ? volume.assets.get(style.iconAsset) : undefined;124126 const collection: Collection = {125127 id: layer.id, featureSet: set.id, style: style.id,
@@ -127,7 +129,7 @@ export class WorldModel {
127129 icon: style.icon || style.symbol, iconAsset: asset?.path ?? "", iconPicture: style.iconPicture,128130 color: presentedColor(kind, style), visible: layer.visible,129131 labelPolicy: layer.labelPolicy, renderAs: style.renderAs,130- attrs: propertiesToAttrs(set.claims),132+ attrs,131133 };132134 collections.push(collection);133135 for (const feature of set.features) {
@@ -228,22 +230,29 @@ type GeometryBearing = { readonly geometry: { readonly kind: number } };
228230 export function presentedKind(229231 features: readonly GeometryBearing[],230232 semanticType: string,233+ declared = "",231234 ): "point" | "path" | "area" {232235 const kinds = new Set(features.map((feature) => feature.geometry.kind));233236 if (kinds.size > 1) {234237 throw new Error(`semantic feature set ${semanticType} mixes geometry kinds`);235238 }236239 const native = kinds.values().next().value as number | undefined;237- if (native === 1) return "point";238- if (native === 2) return "path";239- if (native === 3) return "area";240+ const actual = native === 1 ? "point" : native === 2 ? "path" : native === 3 ? "area" : "";241+ if (declared && declared !== "point" && declared !== "path" && declared !== "area") {242+ throw new Error(`semantic feature set ${semanticType} declares unknown geometry kind ${declared}`);243+ }244+ if (declared && actual && declared !== actual) {245+ throw new Error(`semantic feature set ${semanticType} declares ${declared} but carries ${actual}`);246+ }247+ if (declared === "point" || declared === "path" || declared === "area") return declared;248+ if (actual) return actual;240249 241250 // An empty feature set has no feature from which to infer its shape. Keep242251 // the older geometry.* spelling as a useful declaration for that case.243- const declared = semanticType.startsWith("geometry.")252+ const legacyDeclared = semanticType.startsWith("geometry.")244253 ? semanticType.slice("geometry.".length)245254 : semanticType;246- if (declared === "path" || declared === "area") return declared;255+ if (legacyDeclared === "path" || legacyDeclared === "area") return legacyDeclared;247256 return "point";248257 }249258