texture-generator 0.3.0__py3-none-any.whl

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.
@@ -0,0 +1,603 @@
1
+ """Stochastic fibre networks: deposit many curved fibres and accumulate mass.
2
+
3
+ A sheet of paper is a *stack* of fibres, not a background with lines drawn on
4
+ it. Total fibre length per unit area is grammage / coarseness, so 80 g/m^2
5
+ office paper carries roughly 440 mm of fibre per mm^2 -- with a 30 um fibre
6
+ that is about 13 layers of coverage. Drawing a few hundred opaque segments on
7
+ an empty ground gives coverage well below 1, which is why sparse line marks
8
+ read as scratches rather than as felt: they are isolated, they are opaque,
9
+ and each one is several times too wide.
10
+
11
+ This module fixes all three at once:
12
+
13
+ * **Count from coverage.** :func:`count_for_coverage` turns a target coverage
14
+ into a fibre count, so the density is a physical quantity rather than a
15
+ magic number.
16
+ * **Sub-pixel width.** A fibre 30 um wide is 0.15--1.0 px across at any
17
+ sensible capture scale, so it must contribute *partial* coverage.
18
+ Accumulating bilinearly-splatted mass does that exactly; rasterising a
19
+ 1-px-wide opaque line cannot.
20
+ * **Curvature.** Fibre centrelines follow a 2-D worm-like chain -- heading
21
+ performs a random walk with variance ``2*ds/persistence`` per step -- plus
22
+ a Poisson process of sharp kinks. Measured curl indices put the persistence
23
+ length at roughly 0.7--2 fibre lengths, which bends a fibre visibly without
24
+ coiling it.
25
+
26
+ Everything is vectorised across fibres: one ``(F, S)`` array of headings, one
27
+ ``cumsum``, one ``bincount`` per bilinear corner. Coordinates wrap, so the
28
+ fields tile.
29
+
30
+ Three further facts shape what a *single* fibre looks like, and all three are
31
+ sub-pixel, which is what decides how they must be implemented:
32
+
33
+ * **External fibrillation is opacity, not geometry.** Refining peels microfibril
34
+ bundles off the outer wall; they are 50 nm - 1 um wide (mode 0.1-0.5 um), and
35
+ whole peeled S1 ribbons only reach 1-5 um. At 25.6 px/mm a pixel is 39 um and
36
+ even a 2000 px render of a 20 mm crop is 10 um per pixel, so *every fibril is
37
+ far narrower than one pixel at every size this library renders*. Drawing them
38
+ as polylines would put sub-pixel objects on the raster as pixel-scale marks --
39
+ the same category error that made early procedural paper read as scratches --
40
+ so they are a coverage contribution instead: :func:`deposit` splats a soft
41
+ anisotropic fringe just outside each fibre's own edge, which composites
42
+ through the same Beer-Lambert ``over`` as the fibre and comes out as a hazy
43
+ fibre edge and inter-fibre webbing. It is parameterised the way a fibre
44
+ analyser reports it, as a **fibrillation index** -- fibril projected area over
45
+ parent-fibre projected area.
46
+ * **A fibre end is not a point.** Two populations: a *native* tracheid end
47
+ tapers over its last 150-500 um to a blunt tip 30-60% of mid-fibre width and
48
+ stops there, and a *cut* end (refining; 20-50% of ends) does not taper at all
49
+ -- the S2 fails along its microfibril helix, so the break frays into a brush
50
+ of 3-10 ribbons reaching 20-100 um past it. The ribbons are 0.5-3 um wide, so
51
+ they are a fanned opacity plume rather than drawn strands, for the reason
52
+ above.
53
+ * **Projected width varies along a fibre**, because collapse is intermittent:
54
+ an uncollapsed softwood tracheid presents ~20-30 um and a collapsed one
55
+ ~25-45 um (a flattened ribbon is wider than the tube it was), and thick-walled
56
+ latewood resists collapse where thin-walled earlywood gives way. The variation
57
+ is therefore *correlated over hundreds of micrometres*, not white noise, which
58
+ is the visually load-bearing part.
59
+ """
60
+
61
+ from __future__ import annotations
62
+
63
+ import numpy as np
64
+
65
+ __all__ = [
66
+ "axial_von_mises",
67
+ "cluster_points",
68
+ "count_for_coverage",
69
+ "deposit",
70
+ ]
71
+
72
+ # Splatting more than this many points at once costs more in temporaries than
73
+ # the chunking overhead saves.
74
+ _CHUNK = 1 << 21
75
+
76
+ # Native end taper length as a fraction of fibre length, for callers that do not
77
+ # state one in pixels: 0.22 of a 1.2 mm fibre is ~260 um, mid-band of the
78
+ # 150-500 um a softwood tracheid tapers over.
79
+ # UNVERIFIED: the 150-500 um and the 30-60% tip below are domain knowledge --
80
+ # no source in the research behind this module reports them.
81
+ _TAPER_FRAC = 0.22
82
+ _TIP_FRAC = (0.30, 0.60)
83
+
84
+ # Microfibril angle of the wall layer a fibril was peeled from, in degrees to the
85
+ # fibre axis. **The layer angles are textbook** -- S1 is an outer crossed helix
86
+ # at 50-90 degrees and is removed first by refining, S2 is the bulk at 5-20
87
+ # degrees (20-50 in juvenile or earlywood) and is exposed as refining goes on.
88
+ # **The two-mode mixture and its weights are inference from those angles, not a
89
+ # measured distribution.**
90
+ _S1_DEG = (45.0, 75.0)
91
+ _S2_DEG = (10.0, 25.0)
92
+ _S1_WEIGHT = 0.3
93
+ # Once free and dried a fibril lies back along the fibre, so the angle that
94
+ # shows in a micrograph is smaller than the angle it left the wall at: the
95
+ # reported apparent range is 10-40 degrees, which this factor lands the mixture
96
+ # in. Also inference.
97
+ _LAY_BACK = 0.55
98
+
99
+ # Cut-end brush: ribbons per end, and samples along each ribbon. The brush is
100
+ # 3-10 ribbons; six is the middle of that, and four samples over a 20-100 um
101
+ # reach is finer than a pixel at any size rendered here.
102
+ _BRUSH_RIBBONS = 6
103
+ _BRUSH_SAMPLES = 4
104
+ # Projected area of one brush relative to its parent fibre, per unit of
105
+ # fibrillation index: six 60 um ribbons at 1.5 um wide is ~540 um^2 against a
106
+ # 1.15 mm x 25 um fibre's ~29,000 um^2, i.e. ~2% at the 0.035 fibrillation of a
107
+ # normal printing furnish. Tying it to the index rather than fixing it is the
108
+ # physical statement that refining frays ends and peels fibrils together.
109
+ #
110
+ # The brush comes *out of* the index rather than on top of it: a fibre analyser
111
+ # measures fibril area projecting from the fibre and does not care whether a
112
+ # given fibril is rooted on the wall or on a frayed end, so both populations
113
+ # share one budget and :func:`deposit` gives the wall fringe whatever the ends
114
+ # leave. Adding the brush on top instead -- which is what this used to do --
115
+ # meant ``fibrillation = 0.060`` actually deposited 9.49% of extra mass, so the
116
+ # key did not mean what its name and docstring said.
117
+ _BRUSH_AREA_PER_INDEX = 0.6
118
+
119
+ # Standard deviation of smoothstep-interpolated unit-variance knots. For
120
+ # h(u) = 3u^2 - 2u^3 the interpolant has variance 1 - 2E[h] + 2E[h^2] = 0.742857
121
+ # averaged over the cell, so dividing by this makes the profile unit-variance
122
+ # without a reduction pass over the array.
123
+ _SMOOTHSTEP_SD = 0.861892
124
+
125
+
126
+ def count_for_coverage(
127
+ shape: tuple[int, int],
128
+ coverage: float,
129
+ length_px: float,
130
+ width_px: float,
131
+ ) -> int:
132
+ """How many fibres of this size are needed to cover the canvas ``coverage`` times.
133
+
134
+ Coverage is the mean number of fibre layers over a point: area of canvas
135
+ times coverage, divided by the area of one fibre.
136
+ """
137
+ if float(coverage) <= 0.0:
138
+ return 0
139
+ h, w = int(shape[0]), int(shape[1])
140
+ per_fibre = max(float(length_px) * float(width_px), 1e-6)
141
+ return max(
142
+ 1,
143
+ round(h * w * float(coverage) / per_fibre),
144
+ )
145
+
146
+
147
+ def axial_von_mises(
148
+ rng: np.random.Generator, size: int, mu: float, kappa: float
149
+ ) -> np.ndarray:
150
+ """Sample ``size`` orientations from the axial distribution p(t) ~ exp(k cos 2(t-mu)).
151
+
152
+ Fibre direction is an axis, not an arrow: theta and theta+pi are the same
153
+ fibre. Sampling a von Mises in the doubled angle and halving it gives
154
+ exactly that density. ``kappa`` is small for paper -- measured fibre
155
+ orientation distributions have a peak-to-trough ratio of only 1.5--4,
156
+ i.e. kappa between 0.2 and 0.7. Large kappa produces parallel hatching,
157
+ which no paper looks like.
158
+ """
159
+ if kappa <= 1e-6:
160
+ return rng.uniform(0.0, np.pi, size=size).astype(np.float32)
161
+ doubled = rng.vonmises(0.0, float(kappa), size=size)
162
+ return (float(mu) + 0.5 * doubled).astype(np.float32)
163
+
164
+
165
+ def cluster_points(
166
+ shape: tuple[int, int],
167
+ rng: np.random.Generator,
168
+ count: int,
169
+ *,
170
+ clustered: float = 0.6,
171
+ parent_spacing_px: float = 60.0,
172
+ spread_px: float = 18.0,
173
+ aniso: float = 1.0,
174
+ angle: float = 0.0,
175
+ ) -> tuple[np.ndarray, np.ndarray]:
176
+ """Positions from a Matern cluster (Neyman-Scott) process mixed with uniform.
177
+
178
+ Fibres arrive at the wire in flocs, and flocculation is what formation
179
+ measures. Generating clustered *positions* makes formation emerge from
180
+ the fibre layer instead of being painted on top as an independent cloud,
181
+ so the two agree by construction.
182
+
183
+ ``clustered`` is the fraction of fibres drawn around parents; the rest are
184
+ uniform. Pure clustering looks blotchy, so keep it below ~0.75.
185
+ """
186
+ h, w = int(shape[0]), int(shape[1])
187
+ n = int(count)
188
+ n_clustered = round(n * float(np.clip(clustered, 0.0, 1.0)))
189
+ n_uniform = n - n_clustered
190
+
191
+ xs = [rng.uniform(0.0, w, size=n_uniform).astype(np.float32)]
192
+ ys = [rng.uniform(0.0, h, size=n_uniform).astype(np.float32)]
193
+
194
+ if n_clustered > 0:
195
+ n_parents = max(
196
+ 1,
197
+ round(h * w / max(parent_spacing_px**2, 1.0)),
198
+ )
199
+ px = rng.uniform(0.0, w, size=n_parents).astype(np.float32)
200
+ py = rng.uniform(0.0, h, size=n_parents).astype(np.float32)
201
+ which = rng.integers(0, n_parents, size=n_clustered)
202
+ # Flocs are sheared along the machine direction, so the offspring
203
+ # cloud is an ellipse rather than a disc.
204
+ ox = rng.normal(0.0, spread_px * float(aniso), size=n_clustered)
205
+ oy = rng.normal(0.0, spread_px, size=n_clustered)
206
+ ca, sa = np.cos(angle), np.sin(angle)
207
+ xs.append((px[which] + ox * ca - oy * sa).astype(np.float32))
208
+ ys.append((py[which] + ox * sa + oy * ca).astype(np.float32))
209
+
210
+ return (
211
+ np.concatenate(xs).astype(np.float32) % np.float32(w),
212
+ np.concatenate(ys).astype(np.float32) % np.float32(h),
213
+ )
214
+
215
+
216
+ def _splat(
217
+ shape: tuple[int, int], x: np.ndarray, y: np.ndarray, weight: np.ndarray
218
+ ) -> np.ndarray:
219
+ """Bilinearly accumulate ``weight`` at fractional positions, wrapping at the edges.
220
+
221
+ ``np.bincount`` is used rather than ``np.add.at``: both accumulate
222
+ duplicates correctly, but bincount is roughly an order of magnitude
223
+ faster, which matters at tens of thousands of fibres.
224
+ """
225
+ h, w = int(shape[0]), int(shape[1])
226
+ out = np.zeros(h * w, dtype=np.float64)
227
+ total = x.size
228
+ for start in range(0, total, _CHUNK):
229
+ stop = min(start + _CHUNK, total)
230
+ xc = np.mod(x[start:stop], w)
231
+ yc = np.mod(y[start:stop], h)
232
+ wt = weight[start:stop]
233
+ x0 = np.floor(xc).astype(np.int64)
234
+ y0 = np.floor(yc).astype(np.int64)
235
+ fx = (xc - x0).astype(np.float64)
236
+ fy = (yc - y0).astype(np.float64)
237
+ x0 %= w
238
+ y0 %= h
239
+ x1 = (x0 + 1) % w
240
+ y1 = (y0 + 1) % h
241
+ rows0 = y0 * w
242
+ rows1 = y1 * w
243
+ for idx, frac in (
244
+ (rows0 + x0, (1.0 - fx) * (1.0 - fy)),
245
+ (rows0 + x1, fx * (1.0 - fy)),
246
+ (rows1 + x0, (1.0 - fx) * fy),
247
+ (rows1 + x1, fx * fy),
248
+ ):
249
+ out += np.bincount(idx, weights=frac * wt, minlength=h * w)
250
+ return out.reshape(h, w).astype(np.float32)
251
+
252
+
253
+ def _correlated_profile(
254
+ rng: np.random.Generator, n: int, steps: int, corr_steps: float
255
+ ) -> np.ndarray:
256
+ """``(n, steps)`` zero-mean unit-variance noise, correlated along axis 1.
257
+
258
+ Along-fibre width variation is generated by intermittent collapse, so it is
259
+ correlated over a *collapse domain* -- hundreds of micrometres -- and white
260
+ noise at that amplitude would read as a beaded fibre instead of a fibre that
261
+ is fat in places. Independent knots every ``corr_steps`` samples, smoothstep
262
+ interpolated between them, give that correlation for one gather and two
263
+ multiplies per sample; an FFT along the step axis would cost several times
264
+ as much for a profile nothing measures the spectrum of.
265
+ """
266
+ corr = max(float(corr_steps), 1.0)
267
+ knots = int(np.ceil(steps / corr)) + 2
268
+ k = rng.standard_normal((n, knots)).astype(np.float32)
269
+ pos = np.arange(steps, dtype=np.float32) / np.float32(corr)
270
+ i0 = np.floor(pos).astype(np.int64)
271
+ u = pos - i0.astype(np.float32)
272
+ hu = (u * u * (3.0 - 2.0 * u))[None, :]
273
+ prof = k[:, i0] * (np.float32(1.0) - hu) + k[:, i0 + 1] * hu
274
+ return (prof / np.float32(_SMOOTHSTEP_SD)).astype(np.float32)
275
+
276
+
277
+ def _fibril_angles(rng: np.random.Generator, size: int) -> np.ndarray:
278
+ """Apparent divergence angles of external fibrils, in radians.
279
+
280
+ A peeled fibril leaves the wall at the microfibril angle of the layer it came
281
+ from, and those layer angles are textbook (see :data:`_S1_DEG`). The mixture
282
+ weights and the dried lay-back factor are inference from them rather than a
283
+ measured distribution -- flagged where they are defined.
284
+ """
285
+ s1 = rng.random(size) < _S1_WEIGHT
286
+ lo = np.where(s1, _S1_DEG[0], _S2_DEG[0])
287
+ hi = np.where(s1, _S1_DEG[1], _S2_DEG[1])
288
+ deg = lo + (hi - lo) * rng.random(size)
289
+ return np.deg2rad(deg * _LAY_BACK).astype(np.float32)
290
+
291
+
292
+ def deposit(
293
+ shape: tuple[int, int],
294
+ rng: np.random.Generator,
295
+ *,
296
+ count: int,
297
+ length_px: float,
298
+ width_px: float,
299
+ length_sigma: float = 0.6,
300
+ theta_mu: float = 0.0,
301
+ kappa: float = 0.3,
302
+ persistence: float = 1.2,
303
+ kinks_per_length: float = 3.0,
304
+ kink_deg: float = 25.0,
305
+ step_px: float = 2.5,
306
+ max_steps: int = 96,
307
+ positions: tuple[np.ndarray, np.ndarray] | None = None,
308
+ mass_jitter: float = 0.35,
309
+ width_cv: float = 0.0,
310
+ width_corr_px: float = 0.0,
311
+ taper_px: float | None = None,
312
+ cut_ends: float = 0.0,
313
+ fibrillation: float = 0.0,
314
+ fringe_px: float = 0.0,
315
+ brush_px: float = 0.0,
316
+ ) -> np.ndarray:
317
+ """Deposit ``count`` curved fibres and return the accumulated mass field.
318
+
319
+ The returned field is in coverage units -- its mean is the mean number of
320
+ fibre layers over a pixel. The end taper removes a fixed fraction of each
321
+ fibre's mass, so a count from :func:`count_for_coverage` lands somewhat below
322
+ its nominal coverage rather than exactly on it -- how far below depends on
323
+ ``taper_px`` and ``cut_ends``, since a cut end does not taper at all.
324
+
325
+ Args:
326
+ shape: ``(H, W)``.
327
+ rng: seeded generator.
328
+ count: number of fibres; see :func:`count_for_coverage`.
329
+ length_px: median fibre length in pixels (lognormal median).
330
+ width_px: median fibre width in pixels. Values below 1 are the normal
331
+ case and are handled exactly -- the fibre contributes partial
332
+ coverage rather than a thin opaque line.
333
+ length_sigma: lognormal sigma of length. 0.6 matches measured pulp
334
+ length distributions; 0.7+ for mixed recycled furnish.
335
+ theta_mu: machine direction in radians.
336
+ kappa: axial von Mises concentration; see :func:`axial_von_mises`.
337
+ persistence: persistence length as a multiple of fibre length. 0.7--2
338
+ spans never-dried chemical pulp through recycled.
339
+ kinks_per_length: expected sharp kinks per fibre length.
340
+ kink_deg: standard deviation of the kink angle in degrees.
341
+ step_px: arclength per polyline step. **Must be <= 1 px**: samples are
342
+ splatted bilinearly, so a footprint spans one pixel either side of
343
+ the sample and consecutive samples stop overlapping above that.
344
+ At 2.5 px a "fibre" is a string of disconnected beads. Values
345
+ above 1.0 are clamped.
346
+ max_steps: cap on steps per fibre; lengths are clipped to match.
347
+ positions: optional ``(x, y)`` start points, e.g. from
348
+ :func:`cluster_points`. Uniform if omitted.
349
+ mass_jitter: lognormal sigma on per-fibre mass (coarseness varies).
350
+ width_cv: coefficient of variation of projected width *along* one fibre,
351
+ from intermittent collapse. 0.15-0.25 is the plausible range and
352
+ **is unverified**: analysers report a *population* width
353
+ distribution, which is a different quantity. 0 disables it.
354
+ width_corr_px: correlation length of that profile -- the size of a
355
+ collapse domain, 200-500 um, also unverified. Both must be set for
356
+ the profile to apply; white-noise width variation is wrong.
357
+ taper_px: length of the *native* end taper. Defaults to
358
+ :data:`_TAPER_FRAC` of the fibre's own length. The taper runs to a
359
+ blunt tip of 30-60% of mid-fibre width, **not** to zero: a softwood
360
+ tracheid end is tapered, closed and bluntly pointed.
361
+ cut_ends: fraction of ends that are refining cuts rather than native
362
+ tips, 0.2-0.5 in a refined furnish. A cut end does not taper, and
363
+ frays into a brush if ``brush_px`` is set.
364
+ fibrillation: fibrillation index -- fibril projected area over parent
365
+ fibre projected area, so this fraction of each fibre's mass is
366
+ splatted again as external fibril. 0.01 unrefined sack kraft, 0.035 a
367
+ normal printing furnish, 0.06 heavily worked recycled. It is the
368
+ **total** budget: the cut-end brushes take their share of it (see
369
+ :data:`_BRUSH_AREA_PER_INDEX`) and the wall fringe gets the rest, so
370
+ the extra mass deposited is the index and not more.
371
+ fringe_px: how far a fibril reaches from the wall, i.e. the fringe band
372
+ width, 2-8 um. Needed as well as ``fibrillation`` for any fringe.
373
+ brush_px: how far a cut end's brush reaches past the break, 20-100 um.
374
+ Needs ``cut_ends`` and ``fibrillation`` as well.
375
+ """
376
+ h, w = int(shape[0]), int(shape[1])
377
+ n = int(count)
378
+ if n <= 0:
379
+ return np.zeros((h, w), dtype=np.float32)
380
+
381
+ # Bilinear splatting spreads a sample one pixel either side, so samples
382
+ # must be at most a pixel apart or the fibre breaks into beads.
383
+ step = float(np.clip(step_px, 0.25, 1.0))
384
+ lengths = rng.lognormal(np.log(max(float(length_px), 1e-3)), float(length_sigma), n)
385
+ lengths = np.clip(lengths, step, step * max_steps).astype(np.float32)
386
+
387
+ steps = int(np.ceil(float(lengths.max()) / step))
388
+ steps = int(np.clip(steps, 2, max_steps))
389
+
390
+ if positions is None:
391
+ x0 = rng.uniform(0.0, w, size=n).astype(np.float32)
392
+ y0 = rng.uniform(0.0, h, size=n).astype(np.float32)
393
+ else:
394
+ x0 = np.asarray(positions[0], dtype=np.float32)[:n]
395
+ y0 = np.asarray(positions[1], dtype=np.float32)[:n]
396
+ if x0.size < n or y0.size < n:
397
+ raise ValueError(
398
+ f"positions supply {min(x0.size, y0.size)} points for {n} fibres"
399
+ )
400
+
401
+ theta0 = axial_von_mises(rng, n, theta_mu, kappa)
402
+
403
+ # Worm-like chain: heading variance per step is 2*ds/lp, so the
404
+ # tangent-tangent correlation decays as exp(-s/lp).
405
+ lp = np.maximum(lengths * np.float32(max(persistence, 1e-3)), step)
406
+ turn_sd = np.sqrt(2.0 * step / lp).astype(np.float32)
407
+ dtheta = rng.standard_normal((n, steps)).astype(np.float32) * turn_sd[:, None]
408
+
409
+ # Sharp kinks on top of the smooth bend: this is what separates a paper
410
+ # fibre from a hair.
411
+ if kinks_per_length > 0.0:
412
+ p_kink = np.clip(
413
+ float(kinks_per_length) * step / np.maximum(lengths, 1e-3), 0.0, 1.0
414
+ )
415
+ hits = rng.random((n, steps)).astype(np.float32) < p_kink[:, None]
416
+ kick = np.abs(
417
+ rng.standard_normal((n, steps)).astype(np.float32)
418
+ * np.float32(np.deg2rad(kink_deg))
419
+ )
420
+ sign = np.where(rng.random((n, steps)) < 0.5, -1.0, 1.0).astype(np.float32)
421
+ dtheta = dtheta + np.where(hits, kick * sign, 0.0).astype(np.float32)
422
+
423
+ theta = theta0[:, None] + np.cumsum(dtheta, axis=1)
424
+ xs = x0[:, None] + np.cumsum(np.cos(theta) * np.float32(step), axis=1)
425
+ ys = y0[:, None] + np.cumsum(np.sin(theta) * np.float32(step), axis=1)
426
+
427
+ # Mask samples past each fibre's own length, and taper the last step so
428
+ # length is honoured to sub-step precision.
429
+ arclen = (np.arange(1, steps + 1, dtype=np.float32) * np.float32(step))[None, :]
430
+ live = np.clip((lengths[:, None] - arclen) / np.float32(step) + 1.0, 0.0, 1.0)
431
+
432
+ # --- Ends: two populations -------------------------------------------
433
+ # A native tracheid end tapers over its last 150-500 um to a blunt tip
434
+ # 30-60% of mid-fibre width and stops; it does *not* run out to zero, which
435
+ # is what a symmetric smoothstep over a sixth of the fibre used to do here
436
+ # and is why every fibre used to fade out at both ends like a brush stroke.
437
+ # A cut end is a transverse fracture, so it keeps full width right up to the
438
+ # break -- and gets a brush instead, further down.
439
+ taper_len = max(
440
+ float(taper_px) if taper_px else _TAPER_FRAC * float(length_px), step
441
+ )
442
+ tip = rng.uniform(_TIP_FRAC[0], _TIP_FRAC[1], size=n).astype(np.float32)[:, None]
443
+ cut = rng.random((n, 2)) < float(cut_ends)
444
+
445
+ def _blunt(d: np.ndarray, is_cut: np.ndarray) -> np.ndarray:
446
+ s = np.clip(d / np.float32(taper_len), 0.0, 1.0)
447
+ s = s * s * (3.0 - 2.0 * s)
448
+ return np.where(is_cut[:, None], np.float32(1.0), tip + (1.0 - tip) * s)
449
+
450
+ taper = np.minimum(
451
+ _blunt(arclen, cut[:, 0]),
452
+ _blunt(lengths[:, None] - arclen, cut[:, 1]),
453
+ ).astype(np.float32)
454
+
455
+ coarseness = rng.lognormal(0.0, float(mass_jitter), n).astype(np.float32)
456
+ # Mass per sample = width * arclength, so the pixel-integrated coverage
457
+ # of one fibre is width_px * length_px regardless of step size.
458
+ weight = live * taper * (coarseness[:, None] * np.float32(float(width_px) * step))
459
+
460
+ # --- Along-fibre width variation --------------------------------------
461
+ # Collapse is intermittent along a fibre, so projected width is too: an
462
+ # uncollapsed tracheid presents ~20-30 um and a collapsed one ~25-45 um,
463
+ # alternating over collapse domains of a few hundred micrometres. Width
464
+ # enters as mass (the fibre is sub-pixel, so wider means denser, not
465
+ # broader) and as footprint (above a pixel it really is broader).
466
+ wmul = None
467
+ if width_cv > 1e-6 and width_corr_px > 1e-6:
468
+ wmul = np.clip(
469
+ 1.0
470
+ + np.float32(width_cv)
471
+ * _correlated_profile(rng, n, steps, float(width_corr_px) / step),
472
+ 0.25,
473
+ None,
474
+ ).astype(np.float32)
475
+ weight = weight * wmul
476
+
477
+ # Below a pixel, a fibre is a sub-unit-coverage mark and the bilinear
478
+ # footprint is already wider than it is: scale opacity, keep the footprint.
479
+ # Above a pixel it is a real ribbon and must actually be that wide, so lay
480
+ # down parallel strands across the local normal, splitting the mass
481
+ # between them. Without this, a 3 px fibre at 2000 px renders as a 1 px
482
+ # line at triple opacity -- narrow, hard-edged, and exactly the scribed
483
+ # look the rewrite exists to remove.
484
+ strands = int(np.clip(round(float(width_px)), 1, 4))
485
+ weight = weight / np.float32(strands)
486
+
487
+ # Every fibre is walked out to the longest fibre's step count and the
488
+ # surplus masked off, so roughly half the samples carry no weight. Drop
489
+ # them before splatting rather than paying four bincounts for zeros.
490
+ keep = (weight > 1e-6).ravel()
491
+ xs_f = xs.ravel()[keep]
492
+ ys_f = ys.ravel()[keep]
493
+ wt_f = weight.ravel()[keep].astype(np.float64)
494
+ tx_f = np.cos(theta).ravel()[keep]
495
+ ty_f = np.sin(theta).ravel()[keep]
496
+ nx_f = -ty_f
497
+ ny_f = tx_f
498
+ # Where the fibre is locally wider, its footprint is wider too.
499
+ wm_f = np.float32(1.0) if wmul is None else wmul.ravel()[keep]
500
+
501
+ mass = np.zeros((h, w), dtype=np.float32)
502
+ for k in range(strands):
503
+ offset = np.float32(
504
+ (k - (strands - 1) * 0.5) * (float(width_px) / max(strands, 1))
505
+ )
506
+ mass += _splat(
507
+ (h, w), xs_f + offset * wm_f * nx_f, ys_f + offset * wm_f * ny_f, wt_f
508
+ )
509
+
510
+ # --- External fibrillation: a sub-pixel opacity fringe ------------------
511
+ # Fibrils are 50 nm - 1 um wide, so at 10-39 um per pixel they can only ever
512
+ # be *coverage just outside the fibre wall*, never geometry. Splatting
513
+ # ``fibrillation`` of each sample's mass one fibril-reach out along the local
514
+ # normal puts exactly that there: bilinear splatting spreads it into the
515
+ # neighbouring pixel, which is the softened hazy edge and the inter-fibre
516
+ # webbing that fibrillation actually looks like at these scales. One splat,
517
+ # with the side drawn per sample rather than one splat per side -- there are
518
+ # hundreds of samples per pixel, so both sides fill in either way and the
519
+ # deposition pass is the dominant cost of the whole generator.
520
+ # The index is the *total* external-fibril area, and the cut-end brushes below
521
+ # claim a share of it: 2 ends x P(cut) x _BRUSH_AREA_PER_INDEX per unit index.
522
+ # The wall fringe gets the remainder, so the two together deposit exactly
523
+ # ``fibrillation`` of each fibre's mass however hard the furnish was refined.
524
+ brush_share = min(
525
+ 2.0 * float(np.clip(cut_ends, 0.0, 1.0)) * _BRUSH_AREA_PER_INDEX, 1.0
526
+ )
527
+ fringe_index = float(fibrillation) * (1.0 - brush_share)
528
+ if fringe_index > 1e-6 and fringe_px > 1e-6 and xs_f.size:
529
+ m = xs_f.size
530
+ phi = _fibril_angles(rng, m)
531
+ # Position along the fibril, so the fringe is a band rather than a line.
532
+ u = rng.random(m).astype(np.float32) * np.float32(fringe_px)
533
+ side = np.where(rng.random(m) < 0.5, np.float32(-1.0), np.float32(1.0))
534
+ # A fibril rooted at the wall reaches sideways by its length times the
535
+ # sine of its divergence angle and lies back along the fibre by the
536
+ # cosine -- so the S1/S2 angle mixture is what sets how far the fringe
537
+ # spreads, and a low-angle S2 fibril hugs the fibre as it should.
538
+ lat = np.float32(0.5 * float(width_px)) * wm_f + side * u * np.sin(phi)
539
+ lon = u * np.cos(phi)
540
+ mass += _splat(
541
+ (h, w),
542
+ xs_f + lat * nx_f + lon * tx_f,
543
+ ys_f + lat * ny_f + lon * ty_f,
544
+ wt_f * fringe_index,
545
+ )
546
+
547
+ # --- Cut-end brushes ---------------------------------------------------
548
+ # The S2 fails along its microfibril helix, so a refining cut frays into a
549
+ # brush of 3-10 ribbons reaching 20-100 um past the break. At 0.5-3 um wide
550
+ # those are sub-pixel too, so the brush is a fanned plume of coverage; the
551
+ # ribbons are coherent along their length (they are ribbons, not dust), which
552
+ # is why this walks each one out rather than scattering points in the fan.
553
+ if fibrillation > 1e-6 and brush_px > 1e-6 and cut_ends > 1e-6 and cut.any():
554
+ last = np.clip(np.ceil(lengths / step).astype(np.int64) - 1, 0, steps - 1)
555
+ # Per-fibre mass, so a heavy fibre gets a heavy brush.
556
+ fibre_mass = weight.sum(axis=1)
557
+ px, py, pth, pw = [], [], [], []
558
+ for end, (idx, flip) in enumerate(
559
+ ((last, 0.0), (np.zeros(n, np.int64), np.pi))
560
+ ):
561
+ sel = np.nonzero(cut[:, end])[0]
562
+ if not sel.size:
563
+ continue
564
+ px.append(xs[sel, idx[sel]])
565
+ py.append(ys[sel, idx[sel]])
566
+ pth.append(theta[sel, idx[sel]] + np.float32(flip))
567
+ pw.append(fibre_mass[sel])
568
+ ex = np.concatenate(px)
569
+ ey = np.concatenate(py)
570
+ eth = np.concatenate(pth)
571
+ ew = np.concatenate(pw).astype(np.float64)
572
+ e = ex.size
573
+ # One splayed direction per ribbon, from the same microfibril-angle
574
+ # mixture the fringe uses -- the brush and the fringe are the same
575
+ # mechanism at the end of the fibre rather than along it.
576
+ fan = _fibril_angles(rng, e * _BRUSH_RIBBONS).reshape(e, _BRUSH_RIBBONS)
577
+ fan = fan * np.where(
578
+ rng.random((e, _BRUSH_RIBBONS)) < 0.5, np.float32(-1.0), np.float32(1.0)
579
+ )
580
+ ang = eth[:, None] + fan
581
+ # Ribbon lengths vary, and mass thins out towards the tips.
582
+ reach = np.float32(brush_px) * (
583
+ 0.35 + 0.65 * rng.random((e, _BRUSH_RIBBONS)).astype(np.float32)
584
+ )
585
+ r = (np.arange(1, _BRUSH_SAMPLES + 1, dtype=np.float32) / _BRUSH_SAMPLES)[
586
+ None, None, :
587
+ ]
588
+ rr = reach[..., None] * r
589
+ bx = ex[:, None, None] + rr * np.cos(ang)[..., None]
590
+ by = ey[:, None, None] + rr * np.sin(ang)[..., None]
591
+ # Total brush mass per end is a fixed share of the parent fibre, scaled
592
+ # by the fibrillation index: refining frays ends and peels fibrils
593
+ # together, so one parameter drives both.
594
+ share = float(_BRUSH_AREA_PER_INDEX * fibrillation) / (
595
+ _BRUSH_RIBBONS * _BRUSH_SAMPLES
596
+ )
597
+ # 1.5 - 0.8r averages to 1 over the four samples, so the share above is
598
+ # the whole brush's mass and the taper only redistributes it.
599
+ bw = np.broadcast_to((ew * share)[:, None, None], bx.shape) * np.broadcast_to(
600
+ 1.5 - 0.8 * r, bx.shape
601
+ )
602
+ mass += _splat((h, w), bx.ravel(), by.ravel(), bw.ravel())
603
+ return mass