➡️

Nannou の Range を Ruby に移植する

2023/11/03に公開

Nannou の Range を Ruby でも使えるようにしたもの。

コード
range2.rb
class Range2
  class << self
    def [](...)
      new(...)
    end

    def one  = 1.0
    def zero = 0.0

    def from_pos_and_len(pos, len)
      half_len = len / 2.0
      start = pos - half_len
      _end = pos + half_len
      new(start, _end)
    end

    def map_range(val, in_min, in_max, out_min, out_max)
      (val.to_f - in_min) / (in_max - in_min) * (out_max - out_min) + out_min
    end

    def clamp(value, start, _end)
      if start <= _end
        if value < start
          start
        elsif value > _end
          _end
        else
          value
        end
      else
        if value < _end
          _end
        elsif value > start
          start
        else
          value
        end
      end
    end
  end

  attr_accessor :start
  attr_accessor :end

  def initialize(start, _end)
    @start = start
    @end = _end
  end

  def magnitude
    @end - @start
  end

  def length
    mag = magnitude
    if mag < self.class.zero
      -mag
    else
      mag
    end
  end

  def middle
    (@end + @start).fdiv(2)
  end

  def invert
    self.class.new(@end, @start)
  end

  def map_value(value, other)
    self.class.map_range(value, @start, @end, other.start, other.end)
  end

  def lerp(amount)
    @start + (@end - @start) * amount
  end

  def shift(amount)
    self.class.new(@start + amount, @end + amount)
  end

  def direction
    if @start < @end
      self.class.one
    elsif @start > @end
      -self.class.one
    else
      -self.class.zero
    end
  end

  def absolute
    if @start > @end
      invert
    else
      self
    end
  end

  def max(other)
    start = [@start, @end, other.start, other.end].min
    _end = [@start, @end, other.start, other.end].max
    self.class.new(start, _end)
  end

  def overlap(other)
    a = absolute
    other = other.absolute
    start = [a.start, other.start].max
    _end = [a.end, other.end].min
    magnitude = _end - start
    if magnitude >= self.class.zero
      self.class.new(start, _end)
    end
  end

  def max_directed(other)
    if @start <= @end
      max(other)
    else
      max(other).invert
    end
  end

  def contains?(pos)
    a = absolute
    a.start <= pos && pos <= a.end
  end

  def round(...)    = self.class.new(@start.round(...), @end.round(...))
  def floor(...)    = self.class.new(@start.floor(...), @end.floor(...))
  def ceil(...)     = self.class.new(@start.ceil(...), @end.ceil(...))
  def truncate(...) = self.class.new(@start.truncate(...), @end.truncate(...))

  def pad_start(pad)        = self.class.new(@start + (@start <= @end ? pad : -pad), @end)
  def pad_end(pad)          = self.class.new(@start, @end + (@start <= @end ? -pad : pad))
  def pad(pad)              = pad_start(pad).pad_end(pad)
  def pad_ends(start, _end) = pad_start(start).pad_end(_end)

  def clamp_value(value)
    self.class.clamp(value, @start, @end)
  end

  def stretch_to_value(value)
    if @start <= @end
      if value < @start
        self.class.new(value, @end)
      elsif value > @end
        self.class.new(@start, value)
      else
        self
      end
    else
      if value < @end
        self.class.new(@start, value)
      elsif value > @start
        self.class.new(value, @end)
      else
        self
      end
    end
  end

  def same_direction?(other)
    self_direction = @start <= @end
    other_direction = other.start <= other.end
    self_direction == other_direction
  end

  def align_start_of(other)
    if same_direction?(other)
      diff = other.start - @start
    else
      diff = other.start - @end
    end
    shift(diff)
  end

  def align_end_of(other)
    if same_direction?(other)
      diff = other.end - @end
    else
      diff = other.end - @start
    end
    shift(diff)
  end

  def align_middle_of(other)
    diff = other.middle - middle
    shift(diff)
  end

  def align_after(other)
    if same_direction?(other)
      diff = other.end - @start
    else
      diff = other.end - @end
    end
    shift(diff)
  end

  def align_before(other)
    if self.same_direction?(other)
      diff = other.start - @end
    else
      diff = other.start - @start
    end
    shift(diff)
  end

  def align_to(align, other)
    public_send("align_#{align}_of", other)
  end

  def closest_edge(scalar)
    if scalar < @start
      start_diff = @start - scalar
    else
      start_diff = scalar - @start
    end
    if scalar < @end
      end_diff = @end - scalar
    else
      end_diff = scalar - @end
    end
    if start_diff <= end_diff
      :start
    else
      :end
    end
  end

  ################################################################################

  def to_a
    [@start, @end]
  end

  def ==(other)
    self.class == other.class && @start == other.start && @end == other.end
  end

  def eql?(other)
    self.class == other.class && @start == other.start && @end == other.end
  end

  def hash
    self.class.hash ^ @start.hash ^ @end.hash
  end

  def <=>(other)
    [self.class, @start, @end] <=> [other.class, other.start, other.end]
  end

  def inspect
    "(#{@start} -> #{@end})"
  end

  def to_s
    "(#{@start} -> #{@end})"
  end
end

if $0 == __FILE__
  require "rspec/autorun"

  RSpec.configure do |config|
    config.expect_with :test_unit
  end

  describe Range2 do
    it "new" do
      assert { Range2.new(1, 2) == Range2.new(1, 2) }
    end

    it "pos を中心に半径 len / 2 の幅とする (重要)" do
      assert { Range2::from_pos_and_len(100.0, 10.0) == Range2.new(95.0, 105.0) }
    end

    it "それぞれの値" do
      assert { Range2.new(100.0, 200.0).start == 100.0 }
      assert { Range2.new(100.0, 200.0).middle == 150.0 }
      assert { Range2.new(100.0, 200.0).end == 200.0 }
    end

    describe "ベクトルの強さと長さ" do
      it "強さ (end - start)" do
        assert { Range2.new(100, -200).magnitude == -300 }
      end

      it "長さ (強さの絶対値)" do
        assert { Range2.new(100, -200).length == 300 }
      end
    end

    it "小数の補正" do
      assert { Range2.new(0.4, 0.5).round == Range2.new(0, 1) }
      assert { Range2.new(0.4, 0.5).floor == Range2.new(0, 0) }
      assert { Range2.new(0.4, 0.5).ceil == Range2.new(1, 1) }
      assert { Range2.new(0.4, 0.5).truncate == Range2.new(0, 0) }
    end

    describe "範囲" do
      it "OR (向きを破壊する)" do
        a = Range2.new(5.0, 3.0)
        b = Range2.new(4.0, 6.0)
        assert { a.max(b) == Range2.new(3.0, 6.0) }
      end

      it "OR (向きを維持する)" do
        a = Range2.new(5.0, 3.0)
        b = Range2.new(4.0, 6.0)
        assert { a.max_directed(b) == Range2.new(6.0, 3.0) }
      end

      it "AND (向きを破壊する)" do
        a = Range2.new(5.0, 3.0)
        b = Range2.new(4.0, 6.0)
        assert { a.overlap(b) == Range2.new(4.0, 5.0) }
      end
    end

    describe "向き" do
      it "現在の向きを返す" do
        assert { Range2.new(0, 10).direction == 1.0 }
        assert { Range2.new(10, 0).direction == -1.0 }
        assert { Range2.new(10, 10).direction == -0.0 }
      end

      it "向きが同じか?" do
        a = Range2.new(1, 2)
        b = Range2.new(3, 4)
        assert { a.same_direction?(b) == true }
      end

      it "向きを反転する" do
        assert { Range2.new(0, 100).invert == Range2.new(100, 0) }
      end

      it "正の向きにする" do
        assert { Range2.new(10, 0).absolute == Range2.new(0, 10) }
      end
    end

    describe "スケーリング" do
      it "map_value" do
        a = Range2.new(0.0, 1.0)
        b = Range2.new(0.0, 100.0)
        assert { a.map_value(0.9, b) == 90.0 }
      end

      it "元の範囲が 0..1 の場合 lerp 使うと簡潔に書ける" do
        b = Range2.new(0.0, 100.0)
        assert { b.lerp(0.9) == 90.0 }
      end
    end

    describe "指定の軸で整列する" do
      it "相手の左端に揃える" do
        a = Range2.new(0, 100)
        b = Range2.new(50, 100)
        assert { a.align_start_of(b) == Range2.new(50, 150) }
      end

      it "相手の右端に揃える" do
        a = Range2.new(0, 50)
        b = Range2.new(0, 100)
        assert { a.align_end_of(b) == Range2.new(50, 100) }
      end

      it "相手の中央に揃える" do
        a = Range2.new(0.0, 50.0)
        b = Range2.new(0.0, 100.0)
        assert { a.align_middle_of(b) == Range2.new(25.0, 75.0) }
      end

      it "相手のどこかに揃える" do
        a = Range2.new(0.0, 5.0)
        b = Range2.new(10.0, 20.0)
        assert { a.align_to(:start, b) == Range2.new(10.0, 15.0) }
        assert { a.align_to(:end, b) == Range2.new(15.0, 20.0) }
        assert { a.align_to(:middle, b) == Range2.new(12.5, 17.5) }
      end
    end

    describe "横に並べる" do
      it "相手の左隣り並べる" do
        a = Range2.new(0.0, 5.0)
        b = Range2.new(0.0, 10.0)
        assert { a.align_after(b) == Range2.new(10.0, 15.0) }
      end

      it "相手の右隣り並べる" do
        a = Range2.new(0.0, 5.0)
        b = Range2.new(0.0, 0.0)
        assert { a.align_before(b) == Range2.new(-5.0, 0.0) }
      end
    end

    describe "Edge を寄せる (サイズが変わる)" do
      it "左端を内側に寄せる" do
        assert { Range2.new(10, 0).pad_start(3) == Range2.new(7, 0) }
      end

      it "右端を内側に寄せる" do
        assert { Range2.new(10, 0).pad_end(3) == Range2.new(10, 3) }
      end

      it "両端を内側に寄せる" do
        assert { Range2.new(10, 0).pad(3) == Range2.new(7, 3) }
      end

      it "両端を内側に寄せる (個別指定)" do
        assert { Range2.new(10, 0).pad_ends(3, 4) == Range2.new(7, 4) }
      end
    end

    it "この範囲に含むか?" do
      assert { Range2.new(1, 2).contains?(2) == true }
    end

    it "対象を補正する" do
      assert { Range2.new(10, 0).clamp_value(-1) == 0 }
      assert { Range2.new(10, 0).clamp_value(11) == 10 }
    end

    it "ずらす (サイズ不変)" do
      assert { Range2.new(2, 3).shift(10) == Range2.new(12, 13) }
    end

    it "近い方の端を引き伸ばす" do
      assert { Range2.new(10, 20).stretch_to_value(5) == Range2.new(5, 20) }
      assert { Range2.new(10, 20).stretch_to_value(25) == Range2.new(10, 25) }
    end

    it "範囲内を指定した場合は何も変化しない" do
      assert { Range2.new(10, 20).stretch_to_value(15) == Range2.new(10, 20) }
    end

    it "値に近い方の Edge を返す" do
      assert { Range2.new(0.0, 10.0).closest_edge(4.0) == :start }
      assert { Range2.new(0.0, 10.0).closest_edge(6.0) == :end }
    end

    it "==" do
      assert { Range2.new(1, 2) == Range2.new(1, 2) }
    end
  end
end
# >> ...............................
# >>
# >> Finished in 0.02181 seconds (files took 0.07278 seconds to load)
# >> 31 examples, 0 failures
# >>

早見表

種類 意味 Methods 同類・備考
生成 pos ± (len / 2) from_pos_and_len 推奨
生成 start..end new(start, end)
参照 左, 右, 中央 start / end / middle
参照 強さ (end - start) magnitude
参照 長さ (強さの絶対値) length
向き 向き direction -1, 0, 1 を返す
向き 相手と同じ向きか? same_direction?(o)
向き 反転 invert
向き 「←」なら「→」にする absolute
対領域 OR a.max(o)
対領域 OR (向きを維持する) a.max_directed(o)
対領域 AND a.overlap(o)
移動 相手の軸に揃える a.align_start_of(o) align_end_of / align_middle_of
移動 相手の軸の ? に揃える a.align_to(?, o)
移動 相手の隣に並べる a.align_after(o) a.align_before(o)
移動 v だけ移動する shift(v)
変形 縮小 pad(v) pad_start / pad_end / pad_ends
変形 v に近い方の端を広げる stretch_to_value(v)
その他 整形補完 a.lerp(v) a.map_value(v, o)
その他 領域に含まれるか? a.contains?(v)
その他 相手を補正する clamp_value(v) v.clamp(range) に類似
その他 v に近い方の Edge を返す closest_edge(v)
その他 小数補正 round floor / ceil / truncate

特徴

  • Range クラスとは根本的に考え方が異なる
    • 単に start と end の位置を持つだけ
    • start > end の関係になることもある
    • 終端を必ず含む
  • 値オブジェクト風
    • start end は外部から更新できるものの破壊的メソッドはない

コンストラクタ

基本

Range2.new(1, 2)  # => (1 -> 2)

pos を中心に半径 len / 2 の幅とする

Range2::from_pos_and_len(100.0, 10.0)  # => (95.0 -> 105.0)

それぞれの値

Range2.new(100.0, 200.0).start   # => 100.0
Range2.new(100.0, 200.0).middle  # => 150.0
Range2.new(100.0, 200.0).end     # => 200.0

ベクトルの強さと長さ

強さ (end - start)

Range2.new(100, -200).magnitude  # => -300

長さ (強さの絶対値)

Range2.new(100, -200).length  # => 300

小数の補正

Range2.new(0.4, 0.5).round     # => (0 -> 1)
Range2.new(0.4, 0.5).floor     # => (0 -> 0)
Range2.new(0.4, 0.5).ceil      # => (1 -> 1)
Range2.new(0.4, 0.5).truncate  # => (0 -> 0)

範囲

OR (向きを破壊する)

a = Range2.new(5.0, 3.0)
b = Range2.new(4.0, 6.0)
a.max(b)  # => (3.0 -> 6.0)

OR (向きを維持する)

a = Range2.new(5.0, 3.0)
b = Range2.new(4.0, 6.0)
a.max_directed(b)  # => (6.0 -> 3.0)

a が右向きなら max と同じだが左向きなら max の invert になる。

AND (向きを破壊する)

a = Range2.new(5.0, 3.0)
b = Range2.new(4.0, 6.0)
a.overlap(b)  # => (4.0 -> 5.0)

向き

現在の向きを返す

Range2.new(0, 10).direction   # => 1.0
Range2.new(10, 0).direction   # => -1.0
Range2.new(10, 10).direction  # => -0.0

向きが同じか?

a = Range2.new(1, 2)
b = Range2.new(3, 4)
a.same_direction?(b)  # => true

向きを反転する

Range2.new(0, 100).invert  # => (100 -> 0)

正の向きにする

Range2.new(10, 0).absolute  # => (0 -> 10)

start > end なら invert する。

スケーリング

a = Range2.new(0.0, 1.0)
b = Range2.new(0.0, 100.0)
a.map_value(0.9, b)  # => 90.0

a の 0.9 は b では 90 になる。

元の範囲が 0..1 の場合 lerp 使うと簡潔に書ける。

b.lerp(0.9)  # => 90.0

指定の軸で整列する

相手の左端に揃える

a = Range2.new(0, 100)
b = Range2.new(50, 100)
a.align_start_of(b)  # => (50 -> 150)

相手の右端に揃える

a = Range2.new(0, 50)
b = Range2.new(0, 100)
a.align_end_of(b)  # => (50 -> 100)

相手の中央に揃える

a = Range2.new(0.0, 50.0)
b = Range2.new(0.0, 100.0)
a.align_middle_of(b)  # => (25.0 -> 75.0)

相手のどこかに揃える

a = Range2.new(0.0, 5.0)
b = Range2.new(10.0, 20.0)
a.align_to(:start, b)   # => (10.0 -> 15.0)
a.align_to(:end, b)     # => (15.0 -> 20.0)
a.align_to(:middle, b)  # => (12.5 -> 17.5)

横に並べる

相手の左隣り並べる

a = Range2.new(0.0, 5.0)
b = Range2.new(0.0, 10.0)
a.align_after(b)  # => (10.0 -> 15.0)

相手の右隣り並べる

a = Range2.new(0.0, 5.0)
b = Range2.new(0.0, 0.0)
a.align_before(b)  # => (-5.0 -> 0.0)

Edge を寄せる (サイズが変わる)

左端を内側に寄せる

Range2.new(10, 0).pad_start(3)  # => (7 -> 0)

右端を内側に寄せる

Range2.new(10, 0).pad_end(3)  # => (10 -> 3)

両端を内側に寄せる

Range2.new(10, 0).pad(3)  # => (7 -> 3)

両端を内側に寄せる (個別指定)

Range2.new(10, 0).pad_ends(3, 4)  # => (7 -> 4)

この範囲に含むか?

Range2.new(1, 2).contains?(2)  # => true

対象を補正する

Range2.new(10, 0).clamp_value(-1)  # => 0
Range2.new(10, 0).clamp_value(11)  # => 10

ずらす (サイズ不変)

Range2.new(2, 3).shift(10)  # => (12 -> 13)

近い方の端を引き伸ばす

Range2.new(10, 20).stretch_to_value(5)   # => (5 -> 20)
Range2.new(10, 20).stretch_to_value(25)  # => (10 -> 25)

範囲内を指定した場合は何も変化しない。

Range2.new(10, 20).stretch_to_value(15)  # => (10 -> 20)

値に近い方の Edge を返す

Range2.new(0.0, 10.0).closest_edge(4.0)  # => :start
Range2.new(0.0, 10.0).closest_edge(6.0)  # => :end

関連

https://zenn.dev/megeton/articles/0ace7ce5d23f48

Discussion