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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ because it turns other people's test suites red.
refuses a text value of ten million characters. At precision 3 or less a
line has fewer points than that, one for each step of longitude the globe
has, and asking for more is refused with the number that fits.
- **GeoJSON: holes, features with no place, both types of id, and bbox.**
`holes` gives every polygon up to 100 000 holes of four points, each running
the other way round to its outline as RFC 7946 asks. Holes need 6 or more
`vertices`, fewer of them fit at precision 3 or less, and asking for more is
refused with the number that fits. `unlocated` takes the place of every
fifth feature (`some`) or of every one (`all`), written as a geometry of
null. `ids` makes every id a number as before, a string such as `f2`, the
two in turn (`mixed`), or leaves it out (`none`) - GDAL keeps only the
numbers of a mixed file and warns that several features share an id.
`bbox` gives the collection and every feature with a place the box their
coordinates lie in. The collection's box comes after its features, because
its extent is known only after the last one, and a box across the
antimeridian has its west edge greater than its east edge. GDAL opens a file
whose bbox is wrong without a word, so a reader that relies on bbox needs a
test of its own. Files made with the earlier settings keep their bytes.
- **A length of time as a setting.** `duration`, `change_interval` and `keyframe_interval` take
`10s`, `1m30s`, `1h`, `500ms` or `59.9s`, to the millisecond. A bare number
is refused rather than read as seconds, and so is a length that does not
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,7 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` |
| `txt`, `md`, `xml` | `encoding`, `bom` |
| `json` | `formatting` |
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `winding`, `antimeridian` |
| `geojson` | `geometry`, `formatting`, `precision`, `altitude`, `vertices`, `holes`, `winding`, `antimeridian`, `unlocated`, `ids`, `bbox` |
| `svg` | `width`, `height` |
| `html` | `structure` |
| `yaml`, `toml` | none in this build - the document is a fixed shape, and a size is the only thing to ask for |
Expand Down
115 changes: 99 additions & 16 deletions internal/format/geojson/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package geojson

import (
"math"
"slices"
// D11 promises the same bytes from the same seed, so a deliberate,
// reproducible generator is the product rather than a weakness. Nothing
// here ever makes a secret.
Expand Down Expand Up @@ -32,16 +33,28 @@ type records struct {
e emitter
d drawing
tail []byte
closing []byte
shortest int64

// seen is the box every feature written so far lies in, for the bbox of
// the collection, and before is what it was until the last feature. The
// one feature built only to learn it does not fit is thrown away, and its
// place must go with it - a box that takes in a feature the file does not
// hold is the same lie as a number the file skips.
seen, before extent
}

// values is everything about a feature except its geometry and its note,
// drawn or chosen in one place so the smallest feature is measured with the
// same code that writes every other one.
// same code that writes every other one. bounds is the box of its geometry,
// set only when the feature carries a bbox.
type values struct {
name, tagA, tagB, city string
whole, cents, zip int64
active bool
id int64
idForm string
bounds extent
}

func newRecords(s settings) *records {
Expand All @@ -54,12 +67,16 @@ func newRecords(s settings) *records {
func (r *records) Shortest() int64 { return r.shortest }

// Discard hands back the number the thrown away feature took with it, so the
// closing one carries it instead and the ids read 1..N. The kind follows the
// number, so the closing feature is the kind the thrown away one would have
// been.
func (r *records) Discard() { r.next-- }
// closing one carries it instead and the ids read 1..N, and the place it took
// in the collection's box. The kind follows the number, so the closing feature
// is the kind the thrown away one would have been.
func (r *records) Discard() {
r.next--
r.seen = r.before
}

func (r *records) Append(dst []byte, rng *rand.Rand) []byte {
r.before = r.seen
dst = r.draw(dst, rng)
dst = appendPhrase(dst, rng, 3+rng.IntN(5))
dst = r.shut(dst)
Expand All @@ -69,31 +86,54 @@ func (r *records) Append(dst []byte, rng *rand.Rand) []byte {
// AppendExact writes the closing feature at exactly n bytes, the end of the
// collection included. The note takes the remainder up to noteCap, and spaces
// after the feature take the rest.
//
// The end of the collection is built after the closing feature is drawn,
// because its bbox takes that feature in as well.
func (r *records) AppendExact(dst []byte, rng *rand.Rand, n int64) []byte {
mark := len(dst)
dst = r.draw(dst, rng)
at := len(dst)
dst = r.shut(dst)
r.tail = append(r.tail[:0], dst[at:]...)
fill := n - int64(len(dst)-mark) - int64(len(r.s.layout.epilogue))
r.closing = r.epilogue(r.closing[:0], r.seen)
fill := n - int64(len(dst)-mark) - int64(len(r.closing))
note := min(fill, noteCap)
dst = core.AppendFiller(dst[:at], words, note, nil)
dst = append(dst, r.tail...)
dst = appendSpaces(dst, fill-note)
return append(dst, r.s.layout.epilogue...)
return append(dst, r.closing...)
}

// epilogue is what closes the collection: the end of its features, its bbox
// when one was asked for and any feature has a place, and the end of the
// object.
func (r *records) epilogue(dst []byte, x extent) []byte {
dst = append(dst, r.s.layout.after...)
if r.s.bbox && x.set {
e := &r.e
e.b, e.depth, e.fresh = dst, 1, false
e.key("bbox")
e.box(x, r.g, r.s.dims())
dst = e.b
}
return append(dst, r.s.layout.end...)
}

// draw writes the next feature up to the opening quote of its note.
func (r *records) draw(dst []byte, rng *rand.Rand) []byte {
r.next++
k := r.s.kinds[(r.next-1)%int64(len(r.s.kinds))]
k := r.s.kindAt(r.next)
r.d.draw(rng, r.s, r.g, k)
v := values{
name: word(rng), whole: 100000 + rng.Int64N(899999), cents: rng.Int64N(100),
active: rng.IntN(2) == 0, tagA: word(rng), tagB: word(rng), city: word(rng),
zip: 10000 + rng.Int64N(90000),
zip: 10000 + rng.Int64N(90000), id: r.next, idForm: r.s.idAt(r.next),
}
return r.write(dst, r.next, k, v)
if r.s.bbox {
v.bounds = r.d.extent()
r.seen.grow(v.bounds, r.d.stride)
}
return r.write(dst, k, v)
}

// measureShortest is the closing feature at its longest with an empty note:
Expand Down Expand Up @@ -138,27 +178,54 @@ func ClosingFeatureBounds(props map[string]string) (carried, whole int64, err er

// worstLength is the length of the longest closing feature of kind k with n
// points, the end of the collection included.
//
// Its id is the widest number in the widest form the settings write, and both
// boxes hold the widest number at every axis. The box of the collection is
// counted whenever the settings draw any geometry - with some features
// unlocated the first one always has a place, so the box is always there.
func (r *records) worstLength(k kind, n int) int64 {
s := r.s
s.vertices = n
r.d.worst(s, r.g, k)
widest := values{name: longestWord, tagA: longestWord, tagB: longestWord, city: longestWord,
whole: 999999, cents: 99, zip: 99999}
buf := r.shut(r.write(nil, math.MaxInt64, k, widest))
return int64(len(buf) + len(s.layout.epilogue))
whole: 999999, cents: 99, zip: 99999, id: math.MaxInt64, idForm: s.widestID()}
if s.bbox {
widest.bounds = r.d.extent()
}
buf := r.shut(r.write(nil, k, widest))
// Into the buffer the closing feature reuses, rather than a new one for
// each kind and each count measured: fourteen of those with mixed were
// enough to take the generator past the allocation ceiling of the guard
// that keeps a file out of memory (CI on #172).
r.closing = r.epilogue(r.closing[:0], widestBox(r.s, r.g))
return int64(len(buf) + len(r.closing))
}

// widestBox is a box with the widest number the grid writes at every axis, or
// none when the settings draw no geometry at all.
func widestBox(s settings, g grid) extent {
if slices.Equal(s.kinds, []kind{kindNone}) {
return extent{}
}
var x extent
x.add([]int64{-g.lon, -g.lat, g.low})
return x
}

// write is one feature through the opening quote of its note, from a drawing
// already made and values already chosen.
func (r *records) write(dst []byte, id int64, k kind, v values) []byte {
func (r *records) write(dst []byte, k kind, v values) []byte {
e := &r.e
e.b = append(dst, r.s.layout.start...)
e.depth = featureDepth
e.open('{')
e.key("type")
e.text("Feature")
e.key("id")
e.whole(id)
e.id(v.id, v.idForm)
if v.bounds.set {
e.key("bbox")
e.box(v.bounds, r.g, r.s.dims())
}
e.key("geometry")
r.geometry(k)
e.key("properties")
Expand Down Expand Up @@ -202,6 +269,22 @@ func (r *records) properties(v values) {
e.close('}')
}

// id writes the id member in the form asked for: a number, the same number as
// text after an f, or nothing at all. Every form counts 1..N, so a test can
// still find a feature that went missing.
func (e *emitter) id(n int64, form string) {
switch form {
case Number:
e.key("id")
e.whole(n)
case String:
e.key("id")
e.b = append(e.b, '"', 'f')
e.whole(n)
e.b = append(e.b, '"')
}
}

// shut closes the note, the properties and the feature.
func (r *records) shut(dst []byte) []byte {
r.e.b = append(dst, '"')
Expand Down
25 changes: 23 additions & 2 deletions internal/format/geojson/geojson.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
if err != nil {
return format.Plan{}, err
}
if most := mostPoints(s.kinds, gridFor(s.precision)); int64(s.vertices) > most {
return format.Plan{}, refuseCrowded(s, most)
if err := refusal(s); err != nil {
return format.Plan{}, err
}
if min := minimumBytes(s); r.Bytes < min {
return format.Plan{}, &format.BelowMinimumError{
Expand All @@ -108,8 +108,12 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
Precision: s.precision,
Altitude: s.altitude,
Vertices: s.vertices,
Holes: s.holes,
Winding: s.winding(),
Antimeridian: s.antimeridian,
Unlocated: s.unlocated,
IDs: s.ids,
BBox: s.bbox,
// Stated even though it is always false here, so a test can assert
// on it without knowing which formats carry a label internally.
format.PropertyLabelEmbedded: false,
Expand All @@ -118,6 +122,23 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
}, nil
}

// refusal is why the globe has no room for what these settings draw, or nil.
// Holes ask for nothing when no outline is drawn, and nothing asks for room
// when every feature is unlocated.
func refusal(s settings) error {
g := gridFor(s.precision)
if most := mostPoints(s.kinds, g); int64(s.vertices) > most {
return refuseCrowded(s, most)
}
if s.holes == 0 || !draws(s.kinds, pieceRing) {
return nil
}
if most := mostHoles(g); s.vertices < minVerticesWithHoles || int64(s.holes) > most {
return refuseHoles(s, most)
}
return nil
}

func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
m, ok := p.Memo.(memo)
if !ok {
Expand Down
62 changes: 52 additions & 10 deletions internal/format/geojson/geometry.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package geojson

// geometry writes the geometry object of kind k from the drawing already made.
// geometry writes the geometry object of kind k from the drawing already made,
// or null for a feature with no place.
func (r *records) geometry(k kind) {
e := &r.e
if k == kindNone {
e.b = append(e.b, "null"...)
return
}
e.open('{')
e.key("type")
e.text(typeName[k])
Expand Down Expand Up @@ -54,7 +59,7 @@ func (r *records) coordinates(k kind) {
}

// piece writes piece j of the drawing: a position for a point, an array of
// positions for a line, and an array holding one ring for an outline.
// positions for a line, and for an outline an array of its rings.
func (r *records) piece(p piece, j int) {
from := 0
if j > 0 {
Expand All @@ -67,10 +72,50 @@ func (r *records) piece(p piece, j int) {
case pieceLine:
r.run(from, to, false)
case pieceRing:
r.e.open('[')
r.e.next()
r.run(from, to, r.s.reversed)
r.e.close(']')
r.rings(from, to)
}
}

// rings is an outline and its holes, the outline first as RFC 7946 asks. The
// holes are the last positions of the piece, five each, and each runs the
// other way round to the outline - clockwise under rfc7946 and
// counter-clockwise under reversed.
func (r *records) rings(from, to int) {
e := &r.e
outline := to - r.s.holes*holePositions
e.open('[')
e.next()
r.run(from, outline, r.s.reversed)
for at := outline; at < to; at += holePositions {
e.next()
r.run(at, at+holePositions, !r.s.reversed)
}
e.close(']')
}

// box writes the bbox of an extent: every axis of the south-west corner, then
// every axis of the north-east one. A box across the antimeridian is written
// with its west edge greater than its east edge, and one that reaches all the
// way round is the whole circle, -180 to 180 (RFC 7946 sections 5.2 and 5.3).
// dims is how many axes a position has.
func (e *emitter) box(x extent, g grid, dims int) {
west, east := g.wrap(x.lo[0]), g.wrap(x.hi[0])
if x.hi[0]-x.lo[0] >= 2*g.lon {
west, east = -g.lon, g.lon
}
e.open('[')
e.corner(g, west, x.lo[1:dims])
e.corner(g, east, x.hi[1:dims])
e.close(']')
}

// corner is the axes of one corner of a box: its longitude, then the rest.
func (e *emitter) corner(g grid, lon int64, rest []int64) {
e.next()
e.fixed(lon, g.places, g.scale)
for _, v := range rest {
e.next()
e.fixed(v, g.places, g.scale)
}
}

Expand All @@ -96,10 +141,7 @@ func (r *records) run(from, to int, backwards bool) {
func (r *records) position(i int) {
e := &r.e
p := r.d.pos[i*r.d.stride : (i+1)*r.d.stride]
x := p[0]
if x > r.g.lon {
x -= 2 * r.g.lon
}
x := r.g.wrap(p[0])
e.open('[')
for j, v := range p {
if j == 0 {
Expand Down
Loading
Loading