gifenc 0.1.0 → 0.2.0

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