rcas 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. metadata +206 -0
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Surfaces in space.
5
+ #
6
+ # plot3d(sin(x)*cos(y), x: -3..3, y: -3..3)
7
+ # plot3d(x**2 - y**2) # -10..10 in both
8
+ # plot3d([cos(u)*sin(v), sin(u)*sin(v), cos(v)], u: 0..2*PI, v: 0..PI)
9
+ # plot3d(x*y, x: -1..1, y: -1..1, view: [30, 60]) # from somewhere else
10
+ #
11
+ # The surface is sampled on a rectangular mesh, every mesh point is carried
12
+ # into the picture by a parallel (axonometric) projection, and the
13
+ # quadrilaterals are painted from the back forwards: each one first erases
14
+ # what lies inside it and then draws its own edges. That is the depth sort,
15
+ # or painter's algorithm [NNS72] - there is no z buffer here, only the
16
+ # order - and it is what makes the picture read as a solid surface rather
17
+ # than as a transparent net.
18
+ #
19
+ # A Plot3D is a Plot, so it prints as braille art, `to_svg` and `save`
20
+ # write a picture and `show` puts one in the terminal. What it has not got
21
+ # is a pair of labelled axes: the box is drawn as its floor and the two
22
+ # walls behind the surface, and the three ranges are named underneath.
23
+ #
24
+ # Sources (keys: MANUAL.md, Sources): the depth sort [NNS72]; the braille
25
+ # canvas and the line drawing are plot.rb's.
26
+ class Plot3D < Plot
27
+ # Azimuth and elevation in degrees: where the surface is looked at from.
28
+ # The default is the familiar three-quarter view from above.
29
+ VIEW = [-60.0, 30.0].freeze
30
+
31
+ # Low, middle and high: the colour of a face in a picture follows its
32
+ # height, which is the one thing a flat picture loses.
33
+ RAMP = %w[#2563eb #059669 #d97706].freeze
34
+
35
+ # At most this many mesh lines across the terminal picture, whatever the
36
+ # mesh: every face is painted either way, so nothing hidden becomes
37
+ # visible, but drawing every line of a fine mesh makes a blot of it.
38
+ LINES = 10
39
+
40
+ # And no two of them closer than this, in braille dots: below it the
41
+ # dots of neighbouring lines merge and the surface stops reading.
42
+ APART = 9
43
+
44
+ # One quadrilateral of the mesh: its four projected corners (a missing one
45
+ # is nil), how far away it is and how high it lies. The corners are also
46
+ # the area it covers, when it has all four.
47
+ Face = Struct.new(:corners, :depth, :height)
48
+
49
+ attr_reader :zlo, :zhi, :axes, :azimuth, :elevation, :mesh, :rows, :columns, :spans
50
+
51
+ def initialize(mesh, xlo:, xhi:, ylo:, yhi:, zlo:, zhi:, axes:, equal: false,
52
+ view: VIEW, title: nil, width: 60, height: 20)
53
+ @xlo = xlo
54
+ @xhi = xhi
55
+ @ylo = ylo
56
+ @yhi = yhi
57
+ @zlo = zlo
58
+ @zhi = zhi
59
+ @axes = axes.map(&:to_s).freeze
60
+ @azimuth, @elevation = view.map(&:to_f)
61
+ @title = title
62
+ @width = width
63
+ @height = height
64
+ @curves = [].freeze
65
+ @var = Var.new(@axes.first.to_sym)
66
+ @spans = axis_spans(equal)
67
+ @cos_azimuth = Math.cos(@azimuth * Math::PI / 180)
68
+ @sin_azimuth = Math.sin(@azimuth * Math::PI / 180)
69
+ @cos_elevation = Math.cos(@elevation * Math::PI / 180)
70
+ @sin_elevation = Math.sin(@elevation * Math::PI / 180)
71
+ @mesh = mesh.map(&:freeze).freeze
72
+ @rows = mesh.size - 1
73
+ @columns = mesh.map(&:size).min - 1
74
+ @centre_u, @centre_v, @span_u, @span_v = viewport
75
+ freeze
76
+ end
77
+
78
+ def z_range = (zlo..zhi)
79
+
80
+ # ---- the projection ----------------------------------------------------
81
+
82
+ # How much of an axis one unit of the box is. Each axis is scaled to the
83
+ # box on its own, because the x, y and z of a graph z = f(x, y) are not
84
+ # in the same units; +equal+ gives all three the same scale instead,
85
+ # which is what a surface in space wants - a sphere should look round.
86
+ def axis_spans(equal)
87
+ lengths = [@xhi - @xlo, @yhi - @ylo, @zhi - @zlo].map { |d| d.abs < 1e-12 ? 1.0 : d.to_f }
88
+ (equal ? [lengths.max] * 3 : lengths).freeze
89
+ end
90
+
91
+ # A point of space as the picture sees it: the two screen coordinates and
92
+ # the depth, which grows towards the viewer. The eye lies in the
93
+ # direction (cos e cos a, cos e sin a, sin e); across the picture runs
94
+ # (-sin a, cos a, 0), and up it runs their cross product.
95
+ def project(point)
96
+ x, y, z = point
97
+ nx = (x - (@xlo + @xhi) / 2.0) / @spans[0]
98
+ ny = (y - (@ylo + @yhi) / 2.0) / @spans[1]
99
+ nz = (z - (@zlo + @zhi) / 2.0) / @spans[2]
100
+ flat = nx * @cos_azimuth + ny * @sin_azimuth
101
+ [-nx * @sin_azimuth + ny * @cos_azimuth,
102
+ nz * @cos_elevation - flat * @sin_elevation,
103
+ flat * @cos_elevation + nz * @sin_elevation]
104
+ end
105
+
106
+ # The box holds everything drawn, so its eight corners fix the picture:
107
+ # the centre and the extent of the two screen coordinates.
108
+ def viewport
109
+ corners = [@xlo, @xhi].product([@ylo, @yhi], [@zlo, @zhi]).map { |p| project(p) }
110
+ us = corners.map(&:first)
111
+ vs = corners.map { |c| c[1] }
112
+ span = ->(lo, hi) { (hi - lo).abs < 1e-12 ? 1.0 : hi - lo }
113
+ [(us.min + us.max) / 2.0, (vs.min + vs.max) / 2.0,
114
+ span.call(us.min, us.max), span.call(vs.min, vs.max)]
115
+ end
116
+
117
+ # One scale for both directions, so that turning the view moves the
118
+ # surface round without stretching it.
119
+ def scale_for(w, h) = [(w - 1) / @span_u, (h - 1) / @span_v].min
120
+
121
+ # The projection centred in a w by h rectangle.
122
+ def screen(point, w, h, left = 0.0, top = 0.0)
123
+ scale = scale_for(w, h)
124
+ [left + (w - 1) / 2.0 + (point[0] - @centre_u) * scale,
125
+ top + (h - 1) / 2.0 - (point[1] - @centre_v) * scale]
126
+ end
127
+
128
+ def pixel(point) = screen(point, width * 2, height * 4).map(&:round)
129
+
130
+ # ---- the mesh ----------------------------------------------------------
131
+
132
+ # The quadrilaterals of the mesh, every +step+-th point of it, farthest
133
+ # first. A cell with a missing corner - a hole in the surface, or a piece
134
+ # the z range cut off - keeps the sides it has but covers nothing: there
135
+ # is no area we could claim is in front.
136
+ #
137
+ # A coarser step is a coarser surface, and that is what the terminal
138
+ # wants. Drawing every line of a fine mesh into a braille canvas does not
139
+ # merely crowd it: neighbouring faces are then two or three dots wide,
140
+ # each rubs out its neighbour's edge where they meet, and the picture
141
+ # comes out speckled. A picture has the room, and takes every line.
142
+ def faces(step = 1)
143
+ down = indices(rows, step)
144
+ across = indices(columns, step)
145
+ faces = []
146
+ down.each_cons(2) do |i0, i1|
147
+ across.each_cons(2) do |j0, j1|
148
+ corners = [[i0, j0], [i1, j0], [i1, j1], [i0, j1]].map { |i, j| mesh[i][j] }
149
+ present = corners.compact
150
+ next if present.size < 2
151
+ points = corners.map { |c| c && project(c) }
152
+ faces << Face.new(points, points.compact.sum { |p| p[2] } / present.size,
153
+ present.sum(&:last) / present.size.to_f)
154
+ end
155
+ end
156
+ faces.sort_by!(&:depth)
157
+ faces
158
+ end
159
+
160
+ # 0, step, 2*step, ... and the last point, so the surface keeps its rim.
161
+ def indices(count, step) = (0..count).step([step, 1].max).to_a.push(count).uniq
162
+
163
+ # A face with all four corners covers the quadrilateral they span.
164
+ def polygon(face) = face.corners.all? ? face.corners : nil
165
+
166
+ # The sides of a face that have both their ends.
167
+ def sides(face)
168
+ (0..3).filter_map do |k|
169
+ a = face.corners[k]
170
+ b = face.corners[(k + 1) % 4]
171
+ [a, b] if a && b
172
+ end
173
+ end
174
+
175
+ # How many mesh points to a face in the terminal: enough that no two mesh
176
+ # lines land closer than APART braille dots, and no more than LINES of
177
+ # them across the picture.
178
+ def terminal_step
179
+ narrow = [@span_u, @span_v].min * scale_for(width * 2, height * 4)
180
+ wanted = [[(narrow / APART).round, 4].max, LINES].min
181
+ [([rows, columns].max / wanted.to_f).round, 1].max
182
+ end
183
+
184
+ # The floor of the box and the two walls behind the surface: enough to
185
+ # see which way the axes run, and no more, since a wall in front of the
186
+ # surface would be a lie the depth sort cannot undo.
187
+ def box_edges
188
+ floor = [[@xlo, @ylo], [@xhi, @ylo], [@xhi, @yhi], [@xlo, @yhi]]
189
+ rear = (0..3).min_by { |k| project([*floor[k], @zlo])[2] }
190
+ edges = (0..3).map { |k| [[*floor[k], @zlo], [*floor[(k + 1) % 4], @zlo]] }
191
+ edges << [[*floor[rear], @zlo], [*floor[rear], @zhi]]
192
+ [-1, 1].each { |step| edges << [[*floor[rear], @zhi], [*floor[(rear + step) % 4], @zhi]] }
193
+ edges.map { |a, b| [project(a), project(b)] }
194
+ end
195
+
196
+ # ---- the terminal picture ----------------------------------------------
197
+
198
+ def to_s
199
+ canvas = Canvas.new(width, height)
200
+ box_edges.each { |a, b| canvas.line(*pixel(a), *pixel(b)) }
201
+ faces(terminal_step).each do |face|
202
+ area = polygon(face)
203
+ erase(canvas, area) if area
204
+ sides(face).each { |a, b| canvas.line(*pixel(a), *pixel(b)) }
205
+ end
206
+ frame(canvas.rows)
207
+ end
208
+
209
+ # What a face covers lies behind it. The dots inside the quadrilateral go
210
+ # before its own edges are drawn, so an edge it shares with a neighbour is
211
+ # rubbed out and put back rather than lost. The outline is walked as well
212
+ # as filled: a fine mesh has faces two or three dots wide, and the
213
+ # scanlines of one of those round away to nothing between the corners.
214
+ def erase(canvas, area)
215
+ points = area.map { |p| pixel(p) }
216
+ points.each_index do |k|
217
+ canvas.trace(*points[k], *points[(k + 1) % points.size]) { |px, py| canvas.unset(px, py) }
218
+ end
219
+ ys = points.map(&:last)
220
+ (ys.min..ys.max).each do |py|
221
+ crossings(points, py + 0.5).each_slice(2) do |left, right|
222
+ next if right.nil?
223
+ (left.round..right.round).each { |px| canvas.unset(px, py) }
224
+ end
225
+ end
226
+ end
227
+
228
+ # Where a horizontal line meets the outline, left to right. Taking the
229
+ # crossings in pairs fills a quadrilateral that is not convex correctly,
230
+ # which a projected mesh cell need not be.
231
+ def crossings(points, y)
232
+ points.each_index.filter_map do |k|
233
+ x0, y0 = points[k]
234
+ x1, y1 = points[(k + 1) % points.size]
235
+ next if (y0 <= y) == (y1 <= y)
236
+ x0 + (y - y0) * (x1 - x0).to_f / (y1 - y0)
237
+ end.sort
238
+ end
239
+
240
+ # No gutter and no axis line: the numbers go underneath instead.
241
+ def frame(art)
242
+ lines = []
243
+ lines << title if title
244
+ lines.concat(art)
245
+ lines << caption
246
+ lines.join("\n")
247
+ end
248
+
249
+ # What each of the three axes runs over - the picture cannot say it.
250
+ def caption
251
+ [[axes[0], xlo, xhi], [axes[1], ylo, yhi], [axes[2], zlo, zhi]]
252
+ .map { |name, lo, hi| "#{name}: #{self.class.label(lo)}..#{self.class.label(hi)}" }.join(" ")
253
+ end
254
+
255
+ # ---- pictures ----------------------------------------------------------
256
+
257
+ def to_svg(theme: nil)
258
+ w, h = svg_size
259
+ theme = (theme || Render.theme).to_s
260
+ background = theme == "light" ? "#ffffff" : "#111827"
261
+ foreground = theme == "light" ? "#111827" : "#e5e7eb"
262
+ grid = theme == "light" ? "#9ca3af" : "#4b5563"
263
+ top = title ? 34 : 14
264
+ area = h - top - 34
265
+ place = ->(p) { screen(p, w - 32, area, 16.0, top.to_f).map { |c| c.round(2) } }
266
+ parts = [%(<rect width="#{w}" height="#{h}" fill="#{background}"/>)]
267
+ parts << %(<text x="#{w / 2}" y="22" fill="#{foreground}" font-family="sans-serif" font-size="16" text-anchor="middle">#{escape(title)}</text>) if title
268
+ stroke = lambda do |a, b, colour, thick|
269
+ (x1, y1) = place.call(a)
270
+ (x2, y2) = place.call(b)
271
+ parts << %(<line x1="#{x1}" y1="#{y1}" x2="#{x2}" y2="#{y2}" stroke="#{colour}" stroke-width="#{thick}"/>)
272
+ end
273
+ box_edges.each { |a, b| stroke.call(a, b, grid, 1) }
274
+ faces.each do |face|
275
+ colour = shade(face.height)
276
+ area_of = polygon(face)
277
+ if area_of
278
+ points = area_of.map { |p| place.call(p).join(",") }.join(" ")
279
+ parts << %(<polygon points="#{points}" fill="#{colour}" fill-opacity="0.9" stroke="#{darken(colour)}" stroke-width="0.7" stroke-linejoin="round"/>)
280
+ else
281
+ sides(face).each { |a, b| stroke.call(a, b, colour, 1.2) }
282
+ end
283
+ end
284
+ parts << %(<text x="#{w / 2}" y="#{h - 12}" fill="#{foreground}" font-family="sans-serif" font-size="13" text-anchor="middle" xml:space="preserve">#{escape(caption)}</text>)
285
+ %(<svg xmlns="http://www.w3.org/2000/svg" width="#{w}" height="#{h}" viewBox="0 0 #{w} #{h}">#{parts.join}</svg>)
286
+ end
287
+
288
+ # The colour of a face: where its height lies between the floor of the
289
+ # box and the top of it.
290
+ def shade(z)
291
+ t = (z - zlo) / (zhi - zlo).to_f
292
+ t = 0.0 unless t.finite?
293
+ t = [[t, 0.0].max, 1.0].min * (RAMP.size - 1)
294
+ blend(RAMP[t.floor], RAMP[t.ceil], t - t.floor)
295
+ end
296
+
297
+ def blend(low, high, t)
298
+ pairs = [low, high].map { |colour| channels(colour) }
299
+ "#" + pairs[0].zip(pairs[1]).map { |a, b| format("%02x", (a + (b - a) * t).round) }.join
300
+ end
301
+
302
+ def darken(colour, factor = 0.65) = "#" + channels(colour).map { |c| format("%02x", (c * factor).round) }.join
303
+
304
+ def channels(colour) = colour[1..].scan(/../).map { |pair| pair.to_i(16) }
305
+ end
306
+
307
+ # Building surfaces from expressions.
308
+ module Plotting
309
+ MESH = 24
310
+
311
+ module_function
312
+
313
+ # plot3d(f, x: -2..2, y: -2..2) for the graph z = f(x, y), or
314
+ # plot3d([X, Y, Z], u: .., v: ..) for a surface given by a parametrization.
315
+ def plot3d(f, n: MESH, z: nil, view: nil, equal: nil, title: nil, width: 60, height: 20, **ranges)
316
+ spans = ranges.select { |_, r| r.is_a?(Range) }
317
+ unnamed = ranges.keys - spans.keys
318
+ raise ArgumentError, "plot3d: expected a range, e.g. #{unnamed.first}: -2..2" unless unnamed.empty?
319
+ raise ArgumentError, "plot3d: give two ranges, e.g. plot3d(f, x: -2..2, y: -2..2)" unless spans.empty? || spans.size == 2
320
+ raise ArgumentError, "plot3d: the mesh n: must be a positive integer" unless n.is_a?(Integer) && n.positive?
321
+ angles = view_angles(view)
322
+ if f.is_a?(Array)
323
+ parametric_surface(f, spans, n, z, angles, equal.nil? || equal, title, width, height)
324
+ else
325
+ explicit_surface(f, spans, n, z, angles, !equal.nil? && equal, title, width, height)
326
+ end
327
+ end
328
+
329
+ # The graph z = f(x, y) over a rectangle of the plane.
330
+ def explicit_surface(f, spans, n, z, view, equal, title, width, height)
331
+ g = Expression.lift(f)
332
+ names = spans.empty? ? g.variables : spans.keys
333
+ raise ArgumentError, "plot3d: #{g} has #{names.size} variables; a surface needs two, e.g. x: -2..2, y: -2..2" unless names.size == 2
334
+ bounds = names.map { |name| spans.empty? ? [DEFAULT_LOW, DEFAULT_HIGH] : range_ends(spans[name], name) }
335
+ tree = Expression.floatify_tree(g)
336
+ grid = mesh_points(bounds, n) do |x, y|
337
+ value = value_at(tree, names[0] => x, names[1] => y)
338
+ value && [x, y, value]
339
+ end
340
+ heights = grid.flatten(1).compact.map(&:last)
341
+ raise Plot::Error, "plot3d: #{g} has no finite values in this range" if heights.empty?
342
+ zlo, zhi = z ? range_ends(z, :z) : autoscale(heights.sort)
343
+ Plot3D.new(clip(grid, zlo, zhi),
344
+ xlo: bounds[0][0], xhi: bounds[0][1], ylo: bounds[1][0], yhi: bounds[1][1], zlo: zlo, zhi: zhi,
345
+ axes: [names[0], names[1], :z], equal: equal, view: view, title: title, width: width, height: height)
346
+ end
347
+
348
+ # A surface given by three components of two parameters. The axes are the
349
+ # coordinates of space, whatever the parameters are called, and all three
350
+ # keep the same scale unless equal: false says otherwise: a sphere drawn
351
+ # in a box of its own would not be round.
352
+ def parametric_surface(components, spans, n, z, view, equal, title, width, height)
353
+ raise ArgumentError, "plot3d: a surface in space has three components, [x(u, v), y(u, v), z(u, v)]" unless components.size == 3
354
+ raise ArgumentError, "plot3d: a parametric surface needs two ranges, e.g. u: 0..PI, v: 0..2*PI" unless spans.size == 2
355
+ names = spans.keys
356
+ bounds = names.map { |name| range_ends(spans[name], name) }
357
+ trees = components.map { |c| Expression.floatify_tree(Expression.lift(c)) }
358
+ grid = mesh_points(bounds, n) do |u, v|
359
+ point = trees.map { |tree| value_at(tree, names[0] => u, names[1] => v) }
360
+ point.all? ? point : nil
361
+ end
362
+ points = grid.flatten(1).compact
363
+ raise Plot::Error, "plot3d: the surface has no finite points in this range" if points.empty?
364
+ xlo, xhi = autoscale(points.map(&:first).sort)
365
+ ylo, yhi = autoscale(points.map { |p| p[1] }.sort)
366
+ zlo, zhi = z ? range_ends(z, :z) : autoscale(points.map(&:last).sort)
367
+ Plot3D.new(clip(grid, zlo, zhi), xlo: xlo, xhi: xhi, ylo: ylo, yhi: yhi, zlo: zlo, zhi: zhi,
368
+ axes: %i[x y z], equal: equal, view: view, title: title, width: width, height: height)
369
+ end
370
+
371
+ # (n + 1) by (n + 1) points of the parameter rectangle, the first
372
+ # parameter along the rows.
373
+ def mesh_points(bounds, n)
374
+ (0..n).map do |i|
375
+ u = bounds[0][0] + (bounds[0][1] - bounds[0][0]) * i / n.to_f
376
+ (0..n).map do |j|
377
+ v = bounds[1][0] + (bounds[1][1] - bounds[1][0]) * j / n.to_f
378
+ yield(u, v)
379
+ end
380
+ end
381
+ end
382
+
383
+ # A point above or below the box is a gap, as a value off the top of a
384
+ # plot is: the surface is cut there rather than flattened against the lid.
385
+ def clip(grid, zlo, zhi)
386
+ grid.map { |row| row.map { |point| point&.last&.between?(zlo, zhi) ? point : nil } }
387
+ end
388
+
389
+ def value_at(tree, bindings)
390
+ v = tree.call(**bindings)
391
+ v = v.value if v.is_a?(Num)
392
+ return nil unless v.is_a?(Numeric) && !v.is_a?(Complex)
393
+ v = v.to_f
394
+ v.finite? ? v : nil
395
+ rescue StandardError => rescued
396
+ RCAS.guard!(rescued)
397
+ nil
398
+ end
399
+
400
+ def range_ends(range, name)
401
+ raise ArgumentError, "plot3d: expected a range, e.g. #{name}: -2..2" unless range.is_a?(Range)
402
+ lo = numeric(range.begin)
403
+ hi = numeric(range.end)
404
+ raise ArgumentError, "plot3d: the #{name} range must be finite, got #{range.begin}..#{range.end}" if lo.nil? || hi.nil?
405
+ raise ArgumentError, "plot3d: the #{name} range is empty" if hi <= lo
406
+ [lo, hi]
407
+ end
408
+
409
+ # view: [azimuth, elevation] in degrees - turning the surface round, and
410
+ # raising the eye above it.
411
+ def view_angles(view)
412
+ return Plot3D::VIEW if view.nil?
413
+ raise ArgumentError, "plot3d: view: [azimuth, elevation] in degrees" unless view.is_a?(Array) && view.size == 2
414
+ angles = view.map { |a| numeric(a) }
415
+ raise ArgumentError, "plot3d: the view angles must be numbers" if angles.any?(&:nil?)
416
+ angles
417
+ end
418
+ end
419
+ end