gifenc 0.1.0 → 0.2.1
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 +4 -4
- data/.yardopts +1 -1
- data/CHANGELOG.md +41 -0
- data/README.md +8 -3
- data/lib/errors.rb +4 -0
- data/lib/extensions.rb +11 -11
- data/lib/geometry.rb +638 -23
- data/lib/gif.rb +47 -2
- data/lib/gifenc.rb +7 -0
- data/lib/image.rb +881 -117
- data/lib/util.rb +10 -0
- metadata +11 -8
data/lib/image.rb
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require 'lzwrb'
|
|
2
|
-
|
|
3
1
|
module Gifenc
|
|
4
2
|
# Represents a single image. A GIF may contain multiple images, and they need
|
|
5
3
|
# not be animation frames (they could simply be tiles of a static image).
|
|
@@ -13,6 +11,9 @@ module Gifenc
|
|
|
13
11
|
# be chained properly.
|
|
14
12
|
class Image
|
|
15
13
|
|
|
14
|
+
# # 1-byte field indicating the beginning of an image block.
|
|
15
|
+
IMAGE_SEPARATOR = ','.freeze
|
|
16
|
+
|
|
16
17
|
# Width of the image in pixels. Use the {#resize} method to change it.
|
|
17
18
|
# @return [Integer] Image width.
|
|
18
19
|
# @see #resize
|
|
@@ -109,12 +110,13 @@ module Gifenc
|
|
|
109
110
|
@width = bbox[2]
|
|
110
111
|
@height = bbox[3]
|
|
111
112
|
end
|
|
112
|
-
@width
|
|
113
|
-
@height
|
|
114
|
-
@x
|
|
115
|
-
@y
|
|
116
|
-
@lct
|
|
117
|
-
@interlace
|
|
113
|
+
@width = width if width
|
|
114
|
+
@height = height if height
|
|
115
|
+
@x = x if x
|
|
116
|
+
@y = y if y
|
|
117
|
+
@lct = lct
|
|
118
|
+
@interlace = interlace
|
|
119
|
+
@compressed = false
|
|
118
120
|
|
|
119
121
|
# Checks
|
|
120
122
|
raise Exception::CanvasError, "The width of the image must be supplied" if !@width
|
|
@@ -143,20 +145,17 @@ module Gifenc
|
|
|
143
145
|
@gce.encode(stream) if @gce
|
|
144
146
|
|
|
145
147
|
# Image descriptor
|
|
146
|
-
stream <<
|
|
147
|
-
stream << [@x, @y, @width, @height].pack('S<4')
|
|
148
|
+
stream << IMAGE_SEPARATOR
|
|
149
|
+
stream << [@x, @y, @width, @height].pack('S<4'.freeze)
|
|
148
150
|
flags = (@interlace ? 1 : 0) << 6
|
|
149
151
|
flags |= @lct.local_flags if @lct
|
|
150
|
-
stream << [flags].pack('C')
|
|
152
|
+
stream << [flags].pack('C'.freeze)
|
|
151
153
|
|
|
152
154
|
# Local Color Table
|
|
153
155
|
@lct.encode(stream) if @lct
|
|
154
156
|
|
|
155
157
|
# LZW-compressed image data
|
|
156
|
-
|
|
157
|
-
stream << min_bits.chr
|
|
158
|
-
lzw = LZWrb.new(preset: LZWrb::PRESET_GIF, min_bits: min_bits, verbosity: :minimal)
|
|
159
|
-
stream << Util.blockify(lzw.encode(@pixels.pack('C*')))
|
|
158
|
+
stream << (@compressed ? @pixels : Util.lzw_encode(@pixels.pack('C*'.freeze)))
|
|
160
159
|
end
|
|
161
160
|
|
|
162
161
|
# Create a duplicate copy of this image.
|
|
@@ -168,7 +167,7 @@ module Gifenc
|
|
|
168
167
|
@width, @height, @x, @y,
|
|
169
168
|
color: @color, gce: gce, delay: @delay, trans_color: @trans_color,
|
|
170
169
|
disposal: @disposal, interlace: @interlace, lct: lct
|
|
171
|
-
)
|
|
170
|
+
).replace(@pixels.dup)
|
|
172
171
|
image
|
|
173
172
|
end
|
|
174
173
|
|
|
@@ -187,6 +186,7 @@ module Gifenc
|
|
|
187
186
|
# @return (see #delay)
|
|
188
187
|
# @see (see #delay)
|
|
189
188
|
def delay=(value)
|
|
189
|
+
return if !value
|
|
190
190
|
@gce = Extension::GraphicControl.new if !@gce
|
|
191
191
|
@gce.delay = value
|
|
192
192
|
end
|
|
@@ -208,6 +208,7 @@ module Gifenc
|
|
|
208
208
|
# @return (see #disposal)
|
|
209
209
|
# @see (see #disposal)
|
|
210
210
|
def disposal=(value)
|
|
211
|
+
return if !value
|
|
211
212
|
@gce = Extension::GraphicControl.new if !@gce
|
|
212
213
|
@gce.disposal = value
|
|
213
214
|
end
|
|
@@ -227,10 +228,33 @@ module Gifenc
|
|
|
227
228
|
# @return (see #trans_color)
|
|
228
229
|
# @see (see #trans_color)
|
|
229
230
|
def trans_color=(value)
|
|
231
|
+
return if !value
|
|
230
232
|
@gce = Extension::GraphicControl.new if !@gce
|
|
231
233
|
@gce.trans_color = value
|
|
232
234
|
end
|
|
233
235
|
|
|
236
|
+
# Fetch one row of pixels from the image.
|
|
237
|
+
# @param row [Integer] The index of the row to fetch.
|
|
238
|
+
# @return [Array<Integer>] The row of pixels.
|
|
239
|
+
# @raise [Exception::CanvasError] If the row is out of bounds.
|
|
240
|
+
def row(row)
|
|
241
|
+
if row < 0 || row >= @height
|
|
242
|
+
raise Exception::CanvasError, "Row out of bounds."
|
|
243
|
+
end
|
|
244
|
+
@pixels[row * @width, @width]
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Fetch one column of pixels from the image.
|
|
248
|
+
# @param col [Integer] The index of the column to fetch.
|
|
249
|
+
# @return [Array<Integer>] The column of pixels.
|
|
250
|
+
# @raise [Exception::CanvasError] If the column is out of bounds.
|
|
251
|
+
def col(col)
|
|
252
|
+
if col < 0 || col >= @width
|
|
253
|
+
raise Exception::CanvasError, "Column out of bounds."
|
|
254
|
+
end
|
|
255
|
+
@height.times.map{ |r| @pixels[col, r] }
|
|
256
|
+
end
|
|
257
|
+
|
|
234
258
|
# Change the pixel data (color indices) of the image. The size of the array
|
|
235
259
|
# must match the current dimensions of the canvas, otherwise a manual resize
|
|
236
260
|
# is first required.
|
|
@@ -247,6 +271,108 @@ module Gifenc
|
|
|
247
271
|
self
|
|
248
272
|
end
|
|
249
273
|
|
|
274
|
+
# Destroy the pixel data of the image. This simply substitutes the contents
|
|
275
|
+
# of the array, hoping that the underlying data will go out of scope and
|
|
276
|
+
# be collected by the garbage collector. This is intended for freeing
|
|
277
|
+
# space and to simulate "destroying" the image.
|
|
278
|
+
# @return (see #initialize)
|
|
279
|
+
def destroy
|
|
280
|
+
@pixels = nil
|
|
281
|
+
self
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Paint the whole canvas with the base image color.
|
|
285
|
+
# @return (see #initialize)
|
|
286
|
+
def clear
|
|
287
|
+
@pixels = [@color] * (@width * @height)
|
|
288
|
+
self
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Copy a rectangular region from another image to this one. The dimension of
|
|
292
|
+
# the region, as well as the source offset and the destination offset, can
|
|
293
|
+
# be provided. If the region would go out of bounds, the function will
|
|
294
|
+
# just gracefully crop it rather than failing. Additionally, a more restrictive
|
|
295
|
+
# bounding box (smaller than the full image) can also be provided, to where
|
|
296
|
+
# the copying will be confined.
|
|
297
|
+
# @note The two images are assumed to have the same color table, since what
|
|
298
|
+
# is copied is the color indexes.
|
|
299
|
+
# @param src [Image] The source image to copy the contents from.
|
|
300
|
+
# @param offset [Array<Integer>] The coordinates of the offset of the region
|
|
301
|
+
# in the source image.
|
|
302
|
+
# @param dim [Array<Integer>] The dimensions of the region, in the form `[W, H]`,
|
|
303
|
+
# where W is the width and H is the height of the rectangle to copy. If
|
|
304
|
+
# unspecified (`nil`), the whole source image will be copied.
|
|
305
|
+
# @param dest [Array<Integer>] The coordinates of the destination offset of
|
|
306
|
+
# the region in this image.
|
|
307
|
+
# @param trans [Boolean] If enabled, the pixels from the source image whose
|
|
308
|
+
# color is the transparent one (for the source image) won't be copied over,
|
|
309
|
+
# effectively achieving the usual GIF composition with basic transparency.
|
|
310
|
+
# It's a bit slower as a consequence.
|
|
311
|
+
# @param bbox [Array<Integer>] The bounding box (with respect to this image)
|
|
312
|
+
# where the copying should be restricted to. Everything outside this region
|
|
313
|
+
# will be left untouched. If unspecified (`nil`), this will be the whole
|
|
314
|
+
# image. As usual, the format is `[X, Y, W, H]`, where `[X, Y]` are the
|
|
315
|
+
# coordinates of the upper left pixel, and `[W, H]` are the pixel width
|
|
316
|
+
# and height, respectively.
|
|
317
|
+
# @raise [Exception::CanvasError] If no source provided.
|
|
318
|
+
# @return (see #initialize)
|
|
319
|
+
def copy(src: nil, offset: [0, 0], dim: nil, dest: [0, 0], trans: false, bbox: nil)
|
|
320
|
+
raise Exception::CanvasError, "Cannot copy, no source provided." if !src
|
|
321
|
+
|
|
322
|
+
# Parse parameters
|
|
323
|
+
bbox = [0, 0, @width, @height] unless bbox
|
|
324
|
+
dim = [src.width, src.height] unless dim
|
|
325
|
+
offset = Geometry::Point.parse(offset).round
|
|
326
|
+
dim = Geometry::Point.parse(dim).round
|
|
327
|
+
dest = Geometry::Point.parse(dest).round
|
|
328
|
+
|
|
329
|
+
# Normalize main bbox
|
|
330
|
+
bbox = Geometry.rect_overlap(bbox, [0, 0, @width, @height])
|
|
331
|
+
return if !bbox
|
|
332
|
+
bbox.map!(&:round)
|
|
333
|
+
|
|
334
|
+
# Normalize source bbox
|
|
335
|
+
src_bbox = [offset.x, offset.y, dim.x, dim.y]
|
|
336
|
+
src_bbox = Geometry.rect_overlap(src_bbox, [0, 0, src.width, src.height])
|
|
337
|
+
return if !src_bbox
|
|
338
|
+
offset = Geometry::Point.parse(src_bbox[0, 2])
|
|
339
|
+
dim = Geometry::Point.parse(src_bbox[2, 2])
|
|
340
|
+
|
|
341
|
+
# Normalize destination bbox
|
|
342
|
+
dest_bbox = [dest.x, dest.y, dim.x, dim.y]
|
|
343
|
+
overlap = Geometry.rect_overlap(dest_bbox, bbox)
|
|
344
|
+
return if !dest_bbox
|
|
345
|
+
dest = Geometry::Point.parse(overlap[0, 2])
|
|
346
|
+
dim = Geometry::Point.parse(overlap[2, 2])
|
|
347
|
+
|
|
348
|
+
# Transform coordinates of source to coordinates of destination
|
|
349
|
+
offset += Gifenc::Geometry.transform([dest], dest_bbox)[0]
|
|
350
|
+
|
|
351
|
+
# Handy fetch
|
|
352
|
+
dx, dy = dest.x.round, dest.y.round
|
|
353
|
+
ox, oy = offset.x.round, offset.y.round
|
|
354
|
+
lx, ly = dim.x.round, dim.y.round
|
|
355
|
+
bg = src.trans_color
|
|
356
|
+
|
|
357
|
+
# Copy pixel data. We use a different, slightly faster, algorithm if we
|
|
358
|
+
# don't have a bg color check to make, by directly copying full rows.
|
|
359
|
+
if trans && bg
|
|
360
|
+
c = nil
|
|
361
|
+
ly.times.each{ |y|
|
|
362
|
+
lx.times.each{ |x|
|
|
363
|
+
c = src[ox + x, oy + y]
|
|
364
|
+
self[dx + x, dy + y] = c unless c == bg
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
else
|
|
368
|
+
ly.times.each{ |y|
|
|
369
|
+
@pixels[(dy + y) * @width + dx, lx] = src.pixels[(oy + y) * src.width + ox, lx]
|
|
370
|
+
}
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
self
|
|
374
|
+
end
|
|
375
|
+
|
|
250
376
|
# Change the image's width and height. If the provided values are smaller,
|
|
251
377
|
# the image is cropped. If they are larger, the image is padded with the
|
|
252
378
|
# color specified by {#color}.
|
|
@@ -288,6 +414,12 @@ module Gifenc
|
|
|
288
414
|
self
|
|
289
415
|
end
|
|
290
416
|
|
|
417
|
+
def compress
|
|
418
|
+
raise Exception::CanvasError, "Image is already compressed." if @compressed
|
|
419
|
+
@pixels = Util.lzw_encode(@pixels.pack('C*'))
|
|
420
|
+
@compressed = true
|
|
421
|
+
end
|
|
422
|
+
|
|
291
423
|
# Returns the bounding box of the image. This is a tuple of the form
|
|
292
424
|
# `[X, Y, W, H]`, where `[X, Y]` are the coordinates of its upper left
|
|
293
425
|
# corner - i.e., it's offset in the logical screen - and `[W, H]` are
|
|
@@ -303,7 +435,7 @@ module Gifenc
|
|
|
303
435
|
# @param y [Integer] The Y coordinate of the pixel.
|
|
304
436
|
# @return [Integer] The color index of the pixel.
|
|
305
437
|
def [](x, y)
|
|
306
|
-
@pixels[y * width + x]
|
|
438
|
+
@pixels[y * @width + x]
|
|
307
439
|
end
|
|
308
440
|
|
|
309
441
|
# Set the value (color _index_) of a pixel fast (i.e. without bound checks).
|
|
@@ -313,7 +445,7 @@ module Gifenc
|
|
|
313
445
|
# @param color [Integer] The new color index of the pixel.
|
|
314
446
|
# @return [Integer] The new color index of the pixel.
|
|
315
447
|
def []=(x, y, color)
|
|
316
|
-
@pixels[y * width + x] = color & 0xFF
|
|
448
|
+
@pixels[y * @width + x] = color & 0xFF
|
|
317
449
|
end
|
|
318
450
|
|
|
319
451
|
# Get the values (color _index_) of a list of pixels safely (i.e. with bound
|
|
@@ -323,10 +455,10 @@ module Gifenc
|
|
|
323
455
|
# @return [Array<Integer>] The list of colors, in the same order.
|
|
324
456
|
# @raise [Exception::CanvasError] If any of the specified points is out of bounds.
|
|
325
457
|
def get(points)
|
|
326
|
-
bound_check(points.min_by(&:first)[0], points.min_by(&:last)[1])
|
|
327
|
-
bound_check(points.max_by(&:first)[0], points.max_by(&:last)[1])
|
|
458
|
+
bound_check([points.min_by(&:first)[0], points.min_by(&:last)[1]], false)
|
|
459
|
+
bound_check([points.max_by(&:first)[0], points.max_by(&:last)[1]], false)
|
|
328
460
|
points.map{ |p|
|
|
329
|
-
@pixels[p[1] * width + p[0]]
|
|
461
|
+
@pixels[p[1] * @width + p[0]]
|
|
330
462
|
}
|
|
331
463
|
end
|
|
332
464
|
|
|
@@ -341,15 +473,29 @@ module Gifenc
|
|
|
341
473
|
# @return (see #initialize)
|
|
342
474
|
# @raise [Exception::CanvasError] If any of the specified points is out of bounds.
|
|
343
475
|
def set(points, colors)
|
|
344
|
-
bound_check(points.min_by(&:first)[0], points.min_by(&:last)[1])
|
|
345
|
-
bound_check(points.max_by(&:first)[0], points.max_by(&:last)[1])
|
|
476
|
+
bound_check([points.min_by(&:first)[0], points.min_by(&:last)[1]], false)
|
|
477
|
+
bound_check([points.max_by(&:first)[0], points.max_by(&:last)[1]], false)
|
|
346
478
|
single = colors.is_a?(Integer)
|
|
347
479
|
points.each_with_index{ |p, i|
|
|
348
|
-
@pixels[p[1] * width + p[0]] = single ? color & 0xFF : colors[i] & 0xFF
|
|
480
|
+
@pixels[p[1] * @width + p[0]] = single ? color & 0xFF : colors[i] & 0xFF
|
|
349
481
|
}
|
|
350
482
|
self
|
|
351
483
|
end
|
|
352
484
|
|
|
485
|
+
# Fill a contiguous region with a new color. This implements the classic
|
|
486
|
+
# flood fill algorithm used in bucket tools.
|
|
487
|
+
# @param x [Integer] X coordinate of the starting pixel.
|
|
488
|
+
# @param y [Integer] Y coordinate of the starting pixel.
|
|
489
|
+
# @param color [Integer] Index of the color to fill the region with.
|
|
490
|
+
# @return (see #initialize)
|
|
491
|
+
# @raise [Exception::CanvasError] If the specified point is out of bounds.
|
|
492
|
+
def fill(x, y, color)
|
|
493
|
+
bound_check([x, y], false)
|
|
494
|
+
return self if self[x, y] == color
|
|
495
|
+
fill_span(x, y, self[x, y], color)
|
|
496
|
+
self
|
|
497
|
+
end
|
|
498
|
+
|
|
353
499
|
# Draw a straight line connecting 2 points. It requires the startpoint `p1`
|
|
354
500
|
# and _either_ of the following:
|
|
355
501
|
# * The endpoint (`p2`).
|
|
@@ -368,62 +514,66 @@ module Gifenc
|
|
|
368
514
|
# the `direction` or the `angle` method is being used.
|
|
369
515
|
# @param color [Integer] Index of the color of the line.
|
|
370
516
|
# @param weight [Integer] Width of the line in pixels.
|
|
371
|
-
# @param
|
|
372
|
-
#
|
|
517
|
+
# @param anchor [Float] Since the weight can be multiple pixels, this argument
|
|
518
|
+
# indicates the position of the line with respect to the coordinates. It
|
|
519
|
+
# must be in the interval [-1, 1]. A value of `0` centers the line in its
|
|
520
|
+
# width, a value of `-1` draws it on one side, and `1` on the other.
|
|
521
|
+
# @param bbox [Array<Integer>] Bounding box determining the region to which
|
|
522
|
+
# the drawing will be restricted, in the format `[X, Y, W, H]`, where `[X, Y]`
|
|
523
|
+
# are the coordinates of the upper left corner of the box, and `[W, H]` are
|
|
524
|
+
# the pixel dimensions. If unspecified (`nil`), this defaults to the whole
|
|
525
|
+
# image.
|
|
526
|
+
# @param avoid [Array<Integer>] List of colors over which the line should
|
|
527
|
+
# NOT be drawn.
|
|
528
|
+
# @param style [Symbol] Named style / pattern of the line. Can be `:solid`
|
|
529
|
+
# (default), `:dashed` and `:dotted`. Fine grained control can be obtained
|
|
530
|
+
# with the `pattern` option.
|
|
531
|
+
# @param density [Symbol] Density of the line style/ pattern. Can be `:normal`
|
|
532
|
+
# (default), `:dense` and `:loose`. Only relevant when the `style` option
|
|
533
|
+
# is used (and only makes a difference for dashed and dotted patterns).
|
|
534
|
+
# Fine grained control can be obtained with the `pattern` option.
|
|
535
|
+
# @param pattern [Array<Integer>] A pair of integers specifying what portion
|
|
536
|
+
# of the line should be ON (i.e. drawn) and OFF (i.e. spacing). With this
|
|
537
|
+
# option, any arbitrary pattern can be achieved. Common patterns, such as
|
|
538
|
+
# dotted and dashed, can be achieved more simply using the `style` and
|
|
539
|
+
# `density` options instead.
|
|
540
|
+
# @param pattern_offset [Integer] If the line style is not solid, this integer
|
|
541
|
+
# will offset /shift the pattern a fixed number of pixels forward (or backwards).
|
|
373
542
|
# @return (see #initialize)
|
|
374
543
|
# @raise [Exception::CanvasError] If the line would go out of bounds.
|
|
375
544
|
# @todo Add support for anchors and anti-aliasing, better brushes, etc.
|
|
376
|
-
def line(p1: nil, p2: nil, vector: nil, angle: nil,
|
|
377
|
-
|
|
545
|
+
def line(p1: nil, p2: nil, vector: nil, angle: nil, direction: nil,
|
|
546
|
+
length: nil, color: 0, weight: 1, anchor: 0, bbox: nil, avoid: [],
|
|
547
|
+
style: :solid, density: :normal, pattern: nil, pattern_offset: 0)
|
|
378
548
|
# Determine start and end points
|
|
379
549
|
raise Exception::CanvasError, "The line start must be specified." if !p1
|
|
380
|
-
|
|
550
|
+
p1 = Geometry::Point.parse(p1)
|
|
381
551
|
if p2
|
|
382
|
-
|
|
552
|
+
p2 = Geometry::Point.parse(p2)
|
|
383
553
|
else
|
|
384
|
-
|
|
554
|
+
p2 = Geometry.endpoint(
|
|
385
555
|
point: p1, vector: vector, direction: direction,
|
|
386
556
|
angle: angle, length: length
|
|
387
557
|
)
|
|
388
558
|
end
|
|
389
|
-
|
|
390
|
-
bound_check(x1, y1)
|
|
391
|
-
|
|
392
|
-
# Normalize coordinates
|
|
393
|
-
swap = (y1 - y0).abs > (x1 - x0).abs
|
|
394
|
-
x0, x1, y0, y1 = y0, y1, x0, x1 if swap
|
|
395
|
-
|
|
396
|
-
# Precompute useful parameters
|
|
397
|
-
dx = x1 - x0
|
|
398
|
-
dy = y1 - y0
|
|
399
|
-
sx = dx < 0 ? -1 : 1
|
|
400
|
-
sy = dy < 0 ? -1 : 1
|
|
401
|
-
dx *= sx
|
|
402
|
-
dy *= sy
|
|
403
|
-
x, y = x0, y0
|
|
404
|
-
s = [sx, sy]
|
|
405
|
-
|
|
406
|
-
# If both endpoints are the same, draw a single point and return
|
|
407
|
-
if dx == 0
|
|
408
|
-
line_chunk(x0, y0, color, weight, swap)
|
|
409
|
-
return self
|
|
410
|
-
end
|
|
559
|
+
pattern = parse_line_pattern(style, density, weight) unless pattern
|
|
411
560
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
# Draw line chunks
|
|
419
|
-
0.upto(max) do |i|
|
|
420
|
-
e_acc += e
|
|
421
|
-
y += sy if e_acc > 0xFFFF unless i == 0 && e == 0x10000
|
|
422
|
-
e_acc &= 0xFFFF
|
|
423
|
-
w = 0xFF - (e_acc >> 8)
|
|
424
|
-
brush_chunk(x, y, color, weight, swap)
|
|
425
|
-
x += sx
|
|
561
|
+
if (p2 - p1).norm < Geometry::PRECISION
|
|
562
|
+
a = Geometry::ORIGIN
|
|
563
|
+
else
|
|
564
|
+
a = (p2 - p1).normal_right.normalize_inf
|
|
565
|
+
a -= a * (1 - anchor)
|
|
426
566
|
end
|
|
567
|
+
steps = (p2 - p1).norm_inf.ceil + 1
|
|
568
|
+
delta = (p2 - p1) / [(steps - 1), 1].max
|
|
569
|
+
point = p1
|
|
570
|
+
brush = Brush.square(weight, color, [a.x, a.y])
|
|
571
|
+
steps.times.each_with_index{ |s|
|
|
572
|
+
if (s - pattern_offset) % pattern.sum < pattern[0]
|
|
573
|
+
brush.draw(point.x.round, point.y.round, self, bbox: bbox, avoid: avoid)
|
|
574
|
+
end
|
|
575
|
+
point += delta
|
|
576
|
+
}
|
|
427
577
|
|
|
428
578
|
self
|
|
429
579
|
end
|
|
@@ -436,76 +586,690 @@ module Gifenc
|
|
|
436
586
|
# @param stroke [Integer] Index of the border color.
|
|
437
587
|
# @param fill [Integer] Index of the fill color (`nil` for no fill).
|
|
438
588
|
# @param weight [Integer] Stroke width of the border in pixels.
|
|
589
|
+
# @param anchor [Float] Indicates the position of the border with respect
|
|
590
|
+
# to the rectangle's boundary. Must be between -1 and 1. For example:
|
|
591
|
+
# * For `0` the border is centered around the boundary.
|
|
592
|
+
# * For `1` the border is entirely contained within the boundary.
|
|
593
|
+
# * For `-1` the border is entirely surrounding the boundary.
|
|
594
|
+
# @param style [Symbol] Border line style (`:solid`, `:dashed`, `:dotted`).
|
|
595
|
+
# See `style` param in {#line}).
|
|
596
|
+
# @param density [Symbol] Border line pattern density (`:normal`, `:dense`,
|
|
597
|
+
# `:loose`). See `density` param in {#line}.
|
|
598
|
+
# @param bbox [Array<Integer>] Bounding box determining the region to which
|
|
599
|
+
# the drawing will be restricted. See `bbox` param in {#line}.
|
|
439
600
|
# @return (see #initialize)
|
|
440
601
|
# @raise [Exception::CanvasError] If the rectangle would go out of bounds.
|
|
441
|
-
def rect(x, y, w, h, stroke, fill = nil, weight: 1
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
602
|
+
def rect(x, y, w, h, stroke = nil, fill = nil, weight: 1, anchor: 1,
|
|
603
|
+
style: :solid, density: :normal, bbox: nil)
|
|
604
|
+
# Normalize bbox
|
|
605
|
+
bbox = [0, 0, @width, @height] unless bbox
|
|
606
|
+
bbox = Geometry.rect_overlap(bbox, [0, 0, @width, @height])
|
|
607
|
+
return if !bbox
|
|
608
|
+
bbox.map!(&:round)
|
|
609
|
+
|
|
610
|
+
# Intersect bbox with rectangle
|
|
611
|
+
rect_bbox = Geometry.rect_overlap(bbox, [x, y, w, h])
|
|
612
|
+
return if !rect_bbox
|
|
613
|
+
x0 = rect_bbox[0].round
|
|
614
|
+
y0 = rect_bbox[1].round
|
|
615
|
+
x1 = (rect_bbox[0] + rect_bbox[2]).round - 1
|
|
616
|
+
y1 = (rect_bbox[1] + rect_bbox[3]).round - 1
|
|
446
617
|
|
|
447
618
|
# Fill rectangle, if provided
|
|
448
619
|
if fill
|
|
449
|
-
|
|
450
|
-
|
|
620
|
+
(x0 .. x1).each{ |x|
|
|
621
|
+
(y0 .. y1).each{ |y|
|
|
451
622
|
@pixels[y * @width + x] = fill
|
|
452
|
-
|
|
453
|
-
|
|
623
|
+
}
|
|
624
|
+
}
|
|
454
625
|
end
|
|
455
626
|
|
|
456
627
|
# Rectangle border
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
628
|
+
if stroke
|
|
629
|
+
if anchor != 0
|
|
630
|
+
o = ((weight - 1) / 2.0 * anchor).round
|
|
631
|
+
w -= 2 * o - (weight % 2 == 0 ? 1 : 0)
|
|
632
|
+
h -= 2 * o - (weight % 2 == 0 ? 1 : 0)
|
|
633
|
+
rect(x0 + o, y0 + o, w, h, stroke, weight: weight, anchor: 0)
|
|
634
|
+
else
|
|
635
|
+
points = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]]
|
|
636
|
+
4.times.each{ |i|
|
|
637
|
+
line(
|
|
638
|
+
p1: points[i],
|
|
639
|
+
p2: points[(i + 1) % 4],
|
|
640
|
+
color: stroke,
|
|
641
|
+
weight: weight,
|
|
642
|
+
anchor: anchor,
|
|
643
|
+
style: style,
|
|
644
|
+
density: density
|
|
645
|
+
)
|
|
646
|
+
}
|
|
647
|
+
end
|
|
648
|
+
end
|
|
461
649
|
|
|
462
650
|
self
|
|
463
651
|
end
|
|
464
652
|
|
|
465
|
-
|
|
653
|
+
# Draw a rectangular grid of straight lines.
|
|
654
|
+
# @param x [Integer] The X offset in the image to begin the grid.
|
|
655
|
+
# @param y [Integer] The Y offset in the image to begin the grid.
|
|
656
|
+
# @param w [Integer] The width of the grid in pixels.
|
|
657
|
+
# @param h [Integer] The height of the grid in pixels.
|
|
658
|
+
# @param step_x [Integer] The separation of the vertical lines in pixels.
|
|
659
|
+
# @param step_y [Integer] The separation of the horizontal lines in pixels.
|
|
660
|
+
# @param off_x [Integer] The initial shift of the vertical lines with respect
|
|
661
|
+
# to the grid start, `x`.
|
|
662
|
+
# @param off_y [Integer] The initial shift of the horizontal lines with respect
|
|
663
|
+
# to the grid start, `y`.
|
|
664
|
+
# @param color [Integer] The index of the color in the color table to use for
|
|
665
|
+
# the grid lines.
|
|
666
|
+
# @param weight [Integer] The size of the brush to use for the grid lines.
|
|
667
|
+
# @param style [Symbol] Line style (`:solid`, `:dashed`, `:dotted`). See
|
|
668
|
+
# `style` param in {#line}).
|
|
669
|
+
# @param density [Symbol] Line pattern density (`:normal`, `:dense`, `:loose`).
|
|
670
|
+
# See `density` param in {#line}.
|
|
671
|
+
# @param pattern [Array<Integer>] Specifies the pattern of the line style.
|
|
672
|
+
# See `pattern` param in {#line}.
|
|
673
|
+
# @param pattern_offsets [Array<Integer>] Specifies the offsets of the patterns
|
|
674
|
+
# on each direction. See `pattern_offset` param in {#line}.
|
|
675
|
+
# @return (see #initialize)
|
|
676
|
+
# @raise [Exception::CanvasError] If the grid would go out of bounds.
|
|
677
|
+
def grid(x, y, w, h, step_x, step_y, off_x = 0, off_y = 0, color: 0, weight: 1,
|
|
678
|
+
style: :solid, density: :normal, pattern: nil, pattern_offsets: [0, 0])
|
|
679
|
+
|
|
680
|
+
if !Geometry.bound_check([[x, y], [x + w - 1, y + h - 1]], self, true)
|
|
681
|
+
raise Exception::CanvasError, "Grid out of bounds."
|
|
682
|
+
end
|
|
683
|
+
pattern = parse_line_pattern(style, density, weight) unless pattern
|
|
684
|
+
|
|
685
|
+
# Draw vertical lines
|
|
686
|
+
(x + off_x ... x + w).step(step_x).each{ |j|
|
|
687
|
+
line(
|
|
688
|
+
p1: [j, y], p2: [j, y + h - 1], color: color, weight: weight,
|
|
689
|
+
anchor: -1, pattern: pattern, pattern_offset: pattern_offsets[0]
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
# Draw horizontal lines
|
|
694
|
+
(y + off_y... y + h).step(step_y).each{ |i|
|
|
695
|
+
line(
|
|
696
|
+
p1: [x, i], p2: [x + w - 1, i], color: color, weight: weight,
|
|
697
|
+
anchor: 1, pattern: pattern, pattern_offset: pattern_offsets[1]
|
|
698
|
+
)
|
|
699
|
+
}
|
|
466
700
|
|
|
467
|
-
|
|
468
|
-
def bound_check(x, y)
|
|
469
|
-
Geometry.bound_check([[x, y]], [0, 0, @width, @height])
|
|
701
|
+
self
|
|
470
702
|
end
|
|
471
703
|
|
|
472
|
-
# Draw
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
704
|
+
# Draw an ellipse with the given properties.
|
|
705
|
+
# @param c [Array<Integer>] The X and Y coordinates of the ellipse's center.
|
|
706
|
+
# @param r [Array<Float>] The semi axes (major and minor) of the ellipse,
|
|
707
|
+
# in pixels. They can be non-integer, which will affect the intermediate
|
|
708
|
+
# calculations and result in a different, in-between, shape.
|
|
709
|
+
# @param stroke [Integer] Index of the color of the border. The border is
|
|
710
|
+
# drawn inside the ellipse, i.e., the supplied axes are not enlarged for
|
|
711
|
+
# the border. Leave `nil` for no border.
|
|
712
|
+
# @param fill [Integer] Index of the color for the filling of the ellipse.
|
|
713
|
+
# Leave `nil` for no filling.
|
|
714
|
+
# @param weight [Integer] Thickness of the border, in pixels.
|
|
715
|
+
# @param style [Symbol] Style of the border. If `:smooth`, the border will
|
|
716
|
+
# approximate an elliptical shape as much as possibe. If `:grid`, each
|
|
717
|
+
# additional unit of weight is added by simply drawing an additional layer
|
|
718
|
+
# of points inside the ellipse with the border's color.
|
|
719
|
+
# @return (see #initialize)
|
|
720
|
+
# @raise [Exception::CanvasError] If the ellipse would go out of bounds.
|
|
721
|
+
def ellipse(c, r, stroke = nil, fill = nil, weight: 1, style: :smooth)
|
|
722
|
+
# Parse data
|
|
723
|
+
return self if !stroke && !fill
|
|
724
|
+
a = r[0]
|
|
725
|
+
b = r[1]
|
|
726
|
+
c = Geometry::Point.parse(c).round
|
|
727
|
+
e1 = Geometry::E1
|
|
728
|
+
e2 = Geometry::E2
|
|
729
|
+
upper = (c - e2 * b).round
|
|
730
|
+
lower = (c + e2 * b).round
|
|
731
|
+
left = (c - e1 * a).round
|
|
732
|
+
right = (c + e1 * a).round
|
|
733
|
+
if !Geometry.bound_check([upper, lower, left, right], self, true)
|
|
734
|
+
raise Exception::CanvasError, "Ellipse out of bounds."
|
|
735
|
+
end
|
|
736
|
+
if stroke
|
|
737
|
+
weight = [weight.to_i, 1].max
|
|
738
|
+
if weight > [a, b].min
|
|
739
|
+
fill = stroke
|
|
740
|
+
stroke = nil
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
f = (a.to_f / b) ** 2
|
|
744
|
+
|
|
745
|
+
# Fill
|
|
746
|
+
if fill
|
|
747
|
+
b.round.downto(0).each{ |y|
|
|
748
|
+
midpoint1 = ((c.y - y) * @width + c.x).round
|
|
749
|
+
midpoint2 = ((c.y + y) * @width + c.x).round if y > 0
|
|
750
|
+
partial_r = (y > 0 ? (a ** 2 - f * (y - 0.5) ** 2) ** 0.5 : a).round
|
|
751
|
+
@pixels[midpoint1 - partial_r, 2 * partial_r + 1] = [fill] * (2 * partial_r + 1)
|
|
752
|
+
@pixels[midpoint2 - partial_r, 2 * partial_r + 1] = [fill] * (2 * partial_r + 1) if y > 0
|
|
753
|
+
}
|
|
754
|
+
end
|
|
478
755
|
|
|
479
|
-
|
|
480
|
-
if
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
756
|
+
# Stroke
|
|
757
|
+
if stroke
|
|
758
|
+
prev_r = 0
|
|
759
|
+
b.round.downto(0).each{ |y|
|
|
760
|
+
midpoint1 = ((c.y - y) * @width + c.x).round
|
|
761
|
+
midpoint2 = ((c.y + y) * @width + c.x).round if y > 0
|
|
762
|
+
partial_r = (y > 0 ? (a ** 2 - f * (y - 0.5) ** 2) ** 0.5 : a).round
|
|
763
|
+
if style == :grid
|
|
764
|
+
border = [weight + partial_r - prev_r, 1 + partial_r].min
|
|
765
|
+
(0 ... [weight, y + 1].min).each{ |w|
|
|
766
|
+
@pixels[midpoint1 - partial_r + w * @width, border] = [stroke] * border
|
|
767
|
+
@pixels[midpoint1 + partial_r - (border - 1) + w * @width, border] = [stroke] * border
|
|
768
|
+
@pixels[midpoint2 - partial_r - w * @width, border] = [stroke] * border if y > 0
|
|
769
|
+
@pixels[midpoint2 + partial_r - (border - 1) - w * @width, border] = [stroke] * border if y > 0
|
|
770
|
+
}
|
|
771
|
+
prev_r = partial_r
|
|
772
|
+
elsif style == :smooth
|
|
773
|
+
a2 = [a - weight, 0].max
|
|
774
|
+
b2 = [b - weight, 0].max
|
|
775
|
+
f2 = (a2.to_f / b2) ** 2
|
|
776
|
+
partial_r2 = (y > 0 ? (a2 ** 2 >= f2 * (y - 0.5) ** 2 ? (a2 ** 2 - f2 * (y - 0.5) ** 2) ** 0.5 : -1) : a2).round
|
|
777
|
+
border = partial_r - partial_r2
|
|
778
|
+
@pixels[midpoint1 - partial_r , border] = [stroke] * border
|
|
779
|
+
@pixels[midpoint1 + partial_r - (border - 1), border] = [stroke] * border
|
|
780
|
+
@pixels[midpoint2 - partial_r , border] = [stroke] * border if y > 0
|
|
781
|
+
@pixels[midpoint2 + partial_r - (border - 1), border] = [stroke] * border if y > 0
|
|
782
|
+
end
|
|
783
|
+
}
|
|
488
784
|
end
|
|
785
|
+
|
|
786
|
+
self
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
# Draw a circle with the given properties.
|
|
790
|
+
# @param c [Array<Integer>] The X and Y coordinates of the circle's center.
|
|
791
|
+
# @param r [Float] The radius of the circle, in pixels. It can be non-integer,
|
|
792
|
+
# which will affect the intermediate calculations and result in a different
|
|
793
|
+
# final shape, which is in-between the ones corresponding to the integer
|
|
794
|
+
# values below and above for the radius.
|
|
795
|
+
# @param stroke [Integer] Index of the color of the border. The border is
|
|
796
|
+
# drawn inside the circle, i.e., the supplied radius is not enlarged for
|
|
797
|
+
# the border. Leave `nil` for no border.
|
|
798
|
+
# @param fill [Integer] Index of the color for the filling of the circle.
|
|
799
|
+
# Leave `nil` for no filling.
|
|
800
|
+
# @param weight [Integer] Thickness of the border, in pixels.
|
|
801
|
+
# @param style [Symbol] Style of the border. If `:smooth`, the border will
|
|
802
|
+
# approximate a circular shape as much as possible. If `:grid`, each
|
|
803
|
+
# additional unit of weight is added by simply drawing an additional layer
|
|
804
|
+
# of points inside the circle with the border's color.
|
|
805
|
+
# @return (see #initialize)
|
|
806
|
+
# @raise [Exception::CanvasError] If the circle would go out of bounds.
|
|
807
|
+
def circle(c, r, stroke = nil, fill = nil, weight: 1, style: :smooth)
|
|
808
|
+
ellipse(c, [r, r], stroke, fill, weight: weight, style: style)
|
|
489
809
|
end
|
|
490
810
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
811
|
+
# Draw a polygonal chain connecting a sequence of points. This simply consists
|
|
812
|
+
# in joining them in order with straight lines.
|
|
813
|
+
# @param points [Array<Point>] The list of points, in order, to join.
|
|
814
|
+
# @param line_color [Integer] The index of the color to use for the lines.
|
|
815
|
+
# @param line_weight [Float] The size of the line stroke, in pixels.
|
|
816
|
+
# @param node_color [Integer] The index of the color to use for the nodes.
|
|
817
|
+
# Default (`nil`) is the same as the line color.
|
|
818
|
+
# @param node_weight [Float] The radius of the node circles, in pixels. If `0`,
|
|
819
|
+
# no nodes will be drawn.
|
|
820
|
+
# @return (see #initialize)
|
|
821
|
+
# @raise [Exception::CanvasError] If the chain would go out of bounds. The
|
|
822
|
+
# segments that are within bounds will be drawn, even if they come after
|
|
823
|
+
# an out of bounds segment.
|
|
824
|
+
def polygonal(points, line_color: 0, line_weight: 1, node_color: nil,
|
|
825
|
+
node_weight: 0
|
|
826
|
+
)
|
|
827
|
+
node_color = line_color unless node_color
|
|
828
|
+
0.upto(points.size - 2).each{ |i|
|
|
829
|
+
next if !points[i] || !points[i + 1]
|
|
830
|
+
line(p1: points[i], p2: points[i + 1], color: line_color, weight: line_weight) rescue nil
|
|
831
|
+
}
|
|
832
|
+
points.each{ |p|
|
|
833
|
+
next if !p
|
|
834
|
+
circle(p, node_weight, nil, node_color)
|
|
835
|
+
}
|
|
836
|
+
self
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Draw a 2D parameterized curve. A lambda function containing the mathematical
|
|
840
|
+
# expression for each coordinate must be passed.
|
|
841
|
+
# @param func [Lambda] A lambda function that takes in a single floating
|
|
842
|
+
# point parameter (the time) and outputs the pair of coordinates `[X, Y]`
|
|
843
|
+
# corresponding to the curve at that given instant.
|
|
844
|
+
# @param from [Float] The starting time to begin plotting the curve, i.e.,
|
|
845
|
+
# the initial value of the time parameter for the lambda.
|
|
846
|
+
# @param to [Float] The ending time to finish plotting the curve, i.e.,
|
|
847
|
+
# the final value of the time parameter for the lambda.
|
|
848
|
+
# @param step [Float] The time step to use. The points of the curve resulting
|
|
849
|
+
# from this time step will be joined via straight lines. The smaller the,
|
|
850
|
+
# time step, the smoother the curve will look, resolution permitting.
|
|
851
|
+
# Alternatively, one may supply the `dots` argument.
|
|
852
|
+
# @param dots [Integer] The amount of points to plot. The plotting interval
|
|
853
|
+
# will be divided into this many segments of equal size, and the resulting
|
|
854
|
+
# points will be joined via straight lines. The more dots, the smoother the
|
|
855
|
+
# curve will look. Alternatively, one may supply the `step` argument.
|
|
856
|
+
# @param line_color [Integer] The index of the color to use for the trace.
|
|
857
|
+
# @param line_weight [Float] The size of the brush to use for the trace.
|
|
858
|
+
# @param node_color [Integer] The index of the color to use for the node
|
|
859
|
+
# circles. If `nil` (default), the line color will be used.
|
|
860
|
+
# @param node_weight [Float] The radius of the node circles. If `0` (default),
|
|
861
|
+
# nodes joining each segment of the curve will not be drawn.
|
|
862
|
+
# @return (see #initialize)
|
|
863
|
+
# @raise [Exception::CanvasError] If the curve goes out of bounds.
|
|
864
|
+
# @todo Add a way to automatically compute the time step with a reasonable
|
|
865
|
+
# value, without having to explicitly send the step or the dots.
|
|
866
|
+
def curve(func, from, to, step: nil, dots: nil, line_color: 0, line_weight: 1,
|
|
867
|
+
node_color: nil, node_weight: 0)
|
|
868
|
+
if !step && !dots
|
|
869
|
+
raise Exception::GeometryError, "Cannot infer the curve's drawing density,|
|
|
870
|
+
please specify either the step or the dots argument."
|
|
871
|
+
end
|
|
872
|
+
step = (to - from).abs.to_f / (dots + 1) if !step
|
|
873
|
+
points = (from .. to).step(step).map{ |t| func.call(t) }
|
|
874
|
+
node_color = line_color unless node_color
|
|
875
|
+
polygonal(points, line_color: line_color, line_weight: line_weight,
|
|
876
|
+
node_color: node_color, node_weight: node_weight)
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
# Draw a general spiral given by its scale functions in either direction.
|
|
880
|
+
# These functions specify, in terms of the time, how the spiral grows
|
|
881
|
+
# horizontally and vertically. For instance, a linear function would yield
|
|
882
|
+
# a spiral of constant growth, i.e., an Archimedean spiral.
|
|
883
|
+
# @param from [Float] The starting time to begin plotting the curve.
|
|
884
|
+
# @param to [Float] The final time to end the plot.
|
|
885
|
+
# @param center [Array<Integer>] The coordinates of the center of the spiral.
|
|
886
|
+
# @param angle [Float] Initial angle of the spiral.
|
|
887
|
+
# @param scale_x [Lambda(Float)] The function that specifies the spiral's
|
|
888
|
+
# growth in the X direction in terms of time.
|
|
889
|
+
# @param scale_y [Lambda(Float)] The function that specifies the spiral's
|
|
890
|
+
# growth in the Y direction in terms of time.
|
|
891
|
+
# @param speed [Float] Speed at which the spiral is traversed.
|
|
892
|
+
# @param color [Integer] Index of the line's color.
|
|
893
|
+
# @param weight [Float] Size of the line.
|
|
894
|
+
# @param control_points [Integer] The amount of control points to use per
|
|
895
|
+
# quadrant when drawing the spiral. The higher, the smoother the curve.
|
|
896
|
+
# @return (see #initialize)
|
|
897
|
+
# @raise [Exception::CanvasError] If the spiral would go out of bounds.
|
|
898
|
+
def spiral_general(
|
|
899
|
+
from, to,
|
|
900
|
+
center: [@width / 2, @height / 2],
|
|
901
|
+
angle: 0,
|
|
902
|
+
scale_x: -> (t) { t },
|
|
903
|
+
scale_y: -> (t) { t },
|
|
904
|
+
speed: 1,
|
|
905
|
+
color: 0,
|
|
906
|
+
weight: 1,
|
|
907
|
+
control_points: 64
|
|
908
|
+
)
|
|
909
|
+
center = Geometry::Point.parse(center)
|
|
910
|
+
curve(
|
|
911
|
+
-> (t) {
|
|
912
|
+
[
|
|
913
|
+
center.x + scale_x.call(t) * Math.cos(angle + speed * t),
|
|
914
|
+
center.y + scale_y.call(t) * Math.sin(angle + speed * t)
|
|
915
|
+
]
|
|
916
|
+
},
|
|
917
|
+
from, to, step: 2 * Math::PI / control_points,
|
|
918
|
+
line_color: color, line_weight: weight
|
|
919
|
+
)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# Draw an Archimedean spiral. This type of spiral is the simplest case,
|
|
923
|
+
# which grows at a constant rate on either direction.
|
|
924
|
+
# @param center [Array<Integer>] The coordinates of the center of the spiral.
|
|
925
|
+
# @param step [Float] Distance between spiral's loops.
|
|
926
|
+
# @param loops [Float] How many loops to draw.
|
|
927
|
+
# @param angle [Float] Initial spiral angle.
|
|
928
|
+
# @param color [Integer] Index of the line's color.
|
|
929
|
+
# @param weight [Float] Size of the line.
|
|
930
|
+
# @return (see #initialize)
|
|
931
|
+
# @raise (see #spiral_general)
|
|
932
|
+
def spiral(center, step, loops, angle: 0, color: 0, weight: 1)
|
|
933
|
+
spiral_general(
|
|
934
|
+
0, loops * 2 * Math::PI, center: center, angle: angle,
|
|
935
|
+
scale_x: -> (t) {step * t / (2 * Math::PI) },
|
|
936
|
+
scale_y: -> (t) {step * t / (2 * Math::PI) },
|
|
937
|
+
color: color, weight: weight
|
|
938
|
+
)
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# Plot the graph of a function. In the following, we will distinguish between
|
|
942
|
+
# "function" coordinates (i.e., the values the function actually takes),
|
|
943
|
+
# and "pixel" coordinates (i.e., the actual coordinates of the pixels that
|
|
944
|
+
# will be drawn).
|
|
945
|
+
# @param func [Lambda] The function to plot. Must take a single Float parameter,
|
|
946
|
+
# which represents the function's variable, and return a single Float value,
|
|
947
|
+
# the function's value at that point. Both in function coordinates.
|
|
948
|
+
# @param x_from [Float] The start of the X range to plot, in function coordinates.
|
|
949
|
+
# @param x_to [Float] The end of the X range to plot, in function coordinates.
|
|
950
|
+
# @param y_from [Float] The start of the Y range to plot, in function coordinates.
|
|
951
|
+
# @param y_to [Float] The end of the Y range to plot, in function coordinates.
|
|
952
|
+
# @param pos [Point] The position of the graph. These are the pixel coordinates
|
|
953
|
+
# of the upper left corner of the graph. See also `:origin`.
|
|
954
|
+
# @param center [Point] The position of the origin. These are the pixel coordinates
|
|
955
|
+
# of the origin of the graph, _even if_ the origin isn't actually in the
|
|
956
|
+
# plotting range (it'll still be used internally to calculate all the
|
|
957
|
+
# other pixel coords relative to this). Takes preference over `:pos`.
|
|
958
|
+
# @param color [Integer] The index of the color to use for the function's graph.
|
|
959
|
+
# @param weight [Integer] The width of the graph's line, in pixels.
|
|
960
|
+
# @param grid [Boolean] Whether to draw a grid or not.
|
|
961
|
+
# @param grid_color [Integer] Index of the color to be used for the grid lines.
|
|
962
|
+
# @param grid_weight [Integer] The width of the grid lines in pixels.
|
|
963
|
+
# @param grid_sep_x [Float] The separation between the vertical grid lines,
|
|
964
|
+
# in function coordinates. Takes preference over `:grid_steps_x`.
|
|
965
|
+
# @param grid_sep_y [Float] The separation between the horizontal grid lines,
|
|
966
|
+
# in function coordinates. Takes preference over `:grid_steps_y`.
|
|
967
|
+
# @param grid_steps_x [Float] How many grid intervals to use for the X range.
|
|
968
|
+
# Only used if no explicit separation has been supplied with `:grid_sep_x`.
|
|
969
|
+
# @param grid_steps_y [Float] How many grid intervals to use for the Y range.
|
|
970
|
+
# Only used if no explicit separation has been supplied with `:grid_sep_y`.
|
|
971
|
+
# @param grid_style [Symbol] Style of the grid lines (`:solid`, `:dashed`,
|
|
972
|
+
# `:dotted`). See `style` option in {#line}.
|
|
973
|
+
# @param grid_density [Symbol] Density of the grid pattern (`:normal`, `:dense`,
|
|
974
|
+
# `:loose`). See `density` option in {#line}.
|
|
975
|
+
# @param axes [Boolean] Whether to draw to axes or not.
|
|
976
|
+
# @param axes_color [Integer] Index of the color to use for the axes.
|
|
977
|
+
# @param axes_weight [Integer] Width of the axes line in pixels.
|
|
978
|
+
# @param axes_style [Symbol] Style of the axes lines (`:solid`, `:dashed`,
|
|
979
|
+
# `:dotted`). See `style` option in {#line}.
|
|
980
|
+
# @param axes_density [Symbol] Density of the axes pattern (`:normal`, `:dense`,
|
|
981
|
+
# `:loose`). See `density` option in {#line}.
|
|
982
|
+
# @param origin [Boolean] Whether to draw the origin or not.
|
|
983
|
+
# @param origin_color [Integer] The index of the color to use for the origin dot.
|
|
984
|
+
# @param origin_weight [Float] The radius of the circle to use for the origin,
|
|
985
|
+
# in pixels.
|
|
986
|
+
# @param background [Boolean] Whether to draw a solid rectangle as a background
|
|
987
|
+
# for the whole plot.
|
|
988
|
+
# @param background_color [Integer] Index of the color to use for the
|
|
989
|
+
# background rectangle.
|
|
990
|
+
# @param background_padding [Integer] Amount of extra padding pixels between
|
|
991
|
+
# the rectangle's boundary and the elements it surrounds.
|
|
992
|
+
# @param frame [Boolean] Whether to draw a frame around the plot. If specified,
|
|
993
|
+
# it will be the border of the background's rectangle.
|
|
994
|
+
# @param frame_color [Integer] Index of the color to use for the frame.
|
|
995
|
+
# @param frame_weight [Integer] Width of the frame lines in pixels.
|
|
996
|
+
# @param frame_style [Symbol] Style of the frame lines (`:solid`, `:dashed`,
|
|
997
|
+
# `:dotted`). See `style` option in {#line}.
|
|
998
|
+
# @param frame_density [Symbol] Density of the frame pattern (`:normal`, `:dense`,
|
|
999
|
+
# `:loose`). See `density` option in {#line}.
|
|
1000
|
+
# @return (see #initialize)
|
|
1001
|
+
# @raise [Exception::CanvasError] If any of the plot's elements would go out
|
|
1002
|
+
# of bounds.
|
|
1003
|
+
# @todo Desirable features:
|
|
1004
|
+
# * Calculate the Y range automatically based on the function and the X
|
|
1005
|
+
# range, unless an explicit Y range is supplied.
|
|
1006
|
+
# * Change plot's orientation (horizontal, or even arbitrary angle).
|
|
1007
|
+
# * Add other elements, such as text labels, or axes tips (e.g. arrows).
|
|
1008
|
+
# * Specify drawing precision (in dots or steps), or even calculate it
|
|
1009
|
+
# based on the function and the supplied range.
|
|
1010
|
+
# * Allow to have arbitrary line styles for the graph line too.
|
|
1011
|
+
def graph(func, x_from, x_to, y_from, y_to, pos: [0, 0], center: nil, x_scale: 1,
|
|
1012
|
+
y_scale: 1, color: 0, weight: 1, grid: false, grid_color: 0,
|
|
1013
|
+
grid_weight: 1, grid_sep_x: nil, grid_sep_y: nil, grid_steps_x: nil,
|
|
1014
|
+
grid_steps_y: nil, grid_style: :dotted, grid_density: :normal, axes: true,
|
|
1015
|
+
axes_color: 0, axes_weight: 1, axes_style: :solid, axes_density: :normal,
|
|
1016
|
+
origin: false, origin_color: 0, origin_weight: 2, background: false,
|
|
1017
|
+
background_color: 0, background_padding: 1, frame: false, frame_color: 0,
|
|
1018
|
+
frame_weight: 1, frame_style: :solid, frame_density: :normal
|
|
1019
|
+
)
|
|
1020
|
+
# Normalize parameters
|
|
1021
|
+
center, origin = origin, center
|
|
1022
|
+
if !origin
|
|
1023
|
+
pos = Geometry::Point.parse(pos || [0, 0])
|
|
1024
|
+
origin = [pos.x - x_scale * x_from, pos.y + y_scale * y_to]
|
|
1025
|
+
end
|
|
1026
|
+
origin = Geometry::Point.parse(origin)
|
|
1027
|
+
|
|
1028
|
+
# Background
|
|
1029
|
+
if background
|
|
1030
|
+
rect(
|
|
1031
|
+
# Calculate real position (upper left corner)
|
|
1032
|
+
origin.x + x_scale * x_from - background_padding,
|
|
1033
|
+
origin.y - y_scale * y_to - background_padding,
|
|
1034
|
+
|
|
1035
|
+
# Calculate real dimensions
|
|
1036
|
+
(x_to - x_from).abs * x_scale + 2 * background_padding + 1,
|
|
1037
|
+
(y_to - y_from).abs * y_scale + 2 * background_padding + 1,
|
|
1038
|
+
|
|
1039
|
+
# Stroke and fill color
|
|
1040
|
+
nil, background_color
|
|
1041
|
+
)
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# Grid
|
|
1045
|
+
if grid
|
|
1046
|
+
grid_sep_x = grid_steps_x ? (x_to - x_from).abs.to_f / grid_steps_x : 10.0 / x_scale if !grid_sep_x
|
|
1047
|
+
grid_sep_y = grid_steps_y ? (y_to - y_from).abs.to_f / grid_steps_y : 10.0 / y_scale if !grid_sep_y
|
|
1048
|
+
grid(
|
|
1049
|
+
# Calculate real position (upper left corner)
|
|
1050
|
+
origin.x + x_scale * x_from,
|
|
1051
|
+
origin.y - y_scale * y_to,
|
|
1052
|
+
|
|
1053
|
+
# Calculate real dimensions
|
|
1054
|
+
(x_to - x_from).abs * x_scale,
|
|
1055
|
+
(y_to - y_from).abs * y_scale,
|
|
1056
|
+
|
|
1057
|
+
# Calculate real separation between grid lines
|
|
1058
|
+
x_scale * grid_sep_x,
|
|
1059
|
+
y_scale * grid_sep_y,
|
|
1060
|
+
|
|
1061
|
+
# Offset the grid lines so that they're centered at the origin
|
|
1062
|
+
(x_from.abs.to_f % grid_sep_x) * x_scale,
|
|
1063
|
+
(y_to.abs.to_f % grid_sep_y) * y_scale,
|
|
1064
|
+
|
|
1065
|
+
# Grid aspect
|
|
1066
|
+
color: grid_color,
|
|
1067
|
+
weight: grid_weight,
|
|
1068
|
+
style: grid_style,
|
|
1069
|
+
density: grid_density
|
|
1070
|
+
)
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Axes
|
|
1074
|
+
if axes
|
|
1075
|
+
# X axis
|
|
1076
|
+
line(
|
|
1077
|
+
p1: [origin.x + x_scale * x_from, origin.y],
|
|
1078
|
+
p2: [origin.x + x_scale * x_to, origin.y],
|
|
1079
|
+
color: axes_color,
|
|
1080
|
+
weight: axes_weight,
|
|
1081
|
+
style: axes_style,
|
|
1082
|
+
density: axes_density
|
|
1083
|
+
) if 0.between?(y_from, y_to)
|
|
1084
|
+
|
|
1085
|
+
# Y axis
|
|
1086
|
+
line(
|
|
1087
|
+
p1: [origin.x, origin.y - y_scale * y_from],
|
|
1088
|
+
p2: [origin.x, origin.y - y_scale * y_to],
|
|
1089
|
+
color: axes_color,
|
|
1090
|
+
weight: axes_weight,
|
|
1091
|
+
style: axes_style,
|
|
1092
|
+
density: axes_density
|
|
1093
|
+
) if 0.between?(x_from, x_to)
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
# Origin
|
|
1097
|
+
if center
|
|
1098
|
+
circle(origin, origin_weight, nil, origin_color)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1101
|
+
# Graph
|
|
1102
|
+
curve(
|
|
1103
|
+
-> (t) {
|
|
1104
|
+
x = origin.x + x_scale * t
|
|
1105
|
+
y = origin.y - y_scale * func.call(t)
|
|
1106
|
+
func.call(t).between?(y_from, y_to) ? [x, y] : nil
|
|
1107
|
+
},
|
|
1108
|
+
x_from, x_to, dots: 100, line_color: color, line_weight: weight
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
# Frame
|
|
1112
|
+
if frame
|
|
1113
|
+
rect(
|
|
1114
|
+
# Calculate real position (upper left corner)
|
|
1115
|
+
origin.x + x_scale * x_from - background_padding,
|
|
1116
|
+
origin.y - y_scale * y_to - background_padding,
|
|
1117
|
+
|
|
1118
|
+
# Calculate real dimensions
|
|
1119
|
+
(x_to - x_from).abs * x_scale + 2 * background_padding + 1,
|
|
1120
|
+
(y_to - y_from).abs * y_scale + 2 * background_padding + 1,
|
|
1121
|
+
|
|
1122
|
+
# Stroke and fill color
|
|
1123
|
+
frame_color, nil,
|
|
1124
|
+
|
|
1125
|
+
# Aspect
|
|
1126
|
+
weight: frame_weight,
|
|
1127
|
+
style: frame_style,
|
|
1128
|
+
density: frame_density
|
|
1129
|
+
)
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
self
|
|
1133
|
+
end
|
|
1134
|
+
|
|
1135
|
+
# Represents a type of drawing brush, and encapsulates all the logic necessary
|
|
1136
|
+
# to use it, such as the weight, shape, anchor point, color, etc.
|
|
1137
|
+
class Brush
|
|
1138
|
+
|
|
1139
|
+
# Actual pixels that form the brush, and that will be drawn when using it.
|
|
1140
|
+
# It is an array of pairs of coordinates, representing the X and Y offsets
|
|
1141
|
+
# from the drawing point that will be painted. For example, if
|
|
1142
|
+
# `pixels = [[0, -1], [-1, 0], [0, 0], [1, 0], [0, 1]]`
|
|
1143
|
+
# then the brush will be a small cross centered at the drawing point.
|
|
1144
|
+
# @return [Array<Array<Integer>>] Coordinates of the brush relative to the
|
|
1145
|
+
# drawing point.
|
|
1146
|
+
attr_accessor :pixels
|
|
1147
|
+
|
|
1148
|
+
# The index in the color table of the default color to use when painting
|
|
1149
|
+
# with this brush. It can be overidden whenever it's actually used.
|
|
1150
|
+
# @return [Integer] Default color index.
|
|
1151
|
+
attr_accessor :color
|
|
1152
|
+
|
|
1153
|
+
# Creates a square brush of a given size.
|
|
1154
|
+
# @param weight [Float] Size of the brush (side of the square) in pixels.
|
|
1155
|
+
# @param color [Integer] Index of the color to use as default for drawing
|
|
1156
|
+
# when no explicit color is provided.
|
|
1157
|
+
# @param anchor [Array<Float>] The anchor determines the position of the
|
|
1158
|
+
# brush with respect to the drawing coordinates. It goes from [-1, -1]
|
|
1159
|
+
# (up and left) to [1, 1] (right and down). [0, 0] would mean the brush
|
|
1160
|
+
# is centered.
|
|
1161
|
+
# @return [Brush] The new square brush.
|
|
1162
|
+
def self.square(weight = 1, color = nil, anchor = [0, 0])
|
|
1163
|
+
weight = weight.to_f
|
|
1164
|
+
weight = 1.0 if weight < 1.0
|
|
1165
|
+
shift_x = ((1 - anchor[0]) * (weight - 1) / 2).round
|
|
1166
|
+
shift_y = ((1 - anchor[1]) * (weight - 1) / 2).round
|
|
1167
|
+
weight = weight.round
|
|
1168
|
+
|
|
1169
|
+
xlim_inf = -shift_x
|
|
1170
|
+
xlim_sup = xlim_inf + weight
|
|
1171
|
+
ylim_inf = -shift_y
|
|
1172
|
+
ylim_sup = ylim_inf + weight
|
|
1173
|
+
|
|
1174
|
+
new(
|
|
1175
|
+
(xlim_inf ... xlim_sup).to_a.product((ylim_inf ... ylim_sup).to_a),
|
|
1176
|
+
color
|
|
1177
|
+
)
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# Create a new brush by providing the raw pixels that form it. For common
|
|
1181
|
+
# shapes, you may instead prefer to use one of the helpers, such as
|
|
1182
|
+
# {.square}.
|
|
1183
|
+
# @param pixels [Array<Array<Integer>>] Relative coordinates of the pixels
|
|
1184
|
+
# that compose the brush (see {#pixels}).
|
|
1185
|
+
# @param color [Integer] Index of default brush color.
|
|
1186
|
+
# @return [Brush] The new brush.
|
|
1187
|
+
def initialize(pixels, color = nil)
|
|
1188
|
+
@pixels = pixels
|
|
1189
|
+
@color = color
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
# Use the brush to draw once on an image at the specified point. If no
|
|
1193
|
+
# color is specified, the brush's default color will be used. A bounding
|
|
1194
|
+
# box can be provided to restrict where in the image the drawing may
|
|
1195
|
+
# happen. If it's not specified, the whole image will determine this box.
|
|
1196
|
+
# @param x [Integer] X coordinate of the drawing point.
|
|
1197
|
+
# @param y [Integer] Y coordinate of the drawing point.
|
|
1198
|
+
# @param img [Image] Image to draw onto.
|
|
1199
|
+
# @param color [Integer] Index of the color in the color table to use.
|
|
1200
|
+
# @param bbox [Array<Integer>] Bounding box determining the drawing region,
|
|
1201
|
+
# in the format `[X, Y, W, H]`.
|
|
1202
|
+
# @param avoid [Array<Integer>] List of colors over which the brush should
|
|
1203
|
+
# NOT paint.
|
|
1204
|
+
def draw(x, y, img, color = @color, bbox: nil, avoid: [])
|
|
1205
|
+
raise Exception::CanvasError, "No provided color nor default color found." if !color
|
|
1206
|
+
bbox = [0, 0, img.width, img.height] if !bbox
|
|
1207
|
+
@pixels.each{ |dx, dy|
|
|
1208
|
+
if Geometry.bound_check([[x + dx, y + dy]], bbox, true) && !avoid.include?(img[x + dx, y + dy])
|
|
1209
|
+
img[x + dx, y + dy] = color
|
|
1210
|
+
end
|
|
1211
|
+
}
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
# Ensure the given point is within the image's bounds.
|
|
1216
|
+
# @param point [Point] The point to check. Can be provided as a tuple of
|
|
1217
|
+
# coordinates `[X, Y]`, or as a {Geometry::Point} object.
|
|
1218
|
+
# @param silent [Boolean] Whether to raise an exception or simply return
|
|
1219
|
+
# false if the bound check fails.
|
|
1220
|
+
def bound_check(point, silent = true)
|
|
1221
|
+
Geometry.bound_check([point], self, silent)
|
|
1222
|
+
end
|
|
1223
|
+
|
|
1224
|
+
private
|
|
1225
|
+
|
|
1226
|
+
# Given a pixel:
|
|
1227
|
+
# * Find the row span which shares that pixel's color.
|
|
1228
|
+
# * Fill it with a new (different) color.
|
|
1229
|
+
# * Repeat recursively for the previous and next row.
|
|
1230
|
+
# Used for flood fill algorithm.
|
|
1231
|
+
def fill_span(x, y, old_color, new_color)
|
|
1232
|
+
# Nothing to fill from this seed
|
|
1233
|
+
return if self[x, y] != old_color
|
|
1234
|
+
self[x, y] = new_color
|
|
1235
|
+
|
|
1236
|
+
# Find negative span and fill it
|
|
1237
|
+
x_min = x - 1
|
|
1238
|
+
while x_min >= 0 && self[x_min, y] == old_color
|
|
1239
|
+
self[x_min, y] = new_color
|
|
1240
|
+
x_min -= 1
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
# Find positive span and fill it
|
|
1244
|
+
x_max = x + 1
|
|
1245
|
+
while x_max < @width && self[x_max, y] == old_color
|
|
1246
|
+
self[x_max, y] = new_color
|
|
1247
|
+
x_max += 1
|
|
1248
|
+
end
|
|
1249
|
+
|
|
1250
|
+
# Recursively test previous and next rows
|
|
1251
|
+
span = (x_min + 1 .. x_max - 1)
|
|
1252
|
+
span.each{ |x| fill_span(x, y - 1, old_color, new_color) } if y > 0
|
|
1253
|
+
span.each{ |x| fill_span(x, y + 1, old_color, new_color) } if y < @height - 1
|
|
1254
|
+
end
|
|
1255
|
+
|
|
1256
|
+
# Parse the line ON/OFF pattern given a few named values.
|
|
1257
|
+
# Style can be solid, dotted or dashed. Density can be normal, dense or loose.
|
|
1258
|
+
def parse_line_pattern(style = :solid, density = :normal, weight = 1)
|
|
1259
|
+
# Normalize params
|
|
1260
|
+
style = :solid if ![:solid, :dotted, :dashed].include?(style)
|
|
1261
|
+
density = :normal if ![:loose, :normal, :dense].include?(density)
|
|
1262
|
+
|
|
1263
|
+
# Length of ON segment depends on style
|
|
1264
|
+
on = style == :dashed ? 4 * weight + 1 : 1
|
|
1265
|
+
|
|
1266
|
+
# Length of OFF segment depends on density
|
|
1267
|
+
off = weight
|
|
1268
|
+
off += on + weight - 1 if [:normal, :loose].include?(density)
|
|
1269
|
+
off += on + weight - 1 if density == :loose
|
|
1270
|
+
off = 0 if style == :solid
|
|
1271
|
+
|
|
1272
|
+
[on, off]
|
|
509
1273
|
end
|
|
510
1274
|
|
|
511
1275
|
end
|