yarn_skein 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2efd2c01dfa8a4654b1f110487399eb0111dba77daf030e7d3826d3514531a75
4
- data.tar.gz: ea44d7874a49e18995a51e9ca09a04c787bf43db2f49a79281213f4679db14bb
3
+ metadata.gz: 6e58123240286568c5799c6ee0d7ed8572de554f44c4e2cbf653e178be29a181
4
+ data.tar.gz: bd9aba63f86efb708f7156575d7136d91cd660f05bc857b5a0cb50b8087586b7
5
5
  SHA512:
6
- metadata.gz: 737cb9cedba8020ac8d5050d771a3051f43fa56a1a55c634120dbb77d1a977e22e0affb71d5b4746fa106c488468a19a737a4723319eb71e85a06722ab2d07fa
7
- data.tar.gz: e52704d9b994e919071302371e97c0d7d56fe0b72e64c26c01d71015211e520566a6406d1de8c259c5826b10614d2a748c69d0852f9f62c09c275c2a50c23a76
6
+ metadata.gz: 7a13f3cde5def54d9bdd85026f2d143a9e3d0e9ca4b76023573839c8fc6f6d1b224580e62eb19907b6559a08391103470efb71fb7e6dba428d1364f64d4ea165
7
+ data.tar.gz: 398c7cfaa30a61beb4839fda0bc96737023b03df676b1fb85cae9e8854e4d7119e2f32be1de5fe2a86d402ea365450a0547b8749938395f68cb18644b61d5567
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-03-28
4
+
5
+ ### Added
6
+ - `YarnSkein::ColorworkEstimator` for estimating per-color yarn yardage in stranded and intarsia colorwork
7
+ - Supports float overhead calculation for stranded colorwork (default 20%)
8
+ - Per-color breakdown with optional skein calculations when yarn is provided
9
+ - Configurable safety margin (default 10%) consistent with `YardageEstimator`
10
+
3
11
  ## [0.3.0] - 2026-03-26
4
12
 
5
13
  ### Added
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module YarnSkein
4
+ # Estimates per-color yarn yardage for colorwork techniques.
5
+ #
6
+ # Supports stranded (Fair Isle) and intarsia colorwork. Stranded colorwork
7
+ # adds a float overhead factor because unused colors are carried across the
8
+ # back of the fabric. Intarsia uses separate yarn sections with no floats.
9
+ #
10
+ # @example Stranded colorwork
11
+ # estimator = YarnSkein::ColorworkEstimator.new(
12
+ # gauge: gauge,
13
+ # technique: :stranded
14
+ # )
15
+ # estimator.estimate(
16
+ # width: 40.inches, height: 24.inches,
17
+ # colors: { main: 0.60, contrast: 0.40 },
18
+ # yarn: yarn
19
+ # )
20
+ #
21
+ # @example Intarsia colorwork
22
+ # estimator = YarnSkein::ColorworkEstimator.new(
23
+ # gauge: gauge,
24
+ # technique: :intarsia
25
+ # )
26
+ # estimator.estimate(
27
+ # width: 40.inches, height: 24.inches,
28
+ # colors: { left: 0.50, right: 0.50 },
29
+ # yarn: yarn
30
+ # )
31
+ class ColorworkEstimator
32
+ TECHNIQUES = %i[stranded intarsia].freeze
33
+
34
+ # Default float overhead for stranded colorwork (20%).
35
+ DEFAULT_FLOAT_OVERHEAD = 0.20
36
+
37
+ # Default safety margin (10%), consistent with YardageEstimator.
38
+ DEFAULT_MARGIN = 0.10
39
+
40
+ # @return [FiberGauge::Gauge]
41
+ attr_reader :gauge
42
+
43
+ # @return [Symbol] :stranded or :intarsia
44
+ attr_reader :technique
45
+
46
+ # @param gauge [FiberGauge::Gauge] gauge swatch data
47
+ # @param technique [Symbol] :stranded or :intarsia
48
+ def initialize(gauge:, technique:)
49
+ unless TECHNIQUES.include?(technique)
50
+ raise ArgumentError, "technique must be one of: #{TECHNIQUES.join(", ")}"
51
+ end
52
+
53
+ @gauge = gauge
54
+ @technique = technique
55
+ end
56
+
57
+ # Estimates per-color yardage for a colorwork piece.
58
+ #
59
+ # @param width [FiberUnits::Length] finished width
60
+ # @param height [FiberUnits::Length] finished height
61
+ # @param colors [Hash<Symbol, Float>] color name => proportion (must sum to 1.0)
62
+ # @param yarn [YarnSkein::Yarn, nil] yarn for skein calculations
63
+ # @param margin [Float] safety margin (default 10%)
64
+ # @param float_overhead [Float] extra yarn for floats in stranded work (default 20%)
65
+ # @return [Hash] per-color breakdown and total
66
+ def estimate(width:, height:, colors:, yarn: nil, margin: DEFAULT_MARGIN, float_overhead: DEFAULT_FLOAT_OVERHEAD)
67
+ validate_colors!(colors)
68
+
69
+ base_yardage = base_yardage_for(width, height, margin: 0.0)
70
+ result = {}
71
+ total_yards = 0.0
72
+
73
+ colors.each do |color_name, proportion|
74
+ color_yards = base_yardage * proportion
75
+ color_yards *= (1.0 + float_overhead) if technique == :stranded
76
+ color_yards *= (1.0 + margin)
77
+ total_yards += color_yards
78
+
79
+ entry = {yardage: color_yards.yards}
80
+ entry[:skeins] = yarn.skeins_required(color_yards.yards) if yarn
81
+ result[color_name] = entry
82
+ end
83
+
84
+ result[:total] = {yardage: total_yards.yards}
85
+ result
86
+ end
87
+
88
+ private
89
+
90
+ # Compute raw yardage (in float yards) for a rectangle with no margin.
91
+ def base_yardage_for(width, height, margin:)
92
+ stitches = gauge.required_stitches(width)
93
+ rows = gauge.required_rows(height)
94
+ total_stitches = stitches.value * rows.value
95
+
96
+ total_stitches * yards_per_stitch
97
+ end
98
+
99
+ # Yards of yarn consumed per stitch, using the geometric U-loop model.
100
+ def yards_per_stitch
101
+ stitch_width_in = 1.0 / gauge.spi
102
+ stitch_height_in = 1.0 / gauge.rpi
103
+ (stitch_width_in + (2.0 * stitch_height_in)) / 36.0
104
+ end
105
+
106
+ def validate_colors!(colors)
107
+ raise ArgumentError, "colors must be a non-empty Hash" unless colors.is_a?(Hash) && !colors.empty?
108
+
109
+ total = colors.values.sum
110
+ unless (total - 1.0).abs < 0.001
111
+ raise ArgumentError, "color proportions must sum to 1.0 (got #{total})"
112
+ end
113
+
114
+ colors.each do |name, proportion|
115
+ unless proportion.is_a?(Numeric) && proportion > 0
116
+ raise ArgumentError, "proportion for #{name} must be a positive number"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -3,6 +3,6 @@
3
3
  # :nocov:
4
4
  module YarnSkein
5
5
  # Current gem version.
6
- VERSION = "0.3.0"
6
+ VERSION = "0.4.0"
7
7
  end
8
8
  # :nocov:
data/lib/yarn_skein.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "yarn_skein/fiber_blend"
8
8
  require_relative "yarn_skein/yarn"
9
9
  require_relative "yarn_skein/substitution"
10
10
  require_relative "yarn_skein/yardage_estimator"
11
+ require_relative "yarn_skein/colorwork_estimator"
11
12
  require_relative "yarn_skein/catalog"
12
13
 
13
14
  # Domain objects for describing yarn skeins, fiber blends, and weight classes.
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class YarnSkeinColorworkEstimatorTest < Minitest::Test
6
+ def gauge
7
+ FiberGauge::Gauge.new(
8
+ stitches: 18.stitches,
9
+ rows: 24.rows,
10
+ width: 4.inches,
11
+ height: 4.inches
12
+ )
13
+ end
14
+
15
+ def yarn
16
+ YarnSkein::Yarn.new(
17
+ brand: "Malabrigo",
18
+ line: "Rios",
19
+ yardage: 210.yards,
20
+ skein_weight: 100.grams
21
+ )
22
+ end
23
+
24
+ def stranded_estimator
25
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :stranded)
26
+ end
27
+
28
+ def intarsia_estimator
29
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :intarsia)
30
+ end
31
+
32
+ # ----------------------------
33
+ # initialization
34
+ # ----------------------------
35
+
36
+ def test_rejects_invalid_technique
37
+ assert_raises(ArgumentError) do
38
+ YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :mosaic)
39
+ end
40
+ end
41
+
42
+ def test_accepts_stranded_technique
43
+ est = YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :stranded)
44
+ assert_equal :stranded, est.technique
45
+ end
46
+
47
+ def test_accepts_intarsia_technique
48
+ est = YarnSkein::ColorworkEstimator.new(gauge: gauge, technique: :intarsia)
49
+ assert_equal :intarsia, est.technique
50
+ end
51
+
52
+ # ----------------------------
53
+ # color validation
54
+ # ----------------------------
55
+
56
+ def test_rejects_empty_colors
57
+ assert_raises(ArgumentError) do
58
+ stranded_estimator.estimate(width: 20.inches, height: 20.inches, colors: {})
59
+ end
60
+ end
61
+
62
+ def test_rejects_non_hash_colors
63
+ assert_raises(ArgumentError) do
64
+ stranded_estimator.estimate(width: 20.inches, height: 20.inches, colors: [0.5, 0.5])
65
+ end
66
+ end
67
+
68
+ def test_rejects_proportions_not_summing_to_one
69
+ assert_raises(ArgumentError) do
70
+ stranded_estimator.estimate(
71
+ width: 20.inches, height: 20.inches,
72
+ colors: {main: 0.60, contrast: 0.30}
73
+ )
74
+ end
75
+ end
76
+
77
+ def test_rejects_zero_proportion
78
+ assert_raises(ArgumentError) do
79
+ stranded_estimator.estimate(
80
+ width: 20.inches, height: 20.inches,
81
+ colors: {main: 1.0, contrast: 0.0}
82
+ )
83
+ end
84
+ end
85
+
86
+ # ----------------------------
87
+ # stranded estimation
88
+ # ----------------------------
89
+
90
+ def test_stranded_returns_per_color_yardage
91
+ result = stranded_estimator.estimate(
92
+ width: 40.inches, height: 24.inches,
93
+ colors: {main: 0.60, contrast: 0.40}
94
+ )
95
+
96
+ assert result.key?(:main)
97
+ assert result.key?(:contrast)
98
+ assert result.key?(:total)
99
+ assert_instance_of FiberUnits::Length, result[:main][:yardage]
100
+ assert_instance_of FiberUnits::Length, result[:contrast][:yardage]
101
+ assert_instance_of FiberUnits::Length, result[:total][:yardage]
102
+ end
103
+
104
+ def test_stranded_includes_float_overhead
105
+ stranded = stranded_estimator.estimate(
106
+ width: 40.inches, height: 24.inches,
107
+ colors: {main: 0.60, contrast: 0.40},
108
+ margin: 0.0, float_overhead: 0.20
109
+ )
110
+
111
+ intarsia = intarsia_estimator.estimate(
112
+ width: 40.inches, height: 24.inches,
113
+ colors: {main: 0.60, contrast: 0.40},
114
+ margin: 0.0
115
+ )
116
+
117
+ # Stranded total should be 20% more than intarsia (due to float overhead)
118
+ stranded_total = stranded[:total][:yardage].to(:yards).value
119
+ intarsia_total = intarsia[:total][:yardage].to(:yards).value
120
+
121
+ assert_in_delta intarsia_total * 1.20, stranded_total, 0.01
122
+ end
123
+
124
+ def test_stranded_custom_float_overhead
125
+ low_overhead = stranded_estimator.estimate(
126
+ width: 40.inches, height: 24.inches,
127
+ colors: {main: 0.60, contrast: 0.40},
128
+ margin: 0.0, float_overhead: 0.15
129
+ )
130
+
131
+ high_overhead = stranded_estimator.estimate(
132
+ width: 40.inches, height: 24.inches,
133
+ colors: {main: 0.60, contrast: 0.40},
134
+ margin: 0.0, float_overhead: 0.25
135
+ )
136
+
137
+ assert high_overhead[:total][:yardage].to(:yards).value >
138
+ low_overhead[:total][:yardage].to(:yards).value
139
+ end
140
+
141
+ def test_stranded_proportions_affect_distribution
142
+ result = stranded_estimator.estimate(
143
+ width: 40.inches, height: 24.inches,
144
+ colors: {main: 0.70, contrast: 0.30},
145
+ margin: 0.0
146
+ )
147
+
148
+ main_yards = result[:main][:yardage].to(:yards).value
149
+ contrast_yards = result[:contrast][:yardage].to(:yards).value
150
+
151
+ assert_in_delta 7.0 / 3.0, main_yards / contrast_yards, 0.01
152
+ end
153
+
154
+ # ----------------------------
155
+ # intarsia estimation
156
+ # ----------------------------
157
+
158
+ def test_intarsia_no_float_overhead
159
+ intarsia = intarsia_estimator.estimate(
160
+ width: 40.inches, height: 24.inches,
161
+ colors: {left: 0.50, right: 0.50},
162
+ margin: 0.0
163
+ )
164
+
165
+ # For intarsia, each half should get half the base yardage
166
+ left_yards = intarsia[:left][:yardage].to(:yards).value
167
+ right_yards = intarsia[:right][:yardage].to(:yards).value
168
+
169
+ assert_in_delta left_yards, right_yards, 0.01
170
+ end
171
+
172
+ def test_intarsia_total_matches_base_yardage
173
+ intarsia = intarsia_estimator.estimate(
174
+ width: 20.inches, height: 30.inches,
175
+ colors: {left: 0.50, right: 0.50},
176
+ margin: 0.0
177
+ )
178
+
179
+ # Total intarsia yardage should match a plain YardageEstimator (no margin)
180
+ base = YarnSkein::YardageEstimator.new(gauge: gauge, yarn: yarn)
181
+ base_result = base.for_rectangle(width: 20.inches, height: 30.inches, margin: 0.0)
182
+
183
+ assert_in_delta(
184
+ base_result[:yardage].to(:yards).value,
185
+ intarsia[:total][:yardage].to(:yards).value,
186
+ 0.01
187
+ )
188
+ end
189
+
190
+ # ----------------------------
191
+ # skein calculations
192
+ # ----------------------------
193
+
194
+ def test_includes_skeins_when_yarn_provided
195
+ result = stranded_estimator.estimate(
196
+ width: 40.inches, height: 24.inches,
197
+ colors: {main: 0.60, contrast: 0.40},
198
+ yarn: yarn
199
+ )
200
+
201
+ assert result[:main].key?(:skeins)
202
+ assert result[:contrast].key?(:skeins)
203
+ assert_instance_of Integer, result[:main][:skeins]
204
+ assert_instance_of Integer, result[:contrast][:skeins]
205
+ end
206
+
207
+ def test_omits_skeins_when_no_yarn
208
+ result = stranded_estimator.estimate(
209
+ width: 40.inches, height: 24.inches,
210
+ colors: {main: 0.60, contrast: 0.40}
211
+ )
212
+
213
+ refute result[:main].key?(:skeins)
214
+ refute result[:contrast].key?(:skeins)
215
+ end
216
+
217
+ # ----------------------------
218
+ # margin
219
+ # ----------------------------
220
+
221
+ def test_default_margin_applied
222
+ no_margin = intarsia_estimator.estimate(
223
+ width: 20.inches, height: 20.inches,
224
+ colors: {main: 1.0},
225
+ margin: 0.0
226
+ )
227
+
228
+ with_margin = intarsia_estimator.estimate(
229
+ width: 20.inches, height: 20.inches,
230
+ colors: {main: 1.0}
231
+ )
232
+
233
+ assert_in_delta(
234
+ no_margin[:main][:yardage].to(:yards).value * 1.10,
235
+ with_margin[:main][:yardage].to(:yards).value,
236
+ 0.01
237
+ )
238
+ end
239
+
240
+ # ----------------------------
241
+ # multi-color
242
+ # ----------------------------
243
+
244
+ def test_three_color_stranded
245
+ result = stranded_estimator.estimate(
246
+ width: 40.inches, height: 24.inches,
247
+ colors: {main: 0.50, contrast_a: 0.30, contrast_b: 0.20},
248
+ margin: 0.0
249
+ )
250
+
251
+ assert result.key?(:main)
252
+ assert result.key?(:contrast_a)
253
+ assert result.key?(:contrast_b)
254
+ assert result.key?(:total)
255
+
256
+ total = result[:main][:yardage].to(:yards).value +
257
+ result[:contrast_a][:yardage].to(:yards).value +
258
+ result[:contrast_b][:yardage].to(:yards).value
259
+
260
+ assert_in_delta total, result[:total][:yardage].to(:yards).value, 0.01
261
+ end
262
+
263
+ # ----------------------------
264
+ # realistic scenario
265
+ # ----------------------------
266
+
267
+ def test_fair_isle_yoke_estimate_is_reasonable
268
+ # A Fair Isle yoke ~20" wide, 12" tall in worsted weight
269
+ result = stranded_estimator.estimate(
270
+ width: 20.inches, height: 12.inches,
271
+ colors: {main: 0.60, contrast: 0.40},
272
+ yarn: yarn
273
+ )
274
+
275
+ main_yards = result[:main][:yardage].to(:yards).value
276
+ contrast_yards = result[:contrast][:yardage].to(:yards).value
277
+ total_yards = result[:total][:yardage].to(:yards).value
278
+
279
+ assert total_yards > 50, "total #{total_yards} seems too low"
280
+ assert total_yards < 500, "total #{total_yards} seems too high"
281
+ assert main_yards > contrast_yards, "main color should use more yarn"
282
+ end
283
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yarn_skein
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Meagan Waller
@@ -66,6 +66,7 @@ files:
66
66
  - Rakefile
67
67
  - lib/yarn_skein.rb
68
68
  - lib/yarn_skein/catalog.rb
69
+ - lib/yarn_skein/colorwork_estimator.rb
69
70
  - lib/yarn_skein/fiber_blend.rb
70
71
  - lib/yarn_skein/substitution.rb
71
72
  - lib/yarn_skein/version.rb
@@ -74,6 +75,7 @@ files:
74
75
  - lib/yarn_skein/yarn.rb
75
76
  - sig/yarn_skein.rbs
76
77
  - test/catalog_test.rb
78
+ - test/colorwork_estimator_test.rb
77
79
  - test/fiber_blend_test.rb
78
80
  - test/substitution_test.rb
79
81
  - test/test_helper.rb