unitytk 0.0.10__tar.gz → 0.0.11__tar.gz

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 (57) hide show
  1. {unitytk-0.0.10/unitytk.egg-info → unitytk-0.0.11}/PKG-INFO +1 -1
  2. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_render_effects_e2e.py +13 -6
  3. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_shadow_plane_runtime_integration.py +115 -268
  4. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/__init__.py +1 -1
  5. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/ShadowPlane.shader +35 -42
  6. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/ShadowPlaneController.cs +57 -65
  7. unitytk-0.0.11/unitytk/templates/ShadowPlaneHorizon.hlsl +344 -0
  8. {unitytk-0.0.10 → unitytk-0.0.11/unitytk.egg-info}/PKG-INFO +1 -1
  9. unitytk-0.0.10/unitytk/templates/ShadowPlaneHorizon.hlsl +0 -471
  10. {unitytk-0.0.10 → unitytk-0.0.11}/LICENSE +0 -0
  11. {unitytk-0.0.10 → unitytk-0.0.11}/MANIFEST.in +0 -0
  12. {unitytk-0.0.10 → unitytk-0.0.11}/docs/README.md +0 -0
  13. {unitytk-0.0.10 → unitytk-0.0.11}/pyproject.toml +0 -0
  14. {unitytk-0.0.10 → unitytk-0.0.11}/setup.cfg +0 -0
  15. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_asset_delivery.py +0 -0
  16. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_audio_events_integration.py +0 -0
  17. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_base_test.py +0 -0
  18. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_bool_to_fade_curve.py +0 -0
  19. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_clip_name_contract_integration.py +0 -0
  20. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_controller_runtime_integration.py +0 -0
  21. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_deploy_templates.py +0 -0
  22. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_emissive_groups_integration.py +0 -0
  23. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_fade_window_preservation.py +0 -0
  24. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_keyed_visibility.py +0 -0
  25. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_launcher.py +0 -0
  26. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_lightmap_metadata_integration.py +0 -0
  27. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_multi_section_audio_integration.py +0 -0
  28. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_render_opacity_controller.py +0 -0
  29. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_render_opacity_e2e.py +0 -0
  30. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_render_opacity_integration.py +0 -0
  31. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_run_tests.py +0 -0
  32. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_scene_builder.py +0 -0
  33. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_shadow_plane_integration.py +0 -0
  34. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_shared_material_standalone.py +0 -0
  35. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_shot_metadata_integration.py +0 -0
  36. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_shots_audio_sidebyside_integration.py +0 -0
  37. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_standalone_enduser.py +0 -0
  38. {unitytk-0.0.10 → unitytk-0.0.11}/test/test_temp_projects.py +0 -0
  39. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/asset_delivery.py +0 -0
  40. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/launcher.py +0 -0
  41. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/scene_builder.py +0 -0
  42. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/template_deployer.py +0 -0
  43. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/AudioEventController.cs +0 -0
  44. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/EmissiveGroupController.cs +0 -0
  45. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/EmissiveGroups.hlsl +0 -0
  46. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/EmissiveGroupsLit.shader +0 -0
  47. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/LightmapMetadataController.cs +0 -0
  48. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/M3trik.Unitytk.asmdef +0 -0
  49. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/README.md +0 -0
  50. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/RenderEffectsController.cs +0 -0
  51. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/ShotMetadataController.cs +0 -0
  52. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/UnitytkSettings.cs +0 -0
  53. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk/templates/__init__.py +0 -0
  54. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk.egg-info/SOURCES.txt +0 -0
  55. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk.egg-info/dependency_links.txt +0 -0
  56. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk.egg-info/requires.txt +0 -0
  57. {unitytk-0.0.10 → unitytk-0.0.11}/unitytk.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitytk
3
- Version: 0.0.10
3
+ Version: 0.0.11
4
4
  Summary: A modular Python toolkit for Unity interaction.
5
5
  Author-email: Ryan Simpson <m3trik@outlook.com>
6
6
  License: MIT
@@ -79,8 +79,12 @@ cube = cmds.polyCube(name="glow")[0]
79
79
 
80
80
  # Opacity: a fade-in over 1..20 (mirrors visibility as before).
81
81
  RenderEffects.key_fade([cube], start=1, end=20, direction="in")
82
- # Highlight: 60-frame period, bright 50%, ramps 25% each -> bright hold 1..16,
83
- # ramp to 0 at 31, dim hold to 46, ramp to 1 at 61, ... cut at 120.
82
+ # Highlight: 60-frame period, bright 50%, ramps 25% each. The train is
83
+ # BRACKETED by dim keys at start and end (mayatk 2026-09-05: Maya holds a
84
+ # curve's first value back to the start of time, so a pulse that merely began
85
+ # bright glowed for the whole timeline before it), and the default lead-in is
86
+ # the cycle's own 15-frame ramp -> dim at 1, bright 16..31, dim 46..61,
87
+ # bright 76..91, back to dim at 120.
84
88
  RenderEffects.key_pulse(
85
89
  [cube], start=1, end=120, period=60, bright_fraction=0.5,
86
90
  ramp_fraction=0.25, color=(0.2, 0.5, 1.0),
@@ -391,16 +395,19 @@ class TestRenderEffectsE2E(unittest.TestCase):
391
395
  got = [float(c) for c in unity["highlight_colors"][0].split(",")]
392
396
  for value, want in zip(got, (0.2, 0.5, 1.0)):
393
397
  self.assertAlmostEqual(value, want, delta=1 / 255)
394
- # (e) The pulse shape survived: bright holds 1, dim holds 0.
398
+ # (e) The pulse shape survived: bright holds 1, dim holds 0, and the
399
+ # DIM BRACKET at `start` is there (mayatk 2026-09-05 -- the ramp opens
400
+ # at 0, not 1). Measured against the authored curve in mayapy for these
401
+ # exact fixture params: 1->0, 16->1, 31->1, 46->0, 61->0, 76->1, 91->1.
395
402
  samples = {
396
403
  int(s.split("|")[0]): float(s.split("|")[1])
397
404
  for s in unity["highlight_samples"]
398
405
  }
399
- self.assertAlmostEqual(samples[1], 1.0, places=2)
406
+ self.assertAlmostEqual(samples[1], 0.0, places=2)
400
407
  self.assertAlmostEqual(samples[16], 1.0, places=2)
401
- self.assertAlmostEqual(samples[31], 0.0, places=2)
408
+ self.assertAlmostEqual(samples[31], 1.0, places=2)
402
409
  self.assertAlmostEqual(samples[46], 0.0, places=2)
403
- self.assertAlmostEqual(samples[61], 1.0, places=2)
410
+ self.assertAlmostEqual(samples[61], 0.0, places=2)
404
411
  # (f) Emission keyword on the highlighted material.
405
412
  self.assertTrue(unity["emission_keyword"], "no material inspected")
406
413
  self.assertTrue(
@@ -8,7 +8,7 @@ Unity and no DCC:
8
8
 
9
9
  1. Create a Unity project, deploy the real package (controller + shader)
10
10
  and copy two generated fixtures into Assets: a stripe silhouette
11
- (``Fixture_shadow.png``) and an analytic two-layer horizon map
11
+ (``Fixture_shadow.png``) and a baked height-field map
12
12
  (``Fixture_horizon.png``: a grounded pole and a floating disc, baked
13
13
  in numpy per the contract's encoding).
14
14
  2. An editor script builds a unit-quad plane on ``m3trik/ShadowPlane`` with
@@ -124,46 +124,33 @@ class StripeSilhouette:
124
124
 
125
125
 
126
126
  class HorizonFixture:
127
- """An analytic two-layer horizon map and the numpy oracle that samples it.
128
-
129
- Layer 0 (grounded, tile ``k``): two thin vertical poles — the first at
130
- local ``(0.5, 0.3)``, off-centre on purpose so a flipped bearing axis or
131
- a swapped frame vector moves its shadow and fails the pixel comparison;
132
- the second shares a bin with it from many texels, so the map carries two
133
- RUNS of set sub-bins there and the shader's run >= 2 (``G``) path is
134
- exercised. Layer 1 (floating, tile ``bins + k``): a horizontal disc
135
- hovering between two heights.
136
-
137
- Encoding per the contract: ``B``/``A`` a 16-bit occupancy mask over the
138
- bin's 16 sub-bins (``B`` bit ``i`` = sub-bin ``i``, ``A`` bit ``i`` =
139
- sub-bin ``8 + i``; all zero = empty); ``R``/``G`` cotangents, ``value =
140
- cot(angle) / max_stretch`` clamped to ``[0, 1]`` (0 = zenith). Floating:
141
- ``R = cot(lo)``, ``G = cot(hi)``. Grounded: ``R = cot(hi)`` of the FIRST
142
- run of set sub-bins, ``G = cot(hi)`` of the later runs (0 with one run);
143
- ``lo`` is the cap.
127
+ """Two poles and a floating slab, baked by pythontk's own bake into the
128
+ height-field map the contract ships, and the oracle that samples it.
129
+
130
+ The first pole stands at local ``(0.5, 0.3)``, off-centre on purpose so a
131
+ flipped bearing axis or a swapped frame vector moves its shadow and fails
132
+ the pixel comparison; the second stands beside the first's light ray. The
133
+ slab hovers between two heights over open ground, so a ray under it must
134
+ pass and one through it must block -- the span a plain height field
135
+ cannot hold.
136
+
137
+ There was an analytic map here, and before it a numpy transcription of
138
+ the HLSL; both pinned the shader to something other than the one oracle
139
+ every consumer answers to. The map is now the real bake, and ``alpha`` is
140
+ ``HeightFieldMap.alpha`` decoding the very bytes the PNG holds (the map's
141
+ PNG round trip is exact).
144
142
  """
145
143
 
146
- BINS = 6
147
- LAYERS = 2
148
- SUB_BINS = 16
149
- TILE_W = 64
150
- TILE_H = 32
151
- R_MIN = 0.35
152
- R_MAX = 3.0
153
- #: The cot scale the map is BAKED with — deliberately NOT the record's
154
- #: top-level ``max_stretch`` (the plane's live, keyable placement cap,
155
- #: which an artist can retune after a bake). Decoding with the wrong one
156
- #: scales every shadow length by the ratio, so the two differing here is
157
- #: what makes the pixel comparison able to tell them apart.
158
- MAX_STRETCH = 4.0
159
- #: Grounded poles: (base (x, z), radius, height). The first is the one
160
- #: the centroid check isolates; the second sits beside the first's light
161
- #: ray, so from texels behind it the first pole holds a lower sub-bin of
162
- #: the same bin with a gap between — two runs.
144
+ SIZE = 64
145
+ SPANS = 2
146
+ #: One fixed footprint for every variant (a pole alone, no slab), so the
147
+ #: texture layout and the frame never change between them.
148
+ BOUNDS = (-0.5, 2.5, -0.5, 2.0)
149
+ #: Grounded poles: (base (x, z), half-width, height) -- square posts.
163
150
  POLES = (((0.5, 0.3), 0.06, 1.2), ((0.214, 1.028), 0.05, 0.8))
164
151
  POLE = POLES[0][0]
165
- #: The floating disc: centre (x, z), radius, underside and top heights —
166
- #: placed so its shadow clears the first pole's shadow line.
152
+ #: The floating slab: centre (x, z), half-width, underside and top heights
153
+ #: -- placed so its shadow clears the first pole's shadow line.
167
154
  DISC = (1.5, 0.9)
168
155
  DISC_RADIUS = 0.5
169
156
  DISC_LO = 0.7
@@ -172,178 +159,73 @@ class HorizonFixture:
172
159
  FRAME_A = (1.0, 0.0, 0.0)
173
160
  FRAME_B = (0.0, 0.0, 1.0)
174
161
 
175
- @classmethod
176
- def cols(cls) -> int:
177
- return int(math.ceil(math.sqrt(cls.LAYERS * cls.BINS)))
178
-
179
- @classmethod
180
- def rows(cls) -> int:
181
- return int(math.ceil(cls.LAYERS * cls.BINS / cls.cols()))
182
-
183
- # ---------------------------------------------------------------- bake
184
- @classmethod
185
- def _mask(cls, phi_lo, phi_hi, k, step):
186
- """Bin ``k``'s 16-bit sub-bin occupancy of the (possibly wrapping)
187
- interval ``[phi_lo, phi_hi]`` (arrays): a sub-bin is set when the
188
- interval overlaps it."""
189
- mask = np.zeros(phi_lo.shape, dtype=np.int64)
190
- sub = step / cls.SUB_BINS
191
- for j in range(cls.SUB_BINS):
192
- s0 = k * step + j * sub
193
- s1 = s0 + sub
194
- hit = np.zeros(phi_lo.shape, dtype=bool)
195
- for shift in (-TWO_PI, 0.0, TWO_PI):
196
- hit |= (
197
- np.minimum(s1, phi_hi + shift) - np.maximum(s0, phi_lo + shift)
198
- ) > 1e-12
199
- mask |= np.where(hit, 1 << j, 0)
200
- return mask
201
-
202
- @classmethod
203
- def _first_bit(cls, mask):
204
- """Lowest set bit per texel (-1 when empty)."""
205
- out = np.full(mask.shape, -1, dtype=int)
206
- for j in range(cls.SUB_BINS - 1, -1, -1):
207
- out = np.where(((mask >> j) & 1) == 1, j, out)
208
- return out
209
-
210
- @classmethod
211
- def _run_at(cls, mask, bit):
212
- """Number of 0->1 transitions in ``mask`` up to ``bit`` (inclusive) —
213
- the run index of that bit (0 where ``bit`` < 0)."""
214
- count = np.zeros(mask.shape, dtype=int)
215
- prev = np.zeros(mask.shape, dtype=int)
216
- for j in range(cls.SUB_BINS):
217
- b = (mask >> j) & 1
218
- count += ((b == 1) & (prev == 0) & (j <= bit)).astype(int)
219
- prev = b
220
- return count
162
+ @staticmethod
163
+ def _box(x0, x1, y0, y1, z0, z1):
164
+ pts = np.array(
165
+ [[x, y, z] for x in (x0, x1) for y in (y0, y1) for z in (z0, z1)],
166
+ dtype=float,
167
+ )
168
+ quads = [
169
+ (0, 1, 3, 2),
170
+ (4, 6, 7, 5),
171
+ (0, 4, 5, 1),
172
+ (2, 3, 7, 6),
173
+ (0, 2, 6, 4),
174
+ (1, 5, 7, 3),
175
+ ]
176
+ tris = []
177
+ for a, b, c, d in quads:
178
+ tris += [(a, b, c), (a, c, d)]
179
+ return pts, np.array(tris, dtype=np.int64)
221
180
 
222
181
  @classmethod
223
- def bake(cls, poles=None, disc=True) -> np.ndarray:
224
- """The map as an ``(rows*TILE_H, cols*TILE_W, 4)`` uint8 array,
225
- row 0 at the top (PNG order). ``poles`` overrides the grounded set
226
- and ``disc=False`` omits the floating layer (the centroid check
227
- bakes the first pole alone)."""
182
+ def meshes(cls, poles=None, disc=True):
228
183
  poles = cls.POLES if poles is None else poles
229
- ms = cls.MAX_STRETCH
230
- cols, rows = cls.cols(), cls.rows()
231
- img = np.zeros((rows * cls.TILE_H, cols * cls.TILE_W, 4), dtype=np.uint8)
232
- step = TWO_PI / cls.BINS
233
- theta = (np.arange(cls.TILE_W) + 0.5) * TWO_PI / cls.TILE_W # (W,)
234
- v = (np.arange(cls.TILE_H) + 0.5) / cls.TILE_H # (H,) top = r_min
235
- r = cls.R_MIN * (cls.R_MAX / cls.R_MIN) ** v # (H,)
236
- px = r[:, None] * np.cos(theta)[None, :] # along frame a
237
- pz = r[:, None] * np.sin(theta)[None, :] # along frame b
238
-
239
- def q(a):
240
- """Quantise a [0, 1] channel the way the contract's encoding does."""
241
- return np.clip(np.round(np.clip(a, 0.0, 1.0) * 255.0), 0, 255).astype(
242
- np.uint8
184
+ parts = [
185
+ cls._box(bx - r, bx + r, 0.0, h, bz - r, bz + r) for (bx, bz), r, h in poles
186
+ ]
187
+ if disc:
188
+ cx, cz = cls.DISC
189
+ r = cls.DISC_RADIUS
190
+ parts.append(
191
+ cls._box(cx - r, cx + r, cls.DISC_LO, cls.DISC_HI, cz - r, cz + r)
243
192
  )
193
+ return parts
244
194
 
245
- def place(layer, k, tile):
246
- t = layer * cls.BINS + k
247
- col, row = t % cols, t // cols
248
- img[
249
- row * cls.TILE_H : (row + 1) * cls.TILE_H,
250
- col * cls.TILE_W : (col + 1) * cls.TILE_W,
251
- ] = tile
252
-
253
- # ---- grounded: the poles, encoded by run ----
254
- per_pole = []
255
- for base, rad, height in poles:
256
- dx, dz = base[0] - px, base[1] - pz
257
- d = np.hypot(dx, dz)
258
- delta = np.arcsin(np.minimum(1.0, rad / np.maximum(d, 1e-9)))
259
- phi_c = np.mod(np.arctan2(dz, dx), TWO_PI)
260
- cot_hi = np.maximum(d - rad, 1e-3) / height # cot(atan(H / (d - rad)))
261
- per_pole.append((phi_c - delta, phi_c + delta, cot_hi))
262
- for k in range(cls.BINS):
263
- masks = [cls._mask(lo, hi, k, step) for lo, hi, _ in per_pole]
264
- union = np.zeros(px.shape, dtype=np.int64)
265
- for m in masks:
266
- union |= m
267
- first = np.full(px.shape, np.inf)
268
- later = np.full(px.shape, np.inf)
269
- for m, (_, _, cot_hi) in zip(masks, per_pole):
270
- run = cls._run_at(union, cls._first_bit(m))
271
- first = np.where(
272
- (m != 0) & (run == 1), np.minimum(first, cot_hi), first
273
- )
274
- later = np.where(
275
- (m != 0) & (run >= 2), np.minimum(later, cot_hi), later
276
- )
277
- tile = np.zeros((cls.TILE_H, cls.TILE_W, 4), dtype=np.uint8)
278
- hit = union != 0
279
- tile[..., 0] = np.where(
280
- hit & np.isfinite(first),
281
- q(np.where(np.isfinite(first), first, 0) / ms),
282
- 0,
283
- )
284
- tile[..., 1] = np.where(
285
- hit & np.isfinite(later),
286
- q(np.where(np.isfinite(later), later, 0) / ms),
287
- 0,
288
- )
289
- tile[..., 2] = (union & 0xFF).astype(np.uint8)
290
- tile[..., 3] = ((union >> 8) & 0xFF).astype(np.uint8)
291
- place(0, k, tile)
292
-
293
- # ---- floating: the disc ----
294
- if not disc:
295
- return img
296
- dx, dz = cls.DISC[0] - px, cls.DISC[1] - pz
297
- d = np.hypot(dx, dz)
298
- inside = d < cls.DISC_RADIUS
299
- delta = np.where(
300
- inside,
301
- math.pi,
302
- np.arcsin(np.minimum(1.0, cls.DISC_RADIUS / np.maximum(d, 1e-9))),
303
- )
304
- phi_c = np.mod(np.arctan2(dz, dx), TWO_PI)
305
- cot_lo = (d + cls.DISC_RADIUS) / cls.DISC_LO
306
- cot_hi = np.where(
307
- inside, 0.0, np.maximum(d - cls.DISC_RADIUS, 0.0) / cls.DISC_HI
195
+ @classmethod
196
+ def map(cls, poles=None, disc=True) -> "ptk.HeightFieldMap":
197
+ """The baked map -- the reference itself."""
198
+ return ptk.ShadowHorizon.bake(
199
+ cls.meshes(poles, disc),
200
+ up=1,
201
+ size=cls.SIZE,
202
+ spans=cls.SPANS,
203
+ bounds=cls.BOUNDS,
308
204
  )
309
- for k in range(cls.BINS):
310
- mask = cls._mask(phi_c - delta, phi_c + delta, k, step)
311
- hit = mask != 0
312
- tile = np.zeros((cls.TILE_H, cls.TILE_W, 4), dtype=np.uint8)
313
- tile[..., 0] = np.where(hit, q(cot_lo / ms), 0)
314
- tile[..., 1] = np.where(hit, q(cot_hi / ms), 0)
315
- tile[..., 2] = (mask & 0xFF).astype(np.uint8)
316
- tile[..., 3] = ((mask >> 8) & 0xFF).astype(np.uint8)
317
- place(1, k, tile)
318
- return img
319
205
 
320
- # -------------------------------------------------------------- oracle
321
206
  @classmethod
322
- def map(cls, img=None) -> "ptk.HorizonMap":
323
- """The baked image as a `pythontk.HorizonMap` — the reference itself.
324
-
325
- There was a numpy transcription of the HLSL here, and it pinned the
326
- shader to a copy of itself: a divergence between them was invisible by
327
- construction, and the divergences the doc of record claimed were
328
- pinned were exactly the ones neither side could see. The map now goes
329
- straight to `HorizonMap.alpha`, which the shared shader body is
330
- written against, so the Unity pixels are compared to the one oracle
331
- every other consumer answers to.
332
- """
333
- return ptk.HorizonMap.from_rgba(
334
- cls.bake() if img is None else img,
335
- bins=cls.BINS,
336
- size=(cls.TILE_W, cls.TILE_H),
337
- r_min=cls.R_MIN,
338
- r_max=cls.R_MAX,
339
- max_stretch=cls.MAX_STRETCH,
340
- )
207
+ def bake(cls, poles=None, disc=True) -> np.ndarray:
208
+ """The map as the ``(S, S x (K + 2), 4)`` uint8 image the contract
209
+ ships, row 0 at the top (PNG order)."""
210
+ return cls.map(poles, disc).to_rgba()
341
211
 
342
212
  @classmethod
343
- def alpha(cls, img, points, source, source_size=0.0):
344
- """`HorizonMap.alpha` at contact-local ground `points` (N, 3) for a
345
- positional `source` (contact-local) of diameter `source_size`."""
346
- return cls.map(img).alpha(points, light=source, source_size=source_size)
213
+ def alpha(cls, points, source, source_size=0.0, *, poles=None, disc=True):
214
+ """``HeightFieldMap.alpha`` at contact-local ground ``points`` (N, 3)
215
+ for a positional ``source`` (contact-local) of diameter
216
+ ``source_size``, decoding the image ``bake`` writes for the same
217
+ variant."""
218
+ hmap = cls.map(poles, disc)
219
+ back = ptk.HeightFieldMap.from_rgba(
220
+ hmap.to_rgba(),
221
+ size=cls.SIZE,
222
+ spans=cls.SPANS,
223
+ bounds=cls.BOUNDS,
224
+ ground=0.0,
225
+ up=1,
226
+ height_scale=hmap.height_scale,
227
+ )
228
+ return back.alpha(points, light=source, source_size=source_size)
347
229
 
348
230
 
349
231
  def _write_png(path: str, img: np.ndarray) -> None:
@@ -613,13 +495,13 @@ __CASES__
613
495
  // (c3) horizon: a sizeless source, then a finite one.
614
496
  ctrl.rigType = "horizon";
615
497
  ctrl.horizonMap = horizon;
616
- ctrl.horizonBins = __BINS__;
617
- ctrl.horizonLayers = __LAYERS__;
618
- ctrl.horizonTile = new Vector2Int(__TILE_W__, __TILE_H__);
619
- ctrl.horizonLayout = new Vector2Int(__COLS__, __ROWS__);
620
- ctrl.horizonMaxStretch = __H_MAX_STRETCH__f;
621
- ctrl.horizonRMin = __R_MIN__f;
622
- ctrl.horizonRMax = __R_MAX__f;
498
+ ctrl.horizonSize = __SIZE__;
499
+ ctrl.horizonSpans = __SPANS__;
500
+ ctrl.horizonLevels = __LEVELS__;
501
+ ctrl.horizonBounds = new Vector4(__BND0__f, __BND1__f, __BND2__f, __BND3__f);
502
+ ctrl.horizonHeightScale = __HSCALE__f;
503
+ ctrl.horizonMapping = "heightfield";
504
+ ctrl.horizonEncoding = 2;
623
505
  ctrl.horizonFrameA = new Vector3(__A0__f, __A1__f, __A2__f);
624
506
  ctrl.horizonFrameB = new Vector3(__B0__f, __B1__f, __B2__f);
625
507
  ctrl.horizonRect = new Vector4(1f, 1f, 0f, 0f);
@@ -634,16 +516,8 @@ __CASES__
634
516
  sb.AppendLine(" \"block_axis_b\": " + V4(mpb.GetVector("_AxisB")) + ",");
635
517
  sb.AppendLine(" \"block_axis_up\": " + V4(mpb.GetVector("_AxisUp")) + ",");
636
518
  sb.AppendLine(" \"block_horizon_params\": " + V4(mpb.GetVector("_HorizonParams")) + ",");
637
- sb.AppendLine(" \"block_horizon_range\": " + V4(mpb.GetVector("_HorizonRange")) + ",");
638
- sb.AppendLine(" \"block_max_stretch\": " + F(mpb.GetFloat("_MaxStretch")) + ",");
639
- // A map baked before the field existed carries no scale (0): the
640
- // decode must fall back to the plane's live cap, not to zero.
641
- ctrl.horizonMaxStretch = 0f;
642
- ctrl.ApplyNow();
643
- rend.GetPropertyBlock(mpb);
644
- sb.AppendLine(" \"block_max_stretch_absent\": " + F(mpb.GetFloat("_MaxStretch")) + ",");
645
- ctrl.horizonMaxStretch = __H_MAX_STRETCH__f;
646
- ctrl.ApplyNow();
519
+ sb.AppendLine(" \"block_horizon_bounds\": " + V4(mpb.GetVector("_HorizonBounds")) + ",");
520
+ sb.AppendLine(" \"block_horizon_meta\": " + V4(mpb.GetVector("_HorizonMeta")) + ",");
647
521
  sb.AppendLine(" \"block_horizon_map_overridden\": "
648
522
  + B(mpb.GetTexture("_HorizonMap") != null) + ",");
649
523
  sb.AppendLine(" \"block_base_map_overridden\": "
@@ -713,15 +587,14 @@ def _verifier_source(results_path: str) -> str:
713
587
  "__HX__": repr(HORIZON_SOURCE[0]),
714
588
  "__HY__": repr(HORIZON_SOURCE[1]),
715
589
  "__HZ__": repr(HORIZON_SOURCE[2]),
716
- "__BINS__": str(hz.BINS),
717
- "__LAYERS__": str(hz.LAYERS),
718
- "__TILE_W__": str(hz.TILE_W),
719
- "__TILE_H__": str(hz.TILE_H),
720
- "__COLS__": str(hz.cols()),
721
- "__ROWS__": str(hz.rows()),
722
- "__R_MIN__": repr(hz.R_MIN),
723
- "__R_MAX__": repr(hz.R_MAX),
724
- "__H_MAX_STRETCH__": repr(hz.MAX_STRETCH),
590
+ "__SIZE__": str(hz.SIZE),
591
+ "__SPANS__": str(hz.SPANS),
592
+ "__LEVELS__": str(hz.map().levels),
593
+ "__BND0__": repr(hz.BOUNDS[0]),
594
+ "__BND1__": repr(hz.BOUNDS[1]),
595
+ "__BND2__": repr(hz.BOUNDS[2]),
596
+ "__BND3__": repr(hz.BOUNDS[3]),
597
+ "__HSCALE__": repr(float(hz.map().height_scale)),
725
598
  "__A0__": repr(hz.FRAME_A[0]),
726
599
  "__A1__": repr(hz.FRAME_A[1]),
727
600
  "__A2__": repr(hz.FRAME_A[2]),
@@ -989,7 +862,7 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
989
862
  hz = HorizonFixture
990
863
  self.assertEqual(
991
864
  (tex["width"], tex["height"]),
992
- (hz.cols() * hz.TILE_W, hz.rows() * hz.TILE_H),
865
+ (hz.SIZE * (hz.SPANS + 2), hz.SIZE),
993
866
  "the NPOT tile grid must keep its exact size",
994
867
  )
995
868
  self.assertEqual(tex["mip_count"], 1)
@@ -1006,39 +879,17 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
1006
879
  self.assertAlmostEqual(unity["block_mode_horizon"], 1.0, places=5)
1007
880
  params = unity["block_horizon_params"]
1008
881
  self.assertEqual(
1009
- [int(v) for v in params], [hz.BINS, hz.TILE_W, hz.TILE_H, hz.cols()]
1010
- )
1011
- # (r_min, r_max) arrive in SCENE units -- the shared body takes no
1012
- # unit slot, so the controller scales them; layers and the ground
1013
- # plane's height in the frame take the other two.
1014
- rng = unity["block_horizon_range"]
1015
- self.assertAlmostEqual(rng[0], hz.R_MIN, places=5)
1016
- self.assertAlmostEqual(rng[1], hz.R_MAX, places=5)
1017
- self.assertEqual(int(rng[2]), hz.LAYERS)
1018
- self.assertAlmostEqual(rng[3], RECORD["ground"], places=5)
1019
- # The cotangent decode takes the scale the MAP was baked with, never
1020
- # the plane's live placement cap (they differ in this fixture).
1021
- self.assertAlmostEqual(
1022
- unity["block_max_stretch"],
1023
- hz.MAX_STRETCH,
1024
- places=5,
1025
- msg="the shader decodes with the wrong cot scale — a "
1026
- "retuned maxStretch would rescale every shadow length",
1027
- )
1028
- self.assertNotAlmostEqual(
1029
- hz.MAX_STRETCH,
1030
- RECORD["max_stretch"],
1031
- places=5,
1032
- msg="the fixture can no longer tell the two apart",
1033
- )
1034
- # ...and falls back to the live cap only when the record carried none.
1035
- self.assertAlmostEqual(
1036
- unity["block_max_stretch_absent"],
1037
- RECORD["max_stretch"],
1038
- places=5,
1039
- msg="a map with no baked scale must fall back to the "
1040
- "plane's cap, not to zero",
882
+ [int(v) for v in params], [hz.SIZE, hz.SPANS, hz.map().levels, 0]
1041
883
  )
884
+ # The footprint and the height scale arrive in SCENE units -- the
885
+ # shared body takes no unit slot, so the controller scales them; the
886
+ # ground plane's height in the frame rides in the meta vector.
887
+ bounds = unity["block_horizon_bounds"]
888
+ for got, want in zip(bounds, hz.BOUNDS):
889
+ self.assertAlmostEqual(got, want, places=5)
890
+ meta = unity["block_horizon_meta"]
891
+ self.assertAlmostEqual(meta[0], hz.map().height_scale, places=5)
892
+ self.assertAlmostEqual(meta[1], RECORD["ground"], places=5)
1042
893
  # The frame travels as an origin and three WORLD axes; the source
1043
894
  # travels in world space too. The fixture's contact is the identity,
1044
895
  # so each is its own contact-local value.
@@ -1109,19 +960,18 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
1109
960
 
1110
961
  @EnvSkip.unless(_unity_available(), "no Unity editor found")
1111
962
  def test_05_horizon_pixels_match_oracle(self):
1112
- """Shader alpha == the contract's math on the same PNG (two layers,
1113
- sizeless and finite sources), and the pole's shadow lies on the far
1114
- side of the light."""
963
+ """Shader alpha == the contract's math on the same PNG (a sizeless
964
+ and a finite source), and the pole's shadow lies on the far side of
965
+ the light."""
1115
966
  unity = self._pixels_or_skip()
1116
967
  self.assertLessEqual(unity["h1_max_rg"], 2, "not a grey shadow (error shader?)")
1117
- img = self.__class__.horizon_img
1118
968
  xs, zs = _pixel_grid(unity)
1119
969
  points = np.column_stack([xs, np.zeros_like(xs), zs])
1120
970
 
1121
971
  for key, size in (("h1", 0.0), ("h2", HORIZON_SOURCE_SIZE)):
1122
972
  got = _alpha(unity, f"{key}_r")
1123
973
  inside, _ = _inside_plane(unity[f"{key}_placement"], xs, zs)
1124
- expected = HorizonFixture.alpha(img, points, HORIZON_SOURCE, size)
974
+ expected = HorizonFixture.alpha(points, HORIZON_SOURCE, size)
1125
975
  expected = np.where(inside, expected, 0.0)
1126
976
  n = int(inside.sum())
1127
977
  self.assertGreater(n, 200, f"{key}: the plane must cover pixels")
@@ -1164,12 +1014,9 @@ class TestShadowPlaneRuntimeIntegration(unittest.TestCase):
1164
1014
  # minus a pole-1-only bake leaves the disc's and the second pole's
1165
1015
  # shadows, which are masked out of the centroid.
1166
1016
  only1 = HorizonFixture.alpha(
1167
- HorizonFixture.bake(HorizonFixture.POLES[:1], disc=False),
1168
- points,
1169
- HORIZON_SOURCE,
1170
- 0.0,
1017
+ points, HORIZON_SOURCE, 0.0, poles=HorizonFixture.POLES[:1], disc=False
1171
1018
  )
1172
- full = HorizonFixture.alpha(img, points, HORIZON_SOURCE, 0.0)
1019
+ full = HorizonFixture.alpha(points, HORIZON_SOURCE, 0.0)
1173
1020
  inside1, _ = _inside_plane(unity["h1_placement"], xs, zs)
1174
1021
  w = np.where(inside1 & (np.abs(full - only1) < 0.01), got, 0.0)
1175
1022
  self.assertGreater(float(w.sum()), 1.0, "no pole shadow to locate")
@@ -1,4 +1,4 @@
1
- __version__ = "0.0.10"
1
+ __version__ = "0.0.11"
2
2
 
3
3
  from .launcher import UnityLauncher, UnityFinder
4
4
  from .scene_builder import SceneBuilder