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.
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 = width if width
113
- @height = height if height
114
- @x = x if x
115
- @y = y if y
116
- @lct = lct
117
- @interlace = 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
- min_bits = 8 #@lct ? @lct.bit_size : 8
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 tip [Boolean] Whether to include the line's final pixel. This
372
- # is useful for when multiple lines are joined to form a polygonal line.
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
- direction: nil, length: nil, color: 0, weight: 1, tip: true)
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
- x0, y0 = p1
550
+ p1 = Geometry::Point.parse(p1)
381
551
  if p2
382
- x1, y1 = p2
552
+ p2 = Geometry::Point.parse(p2)
383
553
  else
384
- x1, y1 = Geometry.endpoint(
554
+ p2 = Geometry.endpoint(
385
555
  point: p1, vector: vector, direction: direction,
386
556
  angle: angle, length: length
387
557
  )
388
558
  end
389
- bound_check(x0, y0)
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
- # Rotate weight
413
- e_acc = 0
414
- e = ((dy << 16) / dx.to_f).round
415
- max = (dy <= 1 ? dx - 1 : dx + (weight / 2.0).to_i - 2) + (tip ? 1 : 0)
416
- weight *= (1 + (dy.to_f / dx) ** 2) ** 0.5
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
- # Check coordinates
443
- x0, y0, x1, y1 = x, y, x + w - 1, y + h - 1
444
- bound_check(x0, y0)
445
- bound_check(x1, y1)
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
- for x in (x0 .. x1)
450
- for y in (y0 .. y1)
620
+ (x0 .. x1).each{ |x|
621
+ (y0 .. y1).each{ |y|
451
622
  @pixels[y * @width + x] = fill
452
- end
453
- end
623
+ }
624
+ }
454
625
  end
455
626
 
456
627
  # Rectangle border
457
- line(p1: [x0, y0], p2: [x1, y0], color: stroke, weight: weight)
458
- line(p1: [x0, y1], p2: [x1, y1], color: stroke, weight: weight)
459
- line(p1: [x0, y0], p2: [x0, y1], color: stroke, weight: weight)
460
- line(p1: [x1, y0], p2: [x1, y1], color: stroke, weight: weight)
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
- private
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
- # Ensure the provided point is within the image's bounds.
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 one line chunk, used for Xiaolin-Wu line algorithm
473
- def line_chunk(x, y, color, weight = 1, swap = false)
474
- weight = 1.0 if weight < 1.0
475
- weight = weight.to_i
476
- min = - weight / 2 + 1
477
- max = weight / 2
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
- w = min
480
- if !swap
481
- self[x, y + w] = color
482
- self[x, y + w] = color while (w += 1) < max
483
- self[x, y + w] = color if w <= max
484
- else
485
- self[y + w, x] = color
486
- self[y + w, x] = color while (w += 1) < max
487
- self[y + w, x] = color if w <= max
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
- def brush_chunk(x, y, color, weight = 1, swap = false)
492
- weight = 1.0 if weight < 1.0
493
- weight = weight.to_i
494
- x, y = y, x if swap
495
- cross_brush = [
496
- [ 0, -1],
497
- [-1, 0],
498
- [ 0, 0],
499
- [ 1, 0],
500
- [ 0, 1]
501
- ]
502
- two_by_two_brush = [
503
- [0, 0],
504
- [1, 0],
505
- [0, 1],
506
- [1, 1]
507
- ]
508
- two_by_two_brush.each{ |dx, dy| self[x + dx, y + dy] = color }
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