nswtopo 3.0 → 3.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/bin/nswtopo +19 -4
  3. data/docs/contours.md +2 -0
  4. data/docs/relief.md +2 -3
  5. data/docs/spot-heights.md +2 -0
  6. data/lib/nswtopo/archive.rb +6 -3
  7. data/lib/nswtopo/chrome.rb +16 -8
  8. data/lib/nswtopo/commands/layers.rb +2 -2
  9. data/lib/nswtopo/config.rb +1 -0
  10. data/lib/nswtopo/formats/gemf.rb +1 -0
  11. data/lib/nswtopo/formats/kmz.rb +16 -10
  12. data/lib/nswtopo/formats/mbtiles.rb +1 -0
  13. data/lib/nswtopo/formats/pdf.rb +34 -4
  14. data/lib/nswtopo/formats/svg.rb +4 -4
  15. data/lib/nswtopo/formats/svgz.rb +1 -0
  16. data/lib/nswtopo/formats/zip.rb +5 -4
  17. data/lib/nswtopo/formats.rb +35 -36
  18. data/lib/nswtopo/geometry/r_tree.rb +24 -23
  19. data/lib/nswtopo/geometry/straight_skeleton/node.rb +4 -4
  20. data/lib/nswtopo/geometry/straight_skeleton/nodes.rb +51 -40
  21. data/lib/nswtopo/geometry/straight_skeleton/split.rb +2 -2
  22. data/lib/nswtopo/geometry/vector.rb +55 -49
  23. data/lib/nswtopo/geometry.rb +0 -5
  24. data/lib/nswtopo/gis/arcgis/layer/map.rb +11 -10
  25. data/lib/nswtopo/gis/arcgis/layer/query.rb +8 -10
  26. data/lib/nswtopo/gis/arcgis/layer.rb +7 -11
  27. data/lib/nswtopo/gis/dem.rb +3 -2
  28. data/lib/nswtopo/gis/gdal_glob.rb +3 -3
  29. data/lib/nswtopo/gis/geojson/collection.rb +59 -13
  30. data/lib/nswtopo/gis/geojson/line_string.rb +142 -1
  31. data/lib/nswtopo/gis/geojson/multi_line_string.rb +49 -7
  32. data/lib/nswtopo/gis/geojson/multi_point.rb +87 -0
  33. data/lib/nswtopo/gis/geojson/multi_polygon.rb +35 -23
  34. data/lib/nswtopo/gis/geojson/point.rb +16 -1
  35. data/lib/nswtopo/gis/geojson/polygon.rb +69 -7
  36. data/lib/nswtopo/gis/geojson.rb +92 -46
  37. data/lib/nswtopo/gis/projection.rb +5 -1
  38. data/lib/nswtopo/helpers/thread_pool.rb +39 -0
  39. data/lib/nswtopo/helpers.rb +44 -5
  40. data/lib/nswtopo/layer/arcgis_raster.rb +3 -3
  41. data/lib/nswtopo/layer/contour.rb +24 -26
  42. data/lib/nswtopo/layer/control.rb +5 -3
  43. data/lib/nswtopo/layer/declination.rb +14 -10
  44. data/lib/nswtopo/layer/feature.rb +5 -5
  45. data/lib/nswtopo/layer/grid.rb +19 -18
  46. data/lib/nswtopo/layer/labels/barriers.rb +23 -0
  47. data/lib/nswtopo/layer/labels/convex_hull.rb +12 -0
  48. data/lib/nswtopo/layer/labels/convex_hulls.rb +86 -0
  49. data/lib/nswtopo/layer/labels/label.rb +63 -0
  50. data/lib/nswtopo/layer/labels.rb +192 -315
  51. data/lib/nswtopo/layer/overlay.rb +11 -12
  52. data/lib/nswtopo/layer/raster.rb +1 -0
  53. data/lib/nswtopo/layer/relief.rb +6 -4
  54. data/lib/nswtopo/layer/spot.rb +11 -17
  55. data/lib/nswtopo/layer/{vector → vector_render}/cutout.rb +1 -1
  56. data/lib/nswtopo/layer/{vector → vector_render}/knockout.rb +2 -3
  57. data/lib/nswtopo/layer/{vector.rb → vector_render.rb} +20 -45
  58. data/lib/nswtopo/layer.rb +2 -1
  59. data/lib/nswtopo/map.rb +62 -60
  60. data/lib/nswtopo/svg.rb +5 -0
  61. data/lib/nswtopo/tiled_web_map.rb +3 -3
  62. data/lib/nswtopo/tree_indenter.rb +2 -2
  63. data/lib/nswtopo/version.rb +1 -1
  64. data/lib/nswtopo.rb +8 -0
  65. metadata +15 -17
  66. data/lib/nswtopo/geometry/overlap.rb +0 -47
  67. data/lib/nswtopo/geometry/segment.rb +0 -27
  68. data/lib/nswtopo/geometry/vector_sequence.rb +0 -180
  69. data/lib/nswtopo/helpers/array.rb +0 -19
  70. data/lib/nswtopo/helpers/concurrently.rb +0 -27
  71. data/lib/nswtopo/helpers/dir.rb +0 -7
  72. data/lib/nswtopo/helpers/hash.rb +0 -15
  73. data/lib/nswtopo/helpers/tar_writer.rb +0 -11
  74. data/lib/nswtopo/layer/labels/barrier.rb +0 -39
@@ -9,20 +9,30 @@ module StraightSkeleton
9
9
 
10
10
  def initialize(data)
11
11
  @active, @indices = Set[], Hash.new.compare_by_identity
12
- data.to_d.map do |points|
12
+ data.map do |points|
13
+ points.map(&:to_d)
14
+ end.map do |points|
13
15
  next points unless points.length > 2
14
16
  points.each.with_object [] do |point, points|
15
17
  points << point unless points.last == point
16
18
  end
17
- end.map.with_index do |(*points, point), index|
18
- points.first == point ? [points, :ring, (index unless points.hole?)] : [points << point, :segments, nil]
19
- end.each do |points, pair, index|
20
- normals = points.send(pair).map(&:diff).map(&:normalised).map(&:perp)
19
+ end.each.with_index do |points, index|
20
+ closed = points.first == points.last
21
+ points.each_cons(2).sum do |p0, p1|
22
+ p0.cross(p1)
23
+ end.then do |signed_area_x2|
24
+ index = nil if signed_area_x2 < 0
25
+ end if closed
26
+ normals = points.each_cons(2).map do |p0, p1|
27
+ (p1 - p0).normalised.perp
28
+ end
21
29
  points.map do |point|
22
30
  Vertex.new self, point
23
- end.each do |node|
24
- @active << node
25
- end.send(pair).zip(normals).each do |edge, normal|
31
+ end.tap do |nodes|
32
+ nodes.pop if closed
33
+ nodes.inject(@active, &:<<)
34
+ nodes << nodes.first if closed
35
+ end.each_cons(2).zip(normals) do |edge, normal|
26
36
  Nodes.stitch normal, *edge
27
37
  @indices[normal] = index if index
28
38
  end
@@ -40,8 +50,8 @@ module StraightSkeleton
40
50
  det = n2.cross(n1) + n1.cross(n0) + n0.cross(n2)
41
51
  return if det.zero?
42
52
  travel = (x0 * n1.cross(n2) + x1 * n2.cross(n0) + x2 * n0.cross(n1)) / det
43
- point = [n1.minus(n2).perp.times(x0), n2.minus(n0).perp.times(x1), n0.minus(n1).perp.times(x2)].inject(&:plus) / det
44
- [point, travel]
53
+ point = ((n1 - n2).perp * x0 + (n2 - n0).perp * x1 + (n0 - n1).perp * x2) / det
54
+ return point, travel
45
55
  end
46
56
 
47
57
  # #################################
@@ -52,11 +62,11 @@ module StraightSkeleton
52
62
  # #################################
53
63
 
54
64
  def self.solve_asym(n0, n1, n2, x0, x1, x2)
55
- det = n0.minus(n1).dot(n2)
65
+ det = (n0 - n1).dot(n2)
56
66
  return if det.zero?
57
67
  travel = (x0 * n1.dot(n2) - x1 * n2.dot(n0) + x2 * n0.cross(n1)) / det
58
- point = (n2.times(x0 - x1).plus n0.minus(n1).perp.times(x2)) / det
59
- [point, travel]
68
+ point = (n2 * (x0 - x1) + (n0 - n1).perp * x2) / det
69
+ return point, travel
60
70
  end
61
71
 
62
72
  def collapse(edge)
@@ -79,14 +89,14 @@ module StraightSkeleton
79
89
  bounds = node.project(@limit).zip(node.point).map do |centre, coord|
80
90
  [coord, centre - @limit, centre + @limit].minmax
81
91
  end if @limit
82
- @index.search(bounds).map do |edge|
92
+ @index.search(bounds).filter_map do |edge|
83
93
  p0, p1, p2 = [*edge, node].map(&:point)
84
94
  t0, t1, t2 = [*edge, node].map(&:travel)
85
95
  (n00, n01), (n10, n11), (n20, n21) = [*edge, node].map(&:normals)
86
96
  next if p0 == p2 || p1 == p2
87
97
  next if node.terminal? and Split === node and node.source.normals[0].equal? n01
88
98
  next if node.terminal? and Split === node and node.source.normals[1].equal? n01
89
- next unless node.terminal? || [n20, n21].compact.inject(&:plus).dot(n01) < 0
99
+ next unless node.terminal? || [n20, n21].compact.inject(&:+).dot(n01) < 0
90
100
  point, travel = case
91
101
  when n20 && n21 then Nodes::solve(n20, n21, n01, n20.dot(p2) - t2, n21.dot(p2) - t2, n01.dot(p0) - t0)
92
102
  when n20 then Nodes::solve_asym(n01, n20, n20, n01.dot(p0) - t0, n20.dot(p2) - t2, n20.cross(p2))
@@ -94,9 +104,9 @@ module StraightSkeleton
94
104
  end || next
95
105
  next if travel * @direction < node.travel
96
106
  next if @limit && travel.abs > @limit.abs
97
- next if point.minus(p0).dot(n01) * @direction < 0
107
+ next if (point - p0).dot(n01) * @direction < 0
98
108
  Split.new self, point, travel, node, edge[0]
99
- end.compact.each do |split|
109
+ end.each do |split|
100
110
  @candidates << split
101
111
  end
102
112
  end
@@ -110,7 +120,7 @@ module StraightSkeleton
110
120
  @track[node.normals[1]] << node if node.normals[1]
111
121
  2.times.inject [node] do |nodes|
112
122
  [nodes.first.prev, *nodes, nodes.last.next].compact
113
- end.segments.uniq.each do |edge|
123
+ end.each_cons(2).uniq.each do |edge|
114
124
  collapse edge
115
125
  end
116
126
  split node if node.splits?
@@ -123,7 +133,7 @@ module StraightSkeleton
123
133
  end
124
134
 
125
135
  def nodeset
126
- [].tap do |result|
136
+ Enumerator.new do |yielder|
127
137
  pending, processed = @active.dup, Set[]
128
138
  while pending.any?
129
139
  nodes = pending.take 1
@@ -137,35 +147,36 @@ module StraightSkeleton
137
147
  end
138
148
  pending.subtract nodes
139
149
  nodes << nodes.first if nodes.first == nodes.last.next
140
- result << nodes unless nodes.one?
150
+ yielder << nodes unless nodes.one?
141
151
  end
142
- end
152
+ end.entries
143
153
  end
144
154
 
145
155
  attr_reader :direction
146
156
 
147
157
  def progress(limit: nil, rounding_angle: DEFAULT_ROUNDING_ANGLE, cutoff_angle: nil, interval: nil, splits: true, &block)
148
- return self if limit && limit.zero?
158
+ return self if limit&.zero?
149
159
 
150
160
  nodeset.tap do
151
161
  @active.clear
152
162
  @indices = nil
153
- end.map do |*nodes, node|
154
- nodes.first == node ? [nodes, :ring] : [nodes << node, :segments]
155
- end.each.with_index do |(nodes, pair), index|
156
- normals = nodes.send(pair).map do |edge|
157
- edge[0].normals[1]
163
+ end.each do |nodes|
164
+ closed = nodes.first == nodes.last
165
+ normals = nodes.map do |node|
166
+ node.normals[1]
158
167
  end
159
168
  nodes.map do |node|
160
169
  Vertex.new self, node.project(@limit)
161
- end.each do |node|
162
- @active << node
163
- end.send(pair).zip(normals).each do |edge, normal|
170
+ end.tap do |nodes|
171
+ nodes.pop if closed
172
+ nodes.inject(@active, &:<<)
173
+ nodes << nodes.first if closed
174
+ end.each_cons(2).zip(normals).each do |edge, normal|
164
175
  Nodes.stitch normal, *edge
165
176
  end
166
177
  end if @limit
167
178
 
168
- @candidates, @travel, @limit, @direction = AVLTree.new, 0, limit && limit.to_d, limit ? limit <=> 0 : 1
179
+ @candidates, @travel, @limit, @direction = AVLTree.new, 0, limit&.to_d, limit ? limit <=> 0 : 1
169
180
 
170
181
  rounding_angle *= Math::PI / 180
171
182
  cutoff_angle *= Math::PI / 180 if cutoff_angle
@@ -189,10 +200,12 @@ module StraightSkeleton
189
200
  events << [:outgoing, node.next] if node.next
190
201
  end.sort_by do |event, node|
191
202
  case event
192
- when :incoming then [-@direction * node.normals[1].angle, 1]
193
- when :outgoing then [-@direction * node.normals[0].negate.angle, 0]
203
+ when :incoming then [-@direction * node.normals[1].angle, 1]
204
+ when :outgoing then [-@direction * (-node.normals[0]).angle, 0]
194
205
  end
195
- end.ring.map(&:transpose).each do |events, neighbours|
206
+ end.then do |events|
207
+ events.zip events.rotate
208
+ end.map(&:transpose).each do |events, neighbours|
196
209
  node = Vertex.new self, point
197
210
  case events
198
211
  when [:outgoing, :incoming] then next
@@ -236,9 +249,9 @@ module StraightSkeleton
236
249
  end.each do |extra_node|
237
250
  @active << extra_node
238
251
  end
239
- edges = [node.neighbours[0], node, *extra_nodes, node.neighbours[1]].segments
252
+ edges = [node.neighbours[0], node, *extra_nodes, node.neighbours[1]].each_cons(2)
240
253
  normals = [node.normals[0], *extra_normals, node.normals[1]]
241
- edges.zip(normals).each do |edge, normal|
254
+ edges.zip(normals) do |edge, normal|
242
255
  Nodes.stitch normal, *edge
243
256
  end
244
257
  end
@@ -251,7 +264,7 @@ module StraightSkeleton
251
264
  end.map do |edge|
252
265
  [edge.map(&:point).transpose.map(&:minmax), edge]
253
266
  end.tap do |bounds_edges|
254
- @index = RTree.load bounds_edges
267
+ @index = RTree.load! bounds_edges
255
268
  end
256
269
 
257
270
  @active.select(&:splits?).each do |node|
@@ -281,9 +294,7 @@ module StraightSkeleton
281
294
  node.project(travel).to_f
282
295
  end
283
296
  end.map do |points|
284
- points.segments.reject do |segment|
285
- segment.inject(&:==)
286
- end.map(&:last).unshift(points.first)
297
+ points.chunk(&:itself).map(&:first)
287
298
  end.reject(&:one?)
288
299
  end
289
300
 
@@ -13,8 +13,8 @@ module StraightSkeleton
13
13
  @edge = @nodes.track(@normal).find do |edge|
14
14
  (n00, n01), (n10, n11) = edge.map(&:normals)
15
15
  p0, p1 = edge.map(&:point)
16
- next if point.minus(p0).cross(n00 ? n00.plus(n01) : n01) < 0
17
- next if point.minus(p1).cross(n11 ? n11.plus(n10) : n10) > 0
16
+ next if (point - p0).cross(n00 ? n00 + n01 : n01) < 0
17
+ next if (point - p1).cross(n11 ? n11 + n10 : n10) > 0
18
18
  true
19
19
  end
20
20
  end
@@ -1,56 +1,86 @@
1
- module Vector
1
+ class Vector
2
+ def initialize(x, y)
3
+ @x, @y = x, y
4
+ freeze
5
+ end
6
+
7
+ def self.[](x, y)
8
+ new x, y
9
+ end
10
+
11
+ attr_reader :x, :y
12
+
13
+ def to_ary
14
+ [@x, @y]
15
+ end
16
+
17
+ extend Forwardable
18
+ delegate %i[to_json hash each join <=>] => :to_ary
19
+
20
+ include Enumerable
21
+ include Comparable
22
+ alias eql? ==
23
+
24
+ def inspect
25
+ "{%s, %s}" % [@x, @y]
26
+ end
27
+
28
+ def to_d
29
+ Vector[@x.to_d, @y.to_d]
30
+ end
31
+
32
+ def to_f
33
+ Vector[@x.to_f, @y.to_f]
34
+ end
35
+
2
36
  def rotate_by(angle)
3
37
  cos = Math::cos(angle)
4
38
  sin = Math::sin(angle)
5
- [self[0] * cos - self[1] * sin, self[0] * sin + self[1] * cos]
6
- end
7
-
8
- def rotate_by!(angle)
9
- replace rotate_by(angle)
39
+ Vector[@x * cos - @y * sin, @x * sin + @y * cos]
10
40
  end
11
41
 
12
42
  def rotate_by_degrees(angle)
13
43
  rotate_by(angle * Math::PI / 180.0)
14
44
  end
15
45
 
16
- def rotate_by_degrees!(angle)
17
- replace rotate_by_degrees(angle)
46
+ def +(other)
47
+ Vector[@x + other.x, @y + other.y]
18
48
  end
19
49
 
20
- def plus(other)
21
- [self, other].transpose.map { |values| values.inject(:+) }
50
+ def -(other)
51
+ Vector[@x - other.x, @y - other.y]
22
52
  end
23
53
 
24
- def minus(other)
25
- [self, other].transpose.map { |values| values.inject(:-) }
54
+ def *(scalar)
55
+ Vector[@x * scalar, @y * scalar]
26
56
  end
27
57
 
28
- def dot(other)
29
- [self, other].transpose.map { |values| values.inject(:*) }.inject(:+)
58
+ def /(scalar)
59
+ Vector[@x / scalar, @y / scalar]
30
60
  end
31
61
 
32
- def times(scalar)
33
- map { |value| value * scalar }
62
+ def +@
63
+ self
34
64
  end
35
65
 
36
- def /(scalar)
37
- map { |value| value / scalar }
66
+ def -@
67
+ self * -1
38
68
  end
39
69
 
40
- def negate
41
- map { |value| -value }
70
+ def dot(other)
71
+ @x * other.x + @y * other.y
42
72
  end
43
73
 
44
- def to_d
45
- map(&:to_d)
74
+ def perp
75
+ Vector[-@y, @x]
46
76
  end
47
77
 
48
- def to_f
49
- map(&:to_f)
78
+ def cross(other)
79
+ perp.dot other
50
80
  end
51
81
 
52
82
  def angle
53
- Math::atan2 at(1), at(0)
83
+ Math::atan2 @y, @x
54
84
  end
55
85
 
56
86
  def norm
@@ -64,28 +94,4 @@ module Vector
64
94
  def proj(other)
65
95
  dot(other) / other.norm
66
96
  end
67
-
68
- def perp
69
- [-self[1], self[0]]
70
- end
71
-
72
- def cross(other)
73
- perp.dot other
74
- end
75
-
76
- def within?(polygon)
77
- polygon.map do |point|
78
- point.minus self
79
- end.ring.inject(0) do |winding, (p0, p1)|
80
- case
81
- when p1[1] > 0 && p0[1] <= 0 && p0.minus(p1).cross(p0) >= 0 then winding + 1
82
- when p0[1] > 0 && p1[1] <= 0 && p1.minus(p0).cross(p0) >= 0 then winding - 1
83
- when p0[1] == 0 && p1[1] == 0 && p0[0] >= 0 && p1[0] < 0 then winding + 1
84
- when p0[1] == 0 && p1[1] == 0 && p1[0] >= 0 && p0[0] < 0 then winding - 1
85
- else winding
86
- end
87
- end != 0
88
- end
89
97
  end
90
-
91
- Array.send :include, Vector
@@ -1,8 +1,3 @@
1
- require 'bigdecimal'
2
- require 'bigdecimal/util'
3
1
  require_relative 'geometry/vector'
4
- require_relative 'geometry/segment'
5
- require_relative 'geometry/vector_sequence'
6
- require_relative 'geometry/overlap'
7
2
  require_relative 'geometry/r_tree'
8
3
  require_relative 'geometry/straight_skeleton'
@@ -116,10 +116,13 @@ module NSWTopo
116
116
  curves = [[coords.last.last, *points]]
117
117
  while curve = curves.shift
118
118
  next if curve.first == curve.last
119
- if curve.values_at(0,-1).distance < 0.99 * curve.segments.map(&:distance).sum
119
+ curve_length = curve.each_cons(2).sum do |p0, p1|
120
+ (p1 - p0).norm
121
+ end
122
+ if (curve.first - curve.last).norm < 0.99 * curve_length
120
123
  reduced = 3.times.inject [ curve ] do |reduced|
121
- reduced << reduced.last.each_cons(2).map do |(x0, y0), (x1, y1)|
122
- [0.5 * (x0 + x1), 0.5 * (y0 + y1)]
124
+ reduced << reduced.last.each_cons(2).map do |p0, p1|
125
+ (p0 + p1) / 2
123
126
  end
124
127
  end
125
128
  curves.unshift reduced.map(&:last).reverse
@@ -141,15 +144,13 @@ module NSWTopo
141
144
  when "esriGeometryPoint"
142
145
  raise "unexpected SVG response (bad point symbol)" unless coords.map(&:length) == [ 4 ]
143
146
  point = coords[0].transpose.map { |coords| coords.sum / coords.length }
144
- next GeoJSON::Point.new point, properties
147
+ next GeoJSON::Point[point, properties]
145
148
  when "esriGeometryPolyline"
146
- next GeoJSON::LineString.new coords[0], properties if @mixed && coords.one?
147
- next GeoJSON::MultiLineString.new coords, properties
149
+ next GeoJSON::LineString[coords[0], properties] if @mixed && coords.one?
150
+ next GeoJSON::MultiLineString[coords, properties]
148
151
  when "esriGeometryPolygon"
149
- coords.each(&:reverse!) unless coords[0].anticlockwise?
150
- polys = coords.slice_before(&:anticlockwise?).entries
151
- next GeoJSON::Polygon.new polys.first, properties if @mixed && polys.one?
152
- next GeoJSON::MultiPolygon.new polys, properties
152
+ polys = GeoJSON::MultiLineString[coords.map(&:reverse), properties].to_multipolygon
153
+ next @mixed && polys.one? ? polys.first : polys
153
154
  end
154
155
  end.tap do |features|
155
156
  yielder << GeoJSON::Collection.new(projection: projection, name: @name, features: features)
@@ -45,7 +45,7 @@ module NSWTopo
45
45
  get_json "#{@id}/query", outFields: out_fields, objectIds: objectids.take(per_page).join(?,)
46
46
  rescue Connection::Error
47
47
  (per_page /= 2) > 0 ? retry : raise
48
- end.fetch("features", []).map do |feature|
48
+ end.fetch("features", []).filter_map do |feature|
49
49
  next unless geometry = feature["geometry"]
50
50
  properties = feature.fetch("attributes", {})
51
51
 
@@ -53,29 +53,27 @@ module NSWTopo
53
53
  when "esriGeometryPoint"
54
54
  point = geometry.values_at "x", "y"
55
55
  next unless point.all?
56
- next GeoJSON::Point.new point, properties
56
+ next GeoJSON::Point[point, properties]
57
57
  when "esriGeometryMultipoint"
58
58
  points = geometry["points"]
59
59
  next unless points&.any?
60
- next GeoJSON::MultiPoint.new points.transpose.take(2).transpose, properties
60
+ next GeoJSON::MultiPoint[points.transpose.take(2).transpose, properties]
61
61
  when "esriGeometryPolyline"
62
62
  raise "ArcGIS curve geometries not supported" if geometry.key? "curvePaths"
63
63
  paths = geometry["paths"]
64
64
  next unless paths&.any?
65
- next GeoJSON::LineString.new paths[0], properties if @mixed && paths.one?
66
- next GeoJSON::MultiLineString.new paths, properties
65
+ next GeoJSON::LineString[paths[0], properties] if @mixed && paths.one?
66
+ next GeoJSON::MultiLineString[paths, properties]
67
67
  when "esriGeometryPolygon"
68
68
  raise "ArcGIS curve geometries not supported" if geometry.key? "curveRings"
69
69
  rings = geometry["rings"]
70
70
  next unless rings&.any?
71
- rings.each(&:reverse!) unless rings[0].anticlockwise?
72
- polys = rings.slice_before(&:anticlockwise?).entries
73
- next GeoJSON::Polygon.new polys.first, properties if @mixed && polys.one?
74
- next GeoJSON::MultiPolygon.new polys, properties
71
+ polys = GeoJSON::MultiLineString[rings.map(&:reverse), properties].to_multipolygon
72
+ next @mixed && polys.one? ? polys.first : polys
75
73
  else
76
74
  raise "unsupported ArcGIS geometry type: #{@geometry_type}"
77
75
  end
78
- end.compact.tap do |features|
76
+ end.tap do |features|
79
77
  yielder << GeoJSON::Collection.new(projection: projection, features: features, name: @name)
80
78
  end
81
79
  objectids.shift per_page
@@ -133,20 +133,16 @@ module NSWTopo
133
133
  def decode(attributes)
134
134
  attributes.map do |name, value|
135
135
  [name, @revalue[name, value, attributes]]
136
- end.to_h.slice(*@fields).then do |decoded|
137
- attributes.replace decoded
138
- end
139
- end
140
-
141
- def transform(feature)
142
- decode(feature.properties).transform_keys!(&@rename)
136
+ end.to_h.slice(*@fields)
143
137
  end
144
138
 
145
139
  def paged(per_page: nil)
146
140
  per_page = [*per_page, *@layer["maxRecordCount"], 500].min
147
141
  Enumerator::Lazy.new pages(per_page) do |yielder, page|
148
- page.each(&method(:transform))
149
- yielder << page
142
+ page.map! do |feature|
143
+ decoded = decode(feature.properties).transform_keys!(&@rename)
144
+ feature.with_properties decoded
145
+ end.then(&yielder)
150
146
  end
151
147
  end
152
148
 
@@ -173,9 +169,9 @@ module NSWTopo
173
169
  end
174
170
 
175
171
  def counts
176
- classify(*@fields, *extra_field).each do |attributes, count|
172
+ classify(*@fields, *extra_field).group_by do |attributes, count|
177
173
  decode attributes
178
- end.group_by(&:first).map do |attributes, attributes_counts|
174
+ end.map do |attributes, attributes_counts|
179
175
  [attributes, attributes_counts.sum(&:last)]
180
176
  end
181
177
  end
@@ -16,12 +16,13 @@ module NSWTopo
16
16
  raise "no elevation data found at specified path" if rasters.none?
17
17
  log_update "%s: extracting DEM raster" % @name
18
18
  end.group_by do |path, info|
19
- Projection.new info.dig("coordinateSystem", "wkt")
19
+ @epsg ? Projection.epsg(@epsg) : Projection.new(info.dig("coordinateSystem", "wkt"))
20
20
  end.map.with_index do |(projection, rasters), index|
21
21
  raise "DEM data not in planar projection with metre units" unless projection.metres?
22
22
 
23
23
  paths, resolutions = rasters.map do |path, info|
24
- [path, info["geoTransform"].values_at(1, 2).norm]
24
+ rx, ry = info["geoTransform"].values_at(1, 2)
25
+ next path, Vector[rx, ry].norm
25
26
  end.sort_by(&:last).transpose
26
27
 
27
28
  txt_path.write paths.reverse.join(?\n)
@@ -28,14 +28,14 @@ module NSWTopo
28
28
  end
29
29
  end.entries.tap do |paths|
30
30
  total = paths.length
31
- end.map.with_index do |path, index|
31
+ end.filter_map.with_index do |path, index|
32
32
  yield [index + 1, total] if block_given?
33
33
  info = JSON.parse OS.gdalinfo("-json", path)
34
34
  next unless info["geoTransform"]
35
- next unless wkt = info.dig("coordinateSystem", "wkt")
35
+ next unless @epsg || info.dig("coordinateSystem", "wkt")
36
36
  next path, info
37
37
  rescue JSON::ParserError
38
- end.compact
38
+ end
39
39
  end
40
40
  end
41
41
  end
@@ -19,7 +19,7 @@ module NSWTopo
19
19
  geometry, properties = feature.values_at "geometry", "properties"
20
20
  type, coordinates = geometry.values_at "type", "coordinates"
21
21
  raise Error, "unsupported geometry type: #{type}" unless TYPES === type
22
- GeoJSON.const_get(type).new coordinates, properties
22
+ GeoJSON.const_get(type)[coordinates, properties]
23
23
  end.then do |features|
24
24
  new projection: projection, features: features, name: name
25
25
  end
@@ -37,6 +37,14 @@ module NSWTopo
37
37
  block_given? ? tap { @features.each(&block) } : @features.each
38
38
  end
39
39
 
40
+ def map!(&block)
41
+ tap { @features.map!(&block) }
42
+ end
43
+
44
+ def reject!(&block)
45
+ tap { @features.reject!(&block) }
46
+ end
47
+
40
48
  def reproject_to(projection)
41
49
  return self if self.projection == projection
42
50
  json = OS.ogr2ogr "-t_srs", projection, "-f", "GeoJSON", "-lco", "RFC7946=NO", "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
@@ -52,32 +60,48 @@ module NSWTopo
52
60
  def to_h
53
61
  {
54
62
  "type" => "FeatureCollection",
63
+ "name" => @name,
55
64
  "crs" => { "type" => "name", "properties" => { "name" => @projection } },
56
65
  "features" => map(&:to_h)
57
- }.tap do |hash|
58
- hash["name"] = @name if @name
59
- end
66
+ }.compact
60
67
  end
61
68
 
62
69
  extend Forwardable
63
70
  delegate %i[coordinates properties wkt area] => :first
64
- delegate %i[reject! select! length] => :@features
71
+ delegate %i[length] => :@features
65
72
 
66
73
  def to_json(**extras)
67
74
  to_h.merge(extras).to_json
68
75
  end
69
76
 
77
+ def with_features(features)
78
+ Collection.new projection: @projection, name: @name, features: features
79
+ end
80
+
81
+ def with_name(name)
82
+ Collection.new projection: @projection, name: name, features: @features
83
+ end
84
+
85
+ def with_sql(sql, name: @name)
86
+ json = OS.ogr2ogr *%w[-f GeoJSON -lco RFC7946=NO /vsistdout/ GeoJSON:/vsistdin/ -dialect SQLite -sql], sql do |stdin|
87
+ stdin.puts to_json
88
+ end
89
+ Collection.load(json, projection: @projection).with_name(name)
90
+ rescue OS::Error
91
+ raise "GDAL with SQLite support required"
92
+ end
93
+
70
94
  def explode
71
- Collection.new projection: @projection, name: @name, features: flat_map(&:explode)
95
+ with_features flat_map(&:explode)
72
96
  end
73
97
 
74
98
  def multi
75
- Collection.new projection: @projection, name: @name, features: map(&:multi)
99
+ with_features map(&:multi)
76
100
  end
77
101
 
78
102
  def merge(other)
79
103
  raise Error, "can't merge different projections" unless @projection == other.projection
80
- Collection.new projection: @projection, name: @name, features: @features + other.features
104
+ with_features @features + other.features
81
105
  end
82
106
 
83
107
  def merge!(other)
@@ -85,6 +109,19 @@ module NSWTopo
85
109
  tap { @features.concat other.features }
86
110
  end
87
111
 
112
+ def dissolve_points
113
+ with_features map(&:dissolve_points)
114
+ end
115
+
116
+ def union
117
+ return self if none?
118
+ with_features [inject(&:+)]
119
+ end
120
+
121
+ def rotate_by_degrees!(angle)
122
+ map! { |feature| feature.rotate_by_degrees(angle) }
123
+ end
124
+
88
125
  def clip(polygon)
89
126
  OS.ogr2ogr "-f", "GeoJSON", "-lco", "RFC7946=NO", "-clipsrc", polygon.wkt, "/vsistdout/", "GeoJSON:/vsistdin/" do |stdin|
90
127
  stdin.puts to_json
@@ -97,14 +134,10 @@ module NSWTopo
97
134
  map do |feature|
98
135
  feature.buffer(*margins, **options)
99
136
  end.then do |features|
100
- Collection.new projection: @projection, name: @name, features: features
137
+ with_features features
101
138
  end
102
139
  end
103
140
 
104
- def rename(name = nil)
105
- tap { @name = name }
106
- end
107
-
108
141
  # TODO: what about empty collections?
109
142
  def bounds
110
143
  map(&:bounds).transpose.map(&:flatten).map(&:minmax)
@@ -113,6 +146,19 @@ module NSWTopo
113
146
  def bbox
114
147
  GeoJSON.polygon [bounds.inject(&:product).values_at(0,2,3,1,0)], projection: @projection
115
148
  end
149
+
150
+ def bbox_centre
151
+ midpoint = bounds.map { |min, max| (max + min) / 2 }
152
+ GeoJSON.point midpoint, projection: @projection
153
+ end
154
+
155
+ def bbox_extents
156
+ bounds.map { |min, max| max - min }
157
+ end
158
+
159
+ def minimum_bbox_angle(*margins)
160
+ dissolve_points.union.first.minimum_bbox_angle(*margins)
161
+ end
116
162
  end
117
163
  end
118
164
  end