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.
- checksums.yaml +7 -0
- data/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
data/lib/rcas/plot3d.rb
ADDED
|
@@ -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
|