three-rb 0.1.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.
Files changed (145) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +153 -0
  5. data/docs/browser-runtime.md +153 -0
  6. data/docs/implementation-plan.md +874 -0
  7. data/docs/loaded-assets-design.md +400 -0
  8. data/docs/next-work.md +107 -0
  9. data/docs/publishing.md +64 -0
  10. data/docs/release-readiness.md +83 -0
  11. data/examples/browser/README.md +54 -0
  12. data/examples/browser/assets/animated_triangle.gltf +123 -0
  13. data/examples/browser/assets/checker.svg +11 -0
  14. data/examples/browser/assets/compressed_triangle.gltf +74 -0
  15. data/examples/browser/assets/studio.hdr +5 -0
  16. data/examples/browser/assets/triangle.gltf +67 -0
  17. data/examples/browser/composition/README.md +35 -0
  18. data/examples/browser/composition/boot.mjs +6 -0
  19. data/examples/browser/composition/index.html +136 -0
  20. data/examples/browser/composition/main.rb +216 -0
  21. data/examples/browser/composition/smoke_test.mjs +266 -0
  22. data/examples/browser/cube/README.md +41 -0
  23. data/examples/browser/cube/boot.mjs +6 -0
  24. data/examples/browser/cube/index.html +142 -0
  25. data/examples/browser/cube/main.rb +62 -0
  26. data/examples/browser/cube/smoke_test.mjs +99 -0
  27. data/examples/browser/cubemap/README.md +23 -0
  28. data/examples/browser/cubemap/boot.mjs +6 -0
  29. data/examples/browser/cubemap/index.html +142 -0
  30. data/examples/browser/cubemap/main.rb +84 -0
  31. data/examples/browser/cubemap/smoke_test.mjs +91 -0
  32. data/examples/browser/gltf/README.md +23 -0
  33. data/examples/browser/gltf/boot.mjs +6 -0
  34. data/examples/browser/gltf/index.html +142 -0
  35. data/examples/browser/gltf/main.rb +105 -0
  36. data/examples/browser/gltf/smoke_test.mjs +162 -0
  37. data/examples/browser/picking/README.md +33 -0
  38. data/examples/browser/picking/boot.mjs +6 -0
  39. data/examples/browser/picking/index.html +142 -0
  40. data/examples/browser/picking/main.rb +113 -0
  41. data/examples/browser/picking/smoke_test.mjs +78 -0
  42. data/examples/browser/postprocessing/README.md +26 -0
  43. data/examples/browser/postprocessing/boot.mjs +6 -0
  44. data/examples/browser/postprocessing/index.html +142 -0
  45. data/examples/browser/postprocessing/main.rb +117 -0
  46. data/examples/browser/postprocessing/smoke_test.mjs +121 -0
  47. data/examples/browser/primitives/README.md +33 -0
  48. data/examples/browser/primitives/boot.mjs +6 -0
  49. data/examples/browser/primitives/index.html +142 -0
  50. data/examples/browser/primitives/main.rb +116 -0
  51. data/examples/browser/primitives/smoke_test.mjs +113 -0
  52. data/examples/browser/serialization/README.md +33 -0
  53. data/examples/browser/serialization/boot.mjs +6 -0
  54. data/examples/browser/serialization/index.html +142 -0
  55. data/examples/browser/serialization/main.rb +97 -0
  56. data/examples/browser/serialization/smoke_test.mjs +67 -0
  57. data/examples/browser/shared/boot.mjs +79 -0
  58. data/examples/browser/shared/smoke_test_helpers.mjs +151 -0
  59. data/examples/browser/textures/README.md +35 -0
  60. data/examples/browser/textures/boot.mjs +6 -0
  61. data/examples/browser/textures/index.html +142 -0
  62. data/examples/browser/textures/main.rb +142 -0
  63. data/examples/browser/textures/smoke_test.mjs +189 -0
  64. data/lib/three/animation/animation_action.rb +57 -0
  65. data/lib/three/animation/animation_clip.rb +22 -0
  66. data/lib/three/animation/animation_mixer.rb +43 -0
  67. data/lib/three/backends/base.rb +87 -0
  68. data/lib/three/backends/threejs/materialization.rb +143 -0
  69. data/lib/three/backends/threejs/parameters.rb +97 -0
  70. data/lib/three/backends/threejs/resource_management.rb +69 -0
  71. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +873 -0
  72. data/lib/three/backends/threejs/synchronization.rb +224 -0
  73. data/lib/three/backends/threejs.rb +365 -0
  74. data/lib/three/cameras/camera.rb +39 -0
  75. data/lib/three/cameras/orthographic_camera.rb +107 -0
  76. data/lib/three/cameras/perspective_camera.rb +137 -0
  77. data/lib/three/constants.rb +40 -0
  78. data/lib/three/controls/orbit_controls.rb +118 -0
  79. data/lib/three/core/buffer_attribute.rb +151 -0
  80. data/lib/three/core/buffer_geometry.rb +181 -0
  81. data/lib/three/core/clock.rb +58 -0
  82. data/lib/three/core/event_dispatcher.rb +57 -0
  83. data/lib/three/core/layers.rb +75 -0
  84. data/lib/three/core/object3d.rb +331 -0
  85. data/lib/three/core/raycaster.rb +73 -0
  86. data/lib/three/dirty.rb +58 -0
  87. data/lib/three/exporters/three_json_exporter.rb +187 -0
  88. data/lib/three/geometries/box_geometry.rb +97 -0
  89. data/lib/three/geometries/plane_geometry.rb +70 -0
  90. data/lib/three/geometries/sphere_geometry.rb +107 -0
  91. data/lib/three/lights/ambient_light.rb +12 -0
  92. data/lib/three/lights/directional_light.rb +38 -0
  93. data/lib/three/lights/hemisphere_light.rb +34 -0
  94. data/lib/three/lights/light.rb +85 -0
  95. data/lib/three/lights/point_light.rb +33 -0
  96. data/lib/three/loaders/cube_texture_loader.rb +13 -0
  97. data/lib/three/loaders/gltf_loader.rb +48 -0
  98. data/lib/three/loaders/rgbe_loader.rb +15 -0
  99. data/lib/three/loaders/texture_loader.rb +13 -0
  100. data/lib/three/loaders/three_json_loader.rb +409 -0
  101. data/lib/three/materials/line_basic_material.rb +65 -0
  102. data/lib/three/materials/material.rb +158 -0
  103. data/lib/three/materials/mesh_basic_material.rb +64 -0
  104. data/lib/three/materials/mesh_lambert_material.rb +71 -0
  105. data/lib/three/materials/mesh_matcap_material.rb +86 -0
  106. data/lib/three/materials/mesh_normal_material.rb +42 -0
  107. data/lib/three/materials/mesh_phong_material.rb +119 -0
  108. data/lib/three/materials/mesh_physical_material.rb +155 -0
  109. data/lib/three/materials/mesh_standard_material.rb +149 -0
  110. data/lib/three/materials/mesh_toon_material.rb +98 -0
  111. data/lib/three/materials/points_material.rb +74 -0
  112. data/lib/three/materials/shadow_material.rb +45 -0
  113. data/lib/three/materials/sprite_material.rb +75 -0
  114. data/lib/three/math/color.rb +133 -0
  115. data/lib/three/math/euler.rb +197 -0
  116. data/lib/three/math/math_utils.rb +36 -0
  117. data/lib/three/math/matrix3.rb +255 -0
  118. data/lib/three/math/matrix4.rb +448 -0
  119. data/lib/three/math/quaternion.rb +277 -0
  120. data/lib/three/math/vector2.rb +95 -0
  121. data/lib/three/math/vector3.rb +396 -0
  122. data/lib/three/objects/external_object3d.rb +28 -0
  123. data/lib/three/objects/group.rb +12 -0
  124. data/lib/three/objects/instanced_mesh.rb +110 -0
  125. data/lib/three/objects/line.rb +41 -0
  126. data/lib/three/objects/mesh.rb +45 -0
  127. data/lib/three/objects/points.rb +41 -0
  128. data/lib/three/objects/sprite.rb +57 -0
  129. data/lib/three/postprocessing/dot_screen_pass.rb +83 -0
  130. data/lib/three/postprocessing/effect_composer.rb +56 -0
  131. data/lib/three/postprocessing/output_pass.rb +40 -0
  132. data/lib/three/postprocessing/render_pass.rb +42 -0
  133. data/lib/three/postprocessing/unreal_bloom_pass.rb +56 -0
  134. data/lib/three/renderers/renderer.rb +11 -0
  135. data/lib/three/renderers/threejs_renderer.rb +85 -0
  136. data/lib/three/scenes/scene.rb +29 -0
  137. data/lib/three/textures/cube_texture.rb +72 -0
  138. data/lib/three/textures/rgbe_texture.rb +45 -0
  139. data/lib/three/textures/texture.rb +200 -0
  140. data/lib/three/version.rb +5 -0
  141. data/lib/three-rb.rb +3 -0
  142. data/lib/three.rb +77 -0
  143. data/package.json +30 -0
  144. data/pnpm-lock.yaml +86 -0
  145. metadata +216 -0
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../math/color"
4
+ require_relative "material"
5
+
6
+ module Three
7
+ class MeshStandardMaterial < Material
8
+ TEXTURE_SLOTS = %i[
9
+ map
10
+ alpha_map
11
+ ao_map
12
+ bump_map
13
+ displacement_map
14
+ emissive_map
15
+ env_map
16
+ light_map
17
+ metalness_map
18
+ normal_map
19
+ roughness_map
20
+ ].freeze
21
+
22
+ attr_reader :color, :roughness, :metalness, :wireframe, :wireframe_linewidth, :fog, :flat_shading
23
+ attr_reader(*TEXTURE_SLOTS)
24
+
25
+ def initialize(parameters = nil)
26
+ super(nil)
27
+ @type = "MeshStandardMaterial"
28
+ @color = Color.new(0xffffff)
29
+ @roughness = 1
30
+ @metalness = 0
31
+ TEXTURE_SLOTS.each { |slot| instance_variable_set(:"@#{slot}", nil) }
32
+ @wireframe = false
33
+ @wireframe_linewidth = 1
34
+ @fog = true
35
+ @flat_shading = false
36
+ bind_color_changes
37
+ set_values(parameters) if parameters
38
+ mark_dirty!
39
+ end
40
+
41
+ def color=(value)
42
+ @color = value.is_a?(Color) ? value : Color.new(value)
43
+ bind_color_changes
44
+ mark_dirty!(:parameters)
45
+ end
46
+
47
+ def roughness=(value)
48
+ @roughness = value
49
+ mark_dirty!(:parameters)
50
+ end
51
+
52
+ def metalness=(value)
53
+ @metalness = value
54
+ mark_dirty!(:parameters)
55
+ end
56
+
57
+ def map=(value)
58
+ set_texture_slot(:map, value)
59
+ end
60
+
61
+ def alpha_map=(value)
62
+ set_texture_slot(:alpha_map, value)
63
+ end
64
+
65
+ def ao_map=(value)
66
+ set_texture_slot(:ao_map, value)
67
+ end
68
+
69
+ def bump_map=(value)
70
+ set_texture_slot(:bump_map, value)
71
+ end
72
+
73
+ def displacement_map=(value)
74
+ set_texture_slot(:displacement_map, value)
75
+ end
76
+
77
+ def emissive_map=(value)
78
+ set_texture_slot(:emissive_map, value)
79
+ end
80
+
81
+ def env_map=(value)
82
+ set_texture_slot(:env_map, value)
83
+ end
84
+
85
+ def light_map=(value)
86
+ set_texture_slot(:light_map, value)
87
+ end
88
+
89
+ def metalness_map=(value)
90
+ set_texture_slot(:metalness_map, value)
91
+ end
92
+
93
+ def normal_map=(value)
94
+ set_texture_slot(:normal_map, value)
95
+ end
96
+
97
+ def roughness_map=(value)
98
+ set_texture_slot(:roughness_map, value)
99
+ end
100
+
101
+ def wireframe=(value)
102
+ @wireframe = value
103
+ mark_dirty!(:parameters)
104
+ end
105
+
106
+ def wireframe_linewidth=(value)
107
+ @wireframe_linewidth = value
108
+ mark_dirty!(:parameters)
109
+ end
110
+
111
+ def fog=(value)
112
+ @fog = value
113
+ mark_dirty!(:parameters)
114
+ end
115
+
116
+ def flat_shading=(value)
117
+ @flat_shading = value
118
+ mark_dirty!(:parameters)
119
+ end
120
+
121
+ def texture_slots
122
+ TEXTURE_SLOTS
123
+ end
124
+
125
+ def to_h
126
+ result = super.merge(
127
+ color: @color.hex,
128
+ roughness: @roughness,
129
+ metalness: @metalness,
130
+ wireframe: @wireframe,
131
+ wireframe_linewidth: @wireframe_linewidth,
132
+ fog: @fog,
133
+ flat_shading: @flat_shading
134
+ )
135
+ TEXTURE_SLOTS.each { |slot| result[slot] = public_send(slot)&.to_h }
136
+ result
137
+ end
138
+
139
+ private
140
+
141
+ def bind_color_changes
142
+ @color.on_change { mark_dirty!(:parameters) }
143
+ end
144
+
145
+ def set_texture_slot(slot, value)
146
+ replace_texture_slot(slot, value)
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../math/color"
4
+ require_relative "material"
5
+
6
+ module Three
7
+ class MeshToonMaterial < Material
8
+ TEXTURE_SLOTS = %i[
9
+ map
10
+ gradient_map
11
+ light_map
12
+ ao_map
13
+ emissive_map
14
+ bump_map
15
+ normal_map
16
+ displacement_map
17
+ alpha_map
18
+ ].freeze
19
+
20
+ attr_reader :color, :emissive, :wireframe, :wireframe_linewidth, :fog, :flat_shading
21
+ attr_reader(*TEXTURE_SLOTS)
22
+
23
+ def initialize(parameters = nil)
24
+ super(nil)
25
+ @type = "MeshToonMaterial"
26
+ @color = Color.new(0xffffff)
27
+ @emissive = Color.new(0x000000)
28
+ TEXTURE_SLOTS.each { |slot| instance_variable_set(:"@#{slot}", nil) }
29
+ @wireframe = false
30
+ @wireframe_linewidth = 1
31
+ @fog = true
32
+ @flat_shading = false
33
+ bind_color_changes
34
+ set_values(parameters) if parameters
35
+ mark_dirty!
36
+ end
37
+
38
+ def color=(value)
39
+ @color = value.is_a?(Color) ? value : Color.new(value)
40
+ bind_color_changes
41
+ mark_dirty!(:parameters)
42
+ end
43
+
44
+ def emissive=(value)
45
+ @emissive = value.is_a?(Color) ? value : Color.new(value)
46
+ bind_color_changes
47
+ mark_dirty!(:parameters)
48
+ end
49
+
50
+ TEXTURE_SLOTS.each do |slot|
51
+ define_method(:"#{slot}=") { |value| replace_texture_slot(slot, value) }
52
+ end
53
+
54
+ def wireframe=(value)
55
+ @wireframe = value
56
+ mark_dirty!(:parameters)
57
+ end
58
+
59
+ def wireframe_linewidth=(value)
60
+ @wireframe_linewidth = value
61
+ mark_dirty!(:parameters)
62
+ end
63
+
64
+ def fog=(value)
65
+ @fog = value
66
+ mark_dirty!(:parameters)
67
+ end
68
+
69
+ def flat_shading=(value)
70
+ @flat_shading = value
71
+ mark_dirty!(:parameters)
72
+ end
73
+
74
+ def texture_slots
75
+ TEXTURE_SLOTS
76
+ end
77
+
78
+ def to_h
79
+ result = super.merge(
80
+ color: @color.hex,
81
+ emissive: @emissive.hex,
82
+ wireframe: @wireframe,
83
+ wireframe_linewidth: @wireframe_linewidth,
84
+ fog: @fog,
85
+ flat_shading: @flat_shading
86
+ )
87
+ TEXTURE_SLOTS.each { |slot| result[slot] = public_send(slot)&.to_h }
88
+ result
89
+ end
90
+
91
+ private
92
+
93
+ def bind_color_changes
94
+ @color.on_change { mark_dirty!(:parameters) }
95
+ @emissive.on_change { mark_dirty!(:parameters) }
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../math/color"
4
+ require_relative "material"
5
+
6
+ module Three
7
+ class PointsMaterial < Material
8
+ attr_reader :color, :map, :alpha_map, :size, :size_attenuation, :fog
9
+
10
+ def initialize(parameters = nil)
11
+ super(nil)
12
+ @type = "PointsMaterial"
13
+ @color = Color.new(0xffffff)
14
+ @map = nil
15
+ @alpha_map = nil
16
+ @size = 1
17
+ @size_attenuation = true
18
+ @fog = true
19
+ bind_color_changes
20
+ set_values(parameters) if parameters
21
+ mark_dirty!
22
+ end
23
+
24
+ def color=(value)
25
+ @color = value.is_a?(Color) ? value : Color.new(value)
26
+ bind_color_changes
27
+ mark_dirty!(:parameters)
28
+ end
29
+
30
+ def map=(value)
31
+ replace_texture_slot(:map, value)
32
+ end
33
+
34
+ def alpha_map=(value)
35
+ replace_texture_slot(:alpha_map, value)
36
+ end
37
+
38
+ def size=(value)
39
+ @size = value
40
+ mark_dirty!(:parameters)
41
+ end
42
+
43
+ def size_attenuation=(value)
44
+ @size_attenuation = value
45
+ mark_dirty!(:parameters)
46
+ end
47
+
48
+ def fog=(value)
49
+ @fog = value
50
+ mark_dirty!(:parameters)
51
+ end
52
+
53
+ def texture_slots
54
+ %i[map alpha_map]
55
+ end
56
+
57
+ def to_h
58
+ super.merge(
59
+ color: @color.hex,
60
+ map: @map&.to_h,
61
+ alpha_map: @alpha_map&.to_h,
62
+ size: @size,
63
+ size_attenuation: @size_attenuation,
64
+ fog: @fog
65
+ )
66
+ end
67
+
68
+ private
69
+
70
+ def bind_color_changes
71
+ @color.on_change { mark_dirty!(:parameters) }
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../math/color"
4
+ require_relative "material"
5
+
6
+ module Three
7
+ class ShadowMaterial < Material
8
+ attr_reader :color, :fog
9
+
10
+ def initialize(parameters = nil)
11
+ super(nil)
12
+ @type = "ShadowMaterial"
13
+ @color = Color.new(0x000000)
14
+ @transparent = true
15
+ @fog = true
16
+ bind_color_changes
17
+ set_values(parameters) if parameters
18
+ mark_dirty!
19
+ end
20
+
21
+ def color=(value)
22
+ @color = value.is_a?(Color) ? value : Color.new(value)
23
+ bind_color_changes
24
+ mark_dirty!(:parameters)
25
+ end
26
+
27
+ def fog=(value)
28
+ @fog = value
29
+ mark_dirty!(:parameters)
30
+ end
31
+
32
+ def to_h
33
+ super.merge(
34
+ color: @color.hex,
35
+ fog: @fog
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def bind_color_changes
42
+ @color.on_change { mark_dirty!(:parameters) }
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../math/color"
4
+ require_relative "material"
5
+
6
+ module Three
7
+ class SpriteMaterial < Material
8
+ attr_reader :color, :map, :alpha_map, :rotation, :size_attenuation, :fog
9
+
10
+ def initialize(parameters = nil)
11
+ super(nil)
12
+ @type = "SpriteMaterial"
13
+ @color = Color.new(0xffffff)
14
+ @map = nil
15
+ @alpha_map = nil
16
+ @rotation = 0
17
+ @size_attenuation = true
18
+ @transparent = true
19
+ @fog = true
20
+ bind_color_changes
21
+ set_values(parameters) if parameters
22
+ mark_dirty!
23
+ end
24
+
25
+ def color=(value)
26
+ @color = value.is_a?(Color) ? value : Color.new(value)
27
+ bind_color_changes
28
+ mark_dirty!(:parameters)
29
+ end
30
+
31
+ def map=(value)
32
+ replace_texture_slot(:map, value)
33
+ end
34
+
35
+ def alpha_map=(value)
36
+ replace_texture_slot(:alpha_map, value)
37
+ end
38
+
39
+ def rotation=(value)
40
+ @rotation = value
41
+ mark_dirty!(:parameters)
42
+ end
43
+
44
+ def size_attenuation=(value)
45
+ @size_attenuation = value
46
+ mark_dirty!(:parameters)
47
+ end
48
+
49
+ def fog=(value)
50
+ @fog = value
51
+ mark_dirty!(:parameters)
52
+ end
53
+
54
+ def texture_slots
55
+ %i[map alpha_map]
56
+ end
57
+
58
+ def to_h
59
+ super.merge(
60
+ color: @color.hex,
61
+ map: @map&.to_h,
62
+ alpha_map: @alpha_map&.to_h,
63
+ rotation: @rotation,
64
+ size_attenuation: @size_attenuation,
65
+ fog: @fog
66
+ )
67
+ end
68
+
69
+ private
70
+
71
+ def bind_color_changes
72
+ @color.on_change { mark_dirty!(:parameters) }
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Three
4
+ class Color
5
+ include Enumerable
6
+
7
+ attr_reader :r, :g, :b
8
+
9
+ DEFAULT = Object.new
10
+
11
+ def initialize(r = DEFAULT, g = nil, b = nil)
12
+ @on_change_callback = proc {}
13
+
14
+ if r.equal?(DEFAULT)
15
+ set_rgb(1, 1, 1)
16
+ elsif g.nil? && b.nil?
17
+ set(r)
18
+ else
19
+ set_rgb(r, g, b)
20
+ end
21
+ end
22
+
23
+ def r=(value)
24
+ @r = value
25
+ changed!
26
+ end
27
+
28
+ def g=(value)
29
+ @g = value
30
+ changed!
31
+ end
32
+
33
+ def b=(value)
34
+ @b = value
35
+ changed!
36
+ end
37
+
38
+ def set(value)
39
+ case value
40
+ when Color
41
+ copy(value)
42
+ when Integer
43
+ set_hex(value)
44
+ when String
45
+ set_style(value)
46
+ else
47
+ set_rgb(value, value, value)
48
+ end
49
+ end
50
+
51
+ def set_rgb(r, g, b)
52
+ @r = r
53
+ @g = g
54
+ @b = b
55
+ changed!
56
+ self
57
+ end
58
+
59
+ def set_hex(hex)
60
+ hex = hex.to_i
61
+ @r = ((hex >> 16) & 255) / 255.0
62
+ @g = ((hex >> 8) & 255) / 255.0
63
+ @b = (hex & 255) / 255.0
64
+ changed!
65
+ self
66
+ end
67
+
68
+ def set_style(style)
69
+ value = style.start_with?("#") ? style[1..] : style
70
+ raise ArgumentError, "unsupported color style: #{style}" unless value&.match?(/\A[0-9a-fA-F]{6}\z/)
71
+
72
+ set_hex(value.to_i(16))
73
+ end
74
+
75
+ def copy(color)
76
+ @r = color.r
77
+ @g = color.g
78
+ @b = color.b
79
+ changed!
80
+ self
81
+ end
82
+
83
+ def clone
84
+ self.class.new(@r, @g, @b)
85
+ end
86
+
87
+ def hex
88
+ r = (@r * 255).round.clamp(0, 255)
89
+ g = (@g * 255).round.clamp(0, 255)
90
+ b = (@b * 255).round.clamp(0, 255)
91
+ (r << 16) ^ (g << 8) ^ b
92
+ end
93
+
94
+ def equals?(color, epsilon: 0.0)
95
+ (@r - color.r).abs <= epsilon &&
96
+ (@g - color.g).abs <= epsilon &&
97
+ (@b - color.b).abs <= epsilon
98
+ end
99
+
100
+ def ==(other)
101
+ other.is_a?(self.class) && equals?(other)
102
+ end
103
+
104
+ def each
105
+ return enum_for(:each) unless block_given?
106
+
107
+ yield @r
108
+ yield @g
109
+ yield @b
110
+ end
111
+
112
+ def to_a
113
+ [@r, @g, @b]
114
+ end
115
+
116
+ def deconstruct
117
+ to_a
118
+ end
119
+
120
+ def on_change(&callback)
121
+ @on_change_callback = callback || proc {}
122
+ self
123
+ end
124
+
125
+ alias _on_change on_change
126
+
127
+ private
128
+
129
+ def changed!
130
+ @on_change_callback.call
131
+ end
132
+ end
133
+ end