@woosh/meep-engine 3.18.0 → 3.20.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 (228) hide show
  1. package/build/bundle-worker-image-decoder.js +1 -1
  2. package/build/bundle-worker-terrain.js +1 -1
  3. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -1
  4. package/editor/view/particles/effect/ParticleCurveEditorView.js +303 -120
  5. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -1
  6. package/editor/view/particles/effect/ParticleGradientEditorView.js +108 -63
  7. package/editor/view/particles/effect/ParticleGraphEditorView.js +2 -2
  8. package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
  9. package/editor/view/particles/effect/ParticleNodeParametersView.js +7 -46
  10. package/editor/view/particles/effect/particle-editor.css +60 -30
  11. package/package.json +1 -2
  12. package/samples/engine/README.md +1 -1
  13. package/src/core/binary/compression/decompress_bytes.d.ts +13 -0
  14. package/src/core/binary/compression/decompress_bytes.d.ts.map +1 -0
  15. package/src/core/binary/compression/decompress_bytes.js +28 -0
  16. package/src/core/collection/array/array_group_by.d.ts.map +1 -1
  17. package/src/core/collection/array/array_group_by.js +2 -11
  18. package/src/core/geom/3d/shape/HeightMapShape3D.d.ts.map +1 -1
  19. package/src/core/geom/3d/shape/HeightMapShape3D.js +4 -9
  20. package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +67 -22
  21. package/src/core/model/object/optional_clone.d.ts +7 -0
  22. package/src/core/model/object/optional_clone.d.ts.map +1 -0
  23. package/src/core/model/object/optional_equality.d.ts +10 -0
  24. package/src/core/model/object/optional_equality.d.ts.map +1 -0
  25. package/src/{shade/descriptor/util → core/model/object}/optional_equality.js +2 -2
  26. package/src/core/model/object/optional_hash.d.ts +9 -0
  27. package/src/core/model/object/optional_hash.d.ts.map +1 -0
  28. package/src/{shade/descriptor/util → core/model/object}/optional_hash.js +3 -3
  29. package/src/engine/asset/Asset.d.ts +1 -1
  30. package/src/engine/asset/Asset.d.ts.map +1 -1
  31. package/src/engine/asset/Asset.js +3 -1
  32. package/src/engine/asset/loaders/image/ImageDecoderWorker.js +12 -27
  33. package/src/engine/asset/loaders/image/prototypePNG.js +8 -7
  34. package/src/engine/ecs/gui/hud/HeadsUpDisplaySystem.d.ts.map +1 -1
  35. package/src/engine/ecs/gui/hud/HeadsUpDisplaySystem.js +9 -10
  36. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  37. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +4 -0
  38. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts +266 -0
  39. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts.map +1 -0
  40. package/src/engine/graphics/ecs/particles/ParticleEffect.js +439 -0
  41. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts +58 -0
  42. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts.map +1 -0
  43. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.js +219 -0
  44. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +178 -25
  45. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  46. package/src/engine/graphics3/GPUParticleEmitterSystem.js +809 -310
  47. package/src/format/image/png/PNGReader.d.ts +7 -6
  48. package/src/format/image/png/PNGReader.d.ts.map +1 -1
  49. package/src/format/image/png/PNGReader.js +13 -12
  50. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts +3 -2
  51. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts.map +1 -1
  52. package/src/format/image/png/chunk/png_chunk_decode_iTXt.js +5 -4
  53. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts +3 -2
  54. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts.map +1 -1
  55. package/src/format/image/png/chunk/png_chunk_decode_zTXt.js +5 -4
  56. package/src/format/image/png/png_inflate.d.ts +3 -3
  57. package/src/format/image/png/png_inflate.d.ts.map +1 -1
  58. package/src/format/image/png/png_inflate.js +29 -39
  59. package/src/format/texture/ktx2/ktx2_read.d.ts +4 -4
  60. package/src/format/texture/ktx2/ktx2_read.d.ts.map +1 -1
  61. package/src/format/texture/ktx2/ktx2_read.js +18 -21
  62. package/src/shade/descriptor/binding/BindGroupLayoutDescriptor.js +1 -1
  63. package/src/shade/descriptor/binding/BindGroupLayoutEntry.js +3 -3
  64. package/src/shade/descriptor/pipeline/PipelineDescriptorBase.js +1 -1
  65. package/src/shade/descriptor/pipeline/PipelineLayoutDescriptor.js +1 -1
  66. package/src/shade/descriptor/pipeline/render/ColorTargetState.js +3 -3
  67. package/src/shade/descriptor/pipeline/render/FragmentState.js +1 -1
  68. package/src/shade/descriptor/pipeline/render/RenderPipelineDescriptor.js +3 -3
  69. package/src/shade/playground/particle_ecs/README.md +203 -0
  70. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts +39 -0
  71. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts.map +1 -0
  72. package/src/shade/playground/particle_ecs/bonfire_editor.js +315 -0
  73. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +145 -0
  74. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts.map +1 -0
  75. package/src/shade/playground/particle_ecs/bonfire_effects.js +202 -0
  76. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts +23 -0
  77. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts.map +1 -0
  78. package/src/shade/playground/particle_ecs/bonfire_sprites.js +315 -0
  79. package/src/shade/playground/particle_ecs/bonfire_world.d.ts +86 -0
  80. package/src/shade/playground/particle_ecs/bonfire_world.d.ts.map +1 -0
  81. package/src/shade/playground/particle_ecs/bonfire_world.js +303 -0
  82. package/src/shade/playground/particle_ecs/effects/embers.json +1634 -0
  83. package/src/shade/playground/particle_ecs/effects/flame.json +1882 -0
  84. package/src/shade/playground/particle_ecs/effects/smoke.json +1860 -0
  85. package/src/shade/playground/particle_ecs/effects/soot.json +1606 -0
  86. package/src/shade/playground/particle_ecs/index.html +330 -0
  87. package/src/shade/playground/particle_ecs/main.d.ts +2 -0
  88. package/src/shade/playground/particle_ecs/main.d.ts.map +1 -0
  89. package/src/shade/playground/particle_ecs/main.js +566 -0
  90. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts +16 -0
  91. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts.map +1 -0
  92. package/src/shade/playground/particle_ecs/moonlit_environment.js +144 -0
  93. package/src/shade/playground/particle_editor/README.md +5 -4
  94. package/src/shade/playground/particle_system/particle_prototype.d.ts +7 -9
  95. package/src/shade/playground/particle_system/particle_prototype.d.ts.map +1 -1
  96. package/src/shade/playground/particle_system/particle_prototype.js +7 -9
  97. package/src/shade/playground/profile_hotkey.d.ts +58 -0
  98. package/src/shade/playground/profile_hotkey.d.ts.map +1 -0
  99. package/src/shade/playground/profile_hotkey.js +325 -0
  100. package/src/shade/playground/ssr_motion/README.md +22 -0
  101. package/src/shade/playground/ssr_motion/index.html +18 -0
  102. package/src/shade/playground/ssr_motion/main.d.ts +2 -0
  103. package/src/shade/playground/ssr_motion/main.d.ts.map +1 -0
  104. package/src/shade/playground/ssr_motion/main.js +149 -0
  105. package/src/shade/playground/ssr_variance/README.md +105 -0
  106. package/src/shade/playground/ssr_variance/capture.d.ts +16 -0
  107. package/src/shade/playground/ssr_variance/capture.d.ts.map +1 -0
  108. package/src/shade/playground/ssr_variance/capture.js +96 -0
  109. package/src/shade/playground/ssr_variance/index.html +23 -0
  110. package/src/shade/playground/ssr_variance/main.d.ts +2 -0
  111. package/src/shade/playground/ssr_variance/main.d.ts.map +1 -0
  112. package/src/shade/playground/ssr_variance/main.js +246 -0
  113. package/src/shade/playground/ssr_variance/reference.d.ts +18 -0
  114. package/src/shade/playground/ssr_variance/reference.d.ts.map +1 -0
  115. package/src/shade/playground/ssr_variance/reference.js +78 -0
  116. package/src/shade/playground/ssr_variance/scene.d.ts +11 -0
  117. package/src/shade/playground/ssr_variance/scene.d.ts.map +1 -0
  118. package/src/shade/playground/ssr_variance/scene.js +61 -0
  119. package/src/shade/playground/ssr_variance/statistics.d.ts +44 -0
  120. package/src/shade/playground/ssr_variance/statistics.d.ts.map +1 -0
  121. package/src/shade/playground/ssr_variance/statistics.js +51 -0
  122. package/src/shade/renderer/geometry/Geometry.d.ts.map +1 -1
  123. package/src/shade/renderer/geometry/Geometry.js +4 -7
  124. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts.map +1 -1
  125. package/src/shade/renderer/loader/gltf/tiny-gltf.js +9 -3
  126. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts +4 -4
  127. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts.map +1 -1
  128. package/src/shade/renderer/loader/usd/usd_decode_image.js +4 -4
  129. package/src/shade/renderer/material/StandardShadeMaterial.js +2 -2
  130. package/src/shade/renderer/particles/DESIGN.md +745 -702
  131. package/src/shade/renderer/particles/GPUParticleSystem.d.ts.map +1 -1
  132. package/src/shade/renderer/particles/GPUParticleSystem.js +3 -1
  133. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.d.ts +1 -1
  134. package/src/shade/renderer/particles/graph/ParticleGroupBuilder.js +1 -1
  135. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.d.ts.map +1 -1
  136. package/src/shade/renderer/particles/graph/ParticleNodeRegistry.js +9 -15
  137. package/src/shade/renderer/particles/graph/particle_group_library.d.ts.map +1 -1
  138. package/src/shade/renderer/particles/graph/particle_group_library.js +8 -3
  139. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts +0 -2
  140. package/src/shade/renderer/particles/graph/particle_node_parameters.d.ts.map +1 -1
  141. package/src/shade/renderer/particles/graph/particle_node_parameters.js +0 -9
  142. package/src/shade/renderer/particles/graph/particle_node_presentation.d.ts.map +1 -1
  143. package/src/shade/renderer/particles/graph/particle_node_presentation.js +0 -10
  144. package/src/shade/renderer/particles/graph/particle_ramp.d.ts +2 -8
  145. package/src/shade/renderer/particles/graph/particle_ramp.d.ts.map +1 -1
  146. package/src/shade/renderer/particles/graph/particle_ramp.js +2 -8
  147. package/src/shade/renderer/particles/graph/validate_particle_graph.js +0 -6
  148. package/src/shade/renderer/particles/graph_particles_avboit.d.ts.map +1 -1
  149. package/src/shade/renderer/particles/graph_particles_avboit.js +13 -4
  150. package/src/shade/renderer/particles/isa/InstructionStream.d.ts +1 -12
  151. package/src/shade/renderer/particles/isa/InstructionStream.d.ts.map +1 -1
  152. package/src/shade/renderer/particles/isa/InstructionStream.js +1 -12
  153. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts +54 -60
  154. package/src/shade/renderer/particles/isa/ParticleVMISA.d.ts.map +1 -1
  155. package/src/shade/renderer/particles/isa/ParticleVMISA.js +9 -17
  156. package/src/shade/renderer/particles/isa/particle_assembly.d.ts +0 -1
  157. package/src/shade/renderer/particles/isa/particle_assembly.d.ts.map +1 -1
  158. package/src/shade/renderer/particles/isa/particle_assembly.js +2 -16
  159. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.d.ts +1 -1
  160. package/src/shade/renderer/particles/optimizer/dag/particle_vector_graph.js +1 -1
  161. package/src/shade/renderer/particles/optimizer/dag/particle_vector_lift.js +0 -14
  162. package/src/shade/renderer/particles/optimizer/dag/particle_vector_pack.js +3 -17
  163. package/src/shade/renderer/particles/optimizer/particle_program_analysis.d.ts.map +1 -1
  164. package/src/shade/renderer/particles/optimizer/particle_program_analysis.js +15 -36
  165. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.d.ts.map +1 -1
  166. package/src/shade/renderer/particles/optimizer/particle_program_equivalence.js +1 -5
  167. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.d.ts.map +1 -1
  168. package/src/shade/renderer/particles/optimizer/particle_program_fuzz.js +1 -8
  169. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts +2 -11
  170. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.d.ts.map +1 -1
  171. package/src/shade/renderer/particles/optimizer/particle_vm_semantics.js +2 -11
  172. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +11 -4
  173. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  174. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +431 -424
  175. package/src/shade/renderer/particles/shaders/chunk_particle_avboit_contribution.d.ts.map +1 -1
  176. package/src/shade/renderer/particles/shaders/chunk_particle_avboit_contribution.js +13 -0
  177. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts +3 -6
  178. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.d.ts.map +1 -1
  179. package/src/shade/renderer/particles/shaders/shader_particle_avboit_draw.js +49 -17
  180. package/src/shade/renderer/particles/shaders/shader_particle_emit.d.ts.map +1 -1
  181. package/src/shade/renderer/particles/shaders/shader_particle_emit.js +0 -2
  182. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.d.ts.map +1 -1
  183. package/src/shade/renderer/particles/shaders/shader_particle_emit_wide.js +0 -2
  184. package/src/shade/renderer/particles/shaders/shader_particle_simulate.d.ts.map +1 -1
  185. package/src/shade/renderer/particles/shaders/shader_particle_simulate.js +1 -5
  186. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.d.ts.map +1 -1
  187. package/src/shade/renderer/particles/shaders/shader_particle_simulate_wide.js +0 -2
  188. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts +1 -3
  189. package/src/shade/renderer/particles/vm/ParticleVMReference.d.ts.map +1 -1
  190. package/src/shade/renderer/particles/vm/ParticleVMReference.js +4 -20
  191. package/src/shade/renderer/particles/vm/chunk_particle_vm.d.ts.map +1 -1
  192. package/src/shade/renderer/particles/vm/chunk_particle_vm.js +2 -5
  193. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.d.ts.map +1 -1
  194. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance.js +0 -2
  195. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.d.ts.map +1 -1
  196. package/src/shade/renderer/particles/warmup/shader_particle_warmup_advance_wide.js +0 -2
  197. package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
  198. package/src/shade/renderer/postprocess/ssr/SSR.js +2 -1
  199. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -1
  200. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +20 -5
  201. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -1
  202. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +19 -5
  203. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts +4 -0
  204. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts.map +1 -0
  205. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.js +137 -0
  206. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -8
  207. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
  208. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +18 -114
  209. package/src/shade/renderer/scene/Mesh.js +1 -1
  210. package/src/shade/renderer/texture/ShadeTexture.js +1 -1
  211. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts +2 -2
  212. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts.map +1 -1
  213. package/src/shade/renderer/texture/source/texel_data_from_ktx2.js +3 -3
  214. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts +0 -19
  215. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.d.ts.map +0 -1
  216. package/src/shade/renderer/particles/shaders/chunk_particle_curve_animation.js +0 -30
  217. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.d.ts +0 -11
  218. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.d.ts.map +0 -1
  219. package/src/shade/renderer/particles/shaders/chunk_particle_curve_disabled.js +0 -13
  220. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +0 -20
  221. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +0 -1
  222. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts +0 -14
  223. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts.map +0 -1
  224. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +0 -50
  225. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +0 -1
  226. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +0 -17
  227. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +0 -1
  228. /package/src/{shade/descriptor/util → core/model/object}/optional_clone.js +0 -0
@@ -1,6 +1,7 @@
1
1
  import { AnimationCurve } from "../../../../src/engine/animation/curve/AnimationCurve.js";
2
2
  import { Keyframe } from "../../../../src/engine/animation/curve/Keyframe.js";
3
- import { clamp01 } from "../../../../src/core/math/clamp01.js";
3
+ import { clamp01 } from "../../../../src/core/math/clamp01.js";
4
+ import { spline3_hermite_bounds } from "../../../../src/core/math/spline/spline3_hermite_bounds.js";
4
5
  import {
5
6
  particle_curve_animation,
6
7
  particle_curve_from_animation,
@@ -8,12 +9,12 @@ import {
8
9
  import View from "../../../../src/view/View.js";
9
10
 
10
11
  /**
11
- * A curve, edited in the unit square: `t` across, the value up, and the keys that make it draggable.
12
+ * A curve with normalized time across and an unrestricted value up.
12
13
  *
13
14
  * Drag a key to move it. Click where there is no key and one is added there, with tangents that
14
15
  * leave the curve looking like it did — so adding a key is somewhere to make an edit *from* rather
15
- * than an edit in itself. Middle-click a key, or press the remove button, and it goes. The picker
16
- * below sets the selected key's tangents, which is what shapes the curve on either side of it.
16
+ * than an edit in itself. Middle-click a key, or press the remove button, and it goes. The fields
17
+ * below edit its exact time, value and tangents.
17
18
  *
18
19
  * ## It is an AnimationCurve
19
20
  *
@@ -26,27 +27,26 @@ import View from "../../../../src/view/View.js";
26
27
  * {@link ../../../../src/shade/renderer/particles/graph/particle_ramp.js} — and its agreeing with
27
28
  * this one is what `particle_ramp.spec.js` checks.
28
29
  *
29
- * ## The unit square
30
+ * ## The viewport
30
31
  *
31
- * Drawn from 0 to 1 on both axes, and keys are held inside it, because that is what the node is for:
32
- * `t` normalised in, a factor out. A curve that wants to go to eleven is that curve times eleven,
33
- * which is a `mul` node and is visible in the graph — where a value of eleven hidden inside a
34
- * control would not be.
32
+ * Time spans 0 to 1. The value axis fits the curve, including extrema between keys. Its scale stays
33
+ * fixed throughout a pointer gesture so the mapping under the pointer cannot shift during a drag.
34
+ * Dragging beyond the plot continues along that scale; releasing refits the viewport.
35
35
  *
36
36
  * ## When an edit is committed
37
37
  *
38
38
  * On pointer up, for the reason set out in {@link ./ParticleGradientEditorView.js}: a key dragged
39
- * across the square is one thing to undo, not the hundred pointer events it was made of.
39
+ * across the plot is one thing to undo, not the hundred pointer events it was made of.
40
40
  */
41
41
 
42
42
  /** @type {number} */
43
- const WIDTH = 208;
43
+ const WIDTH = 240;
44
44
 
45
45
  /** @type {number} */
46
- const HEIGHT = 84;
46
+ const HEIGHT = 132;
47
47
 
48
- /** Room around the plot, so a key at 0 or 1 is drawn whole rather than clipped in half. */
49
- const MARGIN = 7;
48
+ /** Separate gutters for axis labels and complete endpoint handles. Used by drawing and picking. */
49
+ const PLOT = Object.freeze({ left: 52, top: 10, right: WIDTH - 12, bottom: HEIGHT - 20 });
50
50
 
51
51
  /** How near a key a press has to be to take hold of it rather than make a new one. */
52
52
  const GRAB_RADIUS = 8;
@@ -59,7 +59,8 @@ const GRAB_RADIUS = 8;
59
59
  const TANGENT_MODES = Object.freeze({
60
60
  Smooth: "smooth",
61
61
  Linear: "linear",
62
- Flat: "flat",
62
+ Flat: "flat",
63
+ Custom: "custom",
63
64
  });
64
65
 
65
66
  export class ParticleCurveEditorView extends View {
@@ -67,7 +68,12 @@ export class ParticleCurveEditorView extends View {
67
68
  * The curve being edited — a working copy, drawn continuously and committed on release.
68
69
  * @type {AnimationCurve}
69
70
  */
70
- #curve = new AnimationCurve();
71
+ #curve = new AnimationCurve();
72
+
73
+ /** Value bounds and tick spacing of the viewport, never limits on the authored data.
74
+ * @type {{min: number, max: number, step: number}}
75
+ */
76
+ #range = { min: 0, max: 1, step: 0.25 };
71
77
 
72
78
  /** @type {HTMLCanvasElement} */
73
79
  #canvas;
@@ -78,14 +84,17 @@ export class ParticleCurveEditorView extends View {
78
84
  /** @type {number} */
79
85
  #selected = 0;
80
86
 
81
- /** @type {boolean} */
82
- #dragging = false;
87
+ /** @type {number|null} */
88
+ #pointerId = null;
89
+
90
+ /** @type {boolean} ignore our commit's synchronous parameter-change notification */
91
+ #writing = false;
83
92
 
84
93
  /** @type {function():object} */
85
94
  #read;
86
95
 
87
96
  /** @type {function(object):void} */
88
- #commit;
97
+ #write;
89
98
 
90
99
  /**
91
100
  * Which rule each key's tangents came from, by key.
@@ -105,7 +114,13 @@ export class ParticleCurveEditorView extends View {
105
114
  #modes = new WeakMap();
106
115
 
107
116
  /** @type {HTMLSelectElement} */
108
- #tangents;
117
+ #tangents;
118
+
119
+ /** @type {HTMLInputElement} */
120
+ #time;
121
+
122
+ /** @type {HTMLInputElement} */
123
+ #value;
109
124
 
110
125
  /**
111
126
  * @param {object} options
@@ -116,7 +131,7 @@ export class ParticleCurveEditorView extends View {
116
131
  super();
117
132
 
118
133
  this.#read = read;
119
- this.#commit = write;
134
+ this.#write = write;
120
135
 
121
136
  this.el = document.createElement('div');
122
137
 
@@ -126,7 +141,9 @@ export class ParticleCurveEditorView extends View {
126
141
 
127
142
  canvas.width = WIDTH;
128
143
  canvas.height = HEIGHT;
129
- canvas.classList.add('plot');
144
+ canvas.classList.add('plot');
145
+ canvas.tabIndex = 0;
146
+ canvas.setAttribute('aria-label', 'Curve: normalized time t and value. Drag keys; click to add; Escape to cancel.');
130
147
 
131
148
  this.el.appendChild(canvas);
132
149
 
@@ -134,7 +151,16 @@ export class ParticleCurveEditorView extends View {
134
151
 
135
152
  fields.classList.add('key-fields');
136
153
 
137
- this.el.appendChild(fields);
154
+ this.el.appendChild(fields);
155
+
156
+ this.#time = coordinate_field(fields, 'time', 'Time (t)');
157
+ this.#time.min = '0';
158
+ this.#time.max = '1';
159
+ this.#value = coordinate_field(fields, 'value', 'Value');
160
+
161
+ const tangentLabel = document.createElement('label');
162
+ tangentLabel.classList.add('ramp-field', 'field-tangents');
163
+ tangentLabel.appendChild(document.createTextNode('Tangents'));
138
164
 
139
165
  const tangents = this.#tangents = document.createElement('select');
140
166
 
@@ -145,12 +171,15 @@ export class ParticleCurveEditorView extends View {
145
171
  const option = document.createElement('option');
146
172
 
147
173
  option.value = mode;
148
- option.textContent = label;
174
+ option.textContent = label;
175
+ // Authored slopes are preserved, but this picker cannot manufacture custom tangents.
176
+ option.disabled = mode === TANGENT_MODES.Custom;
149
177
 
150
178
  tangents.appendChild(option);
151
179
  }
152
180
 
153
- fields.appendChild(tangents);
181
+ tangentLabel.appendChild(tangents);
182
+ fields.appendChild(tangentLabel);
154
183
 
155
184
  const remove = document.createElement('button');
156
185
 
@@ -164,7 +193,27 @@ export class ParticleCurveEditorView extends View {
164
193
  canvas.addEventListener('pointerdown', event => this.#press(event));
165
194
  canvas.addEventListener('pointermove', event => this.#move(event));
166
195
  canvas.addEventListener('pointerup', event => this.#release(event));
167
- canvas.addEventListener('pointercancel', event => this.#release(event));
196
+ canvas.addEventListener('pointercancel', event => this.#cancel(event));
197
+ canvas.addEventListener('lostpointercapture', event => this.#cancel(event));
198
+ canvas.addEventListener('keydown', event => {
199
+ if (event.key === 'Escape' && this.#pointerId !== null) {
200
+ event.stopPropagation();
201
+ this.refresh();
202
+ }
203
+ });
204
+ this.on.unlinked.add(() => this.refresh());
205
+
206
+ for (const input of [this.#time, this.#value]) {
207
+ input.addEventListener('change', () => {
208
+ const key = this.#curve.keys[this.#selected];
209
+ if (key === undefined) return;
210
+ const time = input === this.#time ? Number.parseFloat(input.value) : key.time;
211
+ const value = input === this.#value ? Number.parseFloat(input.value) : key.value;
212
+ this.#moveSelected(Number.isFinite(time) ? clamp01(time) : key.time,
213
+ Number.isFinite(value) ? value : key.value);
214
+ this.#commit();
215
+ });
216
+ }
168
217
 
169
218
  tangents.addEventListener('change', () => {
170
219
  const key = this.#curve.keys[this.#selected];
@@ -176,7 +225,7 @@ export class ParticleCurveEditorView extends View {
176
225
  this.#modes.set(key, tangents.value);
177
226
  this.#applyTangents(this.#selected, tangents.value);
178
227
 
179
- this.#commit(particle_curve_from_animation(this.#curve));
228
+ this.#commit();
180
229
  this.render();
181
230
  });
182
231
 
@@ -196,8 +245,10 @@ export class ParticleCurveEditorView extends View {
196
245
  /**
197
246
  * Re-read the parameter and redraw.
198
247
  */
199
- refresh() {
200
- const value = this.#read();
248
+ refresh() {
249
+ if (this.#writing) return;
250
+ this.#endDrag();
251
+ const value = this.#read();
201
252
 
202
253
  this.#curve = particle_curve_animation(
203
254
  value !== null && typeof value === "object" && Array.isArray(value.keys) ? value : { keys: [] }
@@ -209,25 +260,36 @@ export class ParticleCurveEditorView extends View {
209
260
  this.#curve.keys.forEach((key, index) => this.#modes.set(key, this.#modeOf(index)));
210
261
 
211
262
  this.#writeFields();
212
- this.render();
213
- }
263
+ this.render();
264
+ }
265
+
266
+ #commit() {
267
+ const value = particle_curve_from_animation(this.#curve);
268
+ if (JSON.stringify(value) === JSON.stringify(this.#read())) return;
269
+ this.#writing = true;
270
+ try {
271
+ this.#write(value);
272
+ } finally {
273
+ this.#writing = false;
274
+ }
275
+ }
214
276
 
215
277
  /**
216
278
  * @param {PointerEvent} event
217
- * @returns {{time: number, value: number}} where in the unit square the pointer is
279
+ * @returns {{time: number, value: number}} the pointer in curve coordinates
218
280
  */
219
281
  #pointOf(event) {
220
282
  const rect = this.#canvas.getBoundingClientRect();
221
283
 
222
284
  const scale = rect.width / WIDTH;
223
285
 
224
- const x = (event.clientX - rect.left) / scale - MARGIN;
225
- const y = (event.clientY - rect.top) / (rect.height / HEIGHT) - MARGIN;
286
+ const x = (event.clientX - rect.left) / scale - PLOT.left;
287
+ const y = (event.clientY - rect.top) / (rect.height / HEIGHT) - PLOT.top;
226
288
 
227
289
  return {
228
- time: clamp01(x / (WIDTH - MARGIN * 2)),
290
+ time: clamp01(x / (PLOT.right - PLOT.left)),
229
291
  // the plot runs upward and the canvas runs downward
230
- value: clamp01(1 - y / (HEIGHT - MARGIN * 2)),
292
+ value: this.#range.min + (1 - y / (PLOT.bottom - PLOT.top)) * (this.#range.max - this.#range.min),
231
293
  };
232
294
  }
233
295
 
@@ -238,8 +300,8 @@ export class ParticleCurveEditorView extends View {
238
300
  */
239
301
  #plot(time, value) {
240
302
  return {
241
- x: MARGIN + clamp01(time) * (WIDTH - MARGIN * 2),
242
- y: MARGIN + (1 - clamp01(value)) * (HEIGHT - MARGIN * 2),
303
+ x: PLOT.left + time * (PLOT.right - PLOT.left),
304
+ y: PLOT.top + (this.#range.max - value) / (this.#range.max - this.#range.min) * (PLOT.bottom - PLOT.top),
243
305
  };
244
306
  }
245
307
 
@@ -263,7 +325,7 @@ export class ParticleCurveEditorView extends View {
263
325
 
264
326
  const away = Math.hypot(point.x - x, point.y - y);
265
327
 
266
- if (away <= GRAB_RADIUS && away < distance) {
328
+ if (away <= GRAB_RADIUS && (away < distance || (away === distance && index === this.#selected))) {
267
329
  nearest = index;
268
330
  distance = away;
269
331
  }
@@ -275,8 +337,9 @@ export class ParticleCurveEditorView extends View {
275
337
  /**
276
338
  * @param {PointerEvent} event
277
339
  */
278
- #press(event) {
279
- const key = this.#keyUnder(event);
340
+ #press(event) {
341
+ if (this.#pointerId !== null) return;
342
+ const key = this.#keyUnder(event);
280
343
 
281
344
  if (event.button === 1) {
282
345
  if (key !== -1) {
@@ -294,20 +357,20 @@ export class ParticleCurveEditorView extends View {
294
357
 
295
358
  if (key !== -1) {
296
359
  this.#selected = key;
297
- } else {
298
- const { time } = this.#pointOf(event);
299
-
300
- // On the curve where it already is, with tangents that keep it looking the same, so the
301
- // click is somewhere to edit from rather than an edit.
302
- this.#selected = this.#curve.add(Keyframe.from(time, this.#curve.evaluate(time)));
303
-
304
- this.#modes.set(this.#curve.keys[this.#selected], TANGENT_MODES.Smooth);
305
- this.#applyTangents(this.#selected, TANGENT_MODES.Smooth);
306
-
307
- this.#commit(particle_curve_from_animation(this.#curve));
308
- }
309
-
310
- this.#dragging = true;
360
+ } else {
361
+ const { time } = this.#pointOf(event);
362
+
363
+ // Labels and gutters are not an insertion surface.
364
+ const rect = this.#canvas.getBoundingClientRect();
365
+ const x = (event.clientX - rect.left) / rect.width * WIDTH;
366
+ const y = (event.clientY - rect.top) / rect.height * HEIGHT;
367
+ if (x < PLOT.left || x > PLOT.right || y < PLOT.top || y > PLOT.bottom) return;
368
+ this.#insertAt(time);
369
+ }
370
+
371
+ event.preventDefault();
372
+ this.#canvas.focus({ preventScroll: true });
373
+ this.#pointerId = event.pointerId ?? 0;
311
374
 
312
375
  if (typeof this.#canvas.setPointerCapture === "function") {
313
376
  this.#canvas.setPointerCapture(event.pointerId);
@@ -320,18 +383,24 @@ export class ParticleCurveEditorView extends View {
320
383
  /**
321
384
  * @param {PointerEvent} event
322
385
  */
323
- #move(event) {
324
- if (!this.#dragging) {
386
+ #move(event) {
387
+ if (this.#pointerId !== (event.pointerId ?? 0)) {
325
388
  return;
326
389
  }
327
390
 
328
- const key = this.#curve.keys[this.#selected];
329
-
330
- if (key === undefined) {
331
- return;
332
- }
333
-
334
- const { time, value } = this.#pointOf(event);
391
+ const { time, value } = this.#pointOf(event);
392
+ this.#moveSelected(time, value);
393
+ }
394
+
395
+ /** @param {number} time @param {number} value */
396
+ #moveSelected(time, value) {
397
+ const key = this.#curve.keys[this.#selected];
398
+ if (key === undefined) {
399
+ return;
400
+ }
401
+
402
+ // Crossing several keys changes neighbours at BOTH the old and new locations.
403
+ const affected = new Set(this.#curve.keys.slice(Math.max(0, this.#selected - 1), this.#selected + 2));
335
404
 
336
405
  key.time = time;
337
406
  key.value = value;
@@ -339,35 +408,73 @@ export class ParticleCurveEditorView extends View {
339
408
  // The curve keeps its keys in time order and this one may have crossed a neighbour. Sorting
340
409
  // by hand rather than removing and re-adding, so that the key object — and the selection
341
410
  // pointing at it — survive the move.
342
- this.#selected = resort(this.#curve.keys, this.#selected);
343
-
344
- this.render();
411
+ this.#selected = resort(this.#curve.keys, this.#selected);
412
+
413
+ this.#curve.keys.slice(Math.max(0, this.#selected - 1), this.#selected + 2).forEach(other => affected.add(other));
414
+ for (const other of affected) {
415
+ this.#applyTangents(this.#curve.keys.indexOf(other), this.#modes.get(other) ?? TANGENT_MODES.Custom);
416
+ }
417
+
418
+ this.#writeFields();
419
+ this.render();
345
420
  }
346
421
 
347
422
  /**
348
423
  * @param {PointerEvent} event
349
424
  */
350
425
  #release(event) {
351
- if (!this.#dragging) {
426
+ if (this.#pointerId !== (event.pointerId ?? 0)) {
352
427
  return;
353
428
  }
354
429
 
355
- this.#dragging = false;
356
-
357
- if (typeof this.#canvas.releasePointerCapture === "function" && this.#canvas.hasPointerCapture?.(event.pointerId)) {
358
- this.#canvas.releasePointerCapture(event.pointerId);
359
- }
360
-
361
- // A key that moved changed the slope on both sides of it, so whichever rule its neighbours
362
- // were following has to be applied again — otherwise a "Linear" segment stops being straight
363
- // the moment either of its ends is dragged.
364
- this.#reapplyAround(this.#selected);
365
-
366
- this.#commit(particle_curve_from_animation(this.#curve));
430
+ this.#endDrag();
431
+ this.#commit();
367
432
 
368
433
  this.#writeFields();
369
- this.render();
370
- }
434
+ this.render();
435
+ }
436
+
437
+ #endDrag() {
438
+ const pointerId = this.#pointerId;
439
+ this.#pointerId = null;
440
+ if (pointerId !== null && this.#canvas.hasPointerCapture?.(pointerId)) {
441
+ this.#canvas.releasePointerCapture(pointerId);
442
+ }
443
+ }
444
+
445
+ /** @param {PointerEvent} event */
446
+ #cancel(event) {
447
+ if (this.#pointerId === (event.pointerId ?? 0)) this.refresh();
448
+ }
449
+
450
+ /** Split a Hermite segment using its exact derivative, preserving even custom curves.
451
+ * @param {number} time
452
+ */
453
+ #insertAt(time) {
454
+ const keys = this.#curve.keys;
455
+ const existing = keys.findIndex(key => Math.abs(key.time - time) < 1e-9);
456
+ if (existing !== -1) {
457
+ this.#selected = existing;
458
+ return;
459
+ }
460
+ const previous = keys[this.#curve.getKeyIndexLow(time)];
461
+ const next = keys[this.#curve.getKeyIndexLow(time) + 1];
462
+ let slope = 0;
463
+ if (previous !== undefined && next !== undefined && time > previous.time && time < next.time) {
464
+ const span = next.time - previous.time;
465
+ const u = (time - previous.time) / span;
466
+ const rise = next.value - previous.value;
467
+ const m0 = previous.outTangent * span;
468
+ const m1 = next.inTangent * span;
469
+ slope = ((3 * (m0 + m1 - 2 * rise) * u + 2 * (3 * rise - 2 * m0 - m1)) * u + m0) / span;
470
+ } else if (keys.length > 0) {
471
+ // The engine holds the endpoint value outside the keyed interval.
472
+ if (time < keys[0].time) keys[0].inTangent = 0;
473
+ if (time > keys[keys.length - 1].time) keys[keys.length - 1].outTangent = 0;
474
+ }
475
+ this.#selected = this.#curve.add(Keyframe.from(time, this.#curve.evaluate(time), slope, slope));
476
+ this.#modes.set(keys[this.#selected], this.#modeOf(this.#selected));
477
+ }
371
478
 
372
479
  #removeSelected() {
373
480
  // A curve with one key is a constant, which is a thing to be; a curve with none is nothing,
@@ -382,7 +489,7 @@ export class ParticleCurveEditorView extends View {
382
489
 
383
490
  this.#reapplyAround(this.#selected);
384
491
 
385
- this.#commit(particle_curve_from_animation(this.#curve));
492
+ this.#commit();
386
493
  this.#writeFields();
387
494
  this.render();
388
495
  }
@@ -401,7 +508,7 @@ export class ParticleCurveEditorView extends View {
401
508
  const key = this.#curve.keys[neighbour];
402
509
 
403
510
  if (key !== undefined) {
404
- this.#applyTangents(neighbour, this.#modes.get(key) ?? TANGENT_MODES.Smooth);
511
+ this.#applyTangents(neighbour, this.#modes.get(key) ?? TANGENT_MODES.Custom);
405
512
  }
406
513
  }
407
514
  }
@@ -415,9 +522,11 @@ export class ParticleCurveEditorView extends View {
415
522
 
416
523
  const key = keys[index];
417
524
 
418
- if (key === undefined) {
419
- return;
420
- }
525
+ if (key === undefined) {
526
+ return;
527
+ }
528
+
529
+ if (mode === TANGENT_MODES.Custom) return;
421
530
 
422
531
  if (mode === TANGENT_MODES.Flat) {
423
532
  key.inTangent = 0;
@@ -449,8 +558,8 @@ export class ParticleCurveEditorView extends View {
449
558
  /**
450
559
  * Which rule a key's tangents look like they came from. There is nowhere to *store* the answer —
451
560
  * a curve is keys and tangents, and adding a mode to it would be inventing a format the rest of
452
- * the engine does not read — so it is recovered from the numbers, which is exact for the two
453
- * cases that matter and falls back to Smooth.
561
+ * the engine does not read — so it is recovered from the numbers for the preset rules.
562
+ * Slopes that do not match a rule stay Custom until explicitly changed.
454
563
  *
455
564
  * @param {number} index
456
565
  * @returns {string} a {@link TANGENT_MODES}
@@ -471,19 +580,28 @@ export class ParticleCurveEditorView extends View {
471
580
  linear.keys = this.#curve.keys.map(other => other.clone());
472
581
  linear.alignTangents(index);
473
582
 
474
- return linear.keys[index].inTangent === key.inTangent && linear.keys[index].outTangent === key.outTangent
475
- ? TANGENT_MODES.Linear
476
- : TANGENT_MODES.Smooth;
583
+ const close = (a, b) => Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
584
+ if (close(linear.keys[index].inTangent, key.inTangent) && close(linear.keys[index].outTangent, key.outTangent)) {
585
+ return TANGENT_MODES.Linear;
586
+ }
587
+ const previous = this.#curve.keys[index - 1] ?? key;
588
+ const next = this.#curve.keys[index + 1] ?? key;
589
+ const slope = next.time === previous.time ? 0 : (next.value - previous.value) / (next.time - previous.time);
590
+ return close(slope, key.inTangent) && close(slope, key.outTangent) ? TANGENT_MODES.Smooth : TANGENT_MODES.Custom;
477
591
  }
478
592
 
479
593
  #writeFields() {
480
594
  const key = this.#curve.keys[this.#selected];
481
595
 
482
- this.#tangents.value = (key === undefined ? undefined : this.#modes.get(key)) ?? TANGENT_MODES.Smooth;
596
+ this.#tangents.value = (key === undefined ? undefined : this.#modes.get(key)) ?? TANGENT_MODES.Smooth;
597
+ this.#time.value = key === undefined ? '' : String(Number(key.time.toFixed(6)));
598
+ this.#value.value = key === undefined ? '' : String(key.value);
599
+ for (const field of [this.#time, this.#value, this.#tangents]) field.disabled = key === undefined;
483
600
  }
484
601
 
485
- render() {
486
- const context = this.#context;
602
+ render() {
603
+ if (this.#pointerId === null) this.#range = value_range(this.#curve);
604
+ const context = this.#context;
487
605
 
488
606
  if (context === null) {
489
607
  return;
@@ -496,45 +614,62 @@ export class ParticleCurveEditorView extends View {
496
614
  context.fillStyle = '#171b21';
497
615
  context.fillRect(0, 0, canvas.width, canvas.height);
498
616
 
499
- // the unit square, so "0" and "1" are somewhere rather than implied
617
+ // Numeric values on Y, normalized time on X. Both use the same mapping as the handles.
500
618
  context.strokeStyle = '#2c323b';
501
619
  context.lineWidth = 1;
502
620
  context.beginPath();
503
621
 
504
- for (const fraction of [0, 0.5, 1]) {
505
- const horizontal = this.#plot(0, fraction);
506
- const vertical = this.#plot(fraction, 0);
507
-
508
- context.moveTo(MARGIN, horizontal.y + 0.5);
509
- context.lineTo(WIDTH - MARGIN, horizontal.y + 0.5);
510
-
511
- context.moveTo(vertical.x + 0.5, MARGIN);
512
- context.lineTo(vertical.x + 0.5, HEIGHT - MARGIN);
622
+ context.font = '10px sans-serif';
623
+ context.fillStyle = '#9da0a3';
624
+ const { min, max, step } = this.#range;
625
+ const ticks = Math.round((max - min) / step);
626
+ for (let i = 0; i <= ticks; i++) {
627
+ const value = min + i * step;
628
+ const horizontal = this.#plot(0, value);
629
+ context.moveTo(PLOT.left, horizontal.y + 0.5);
630
+ context.lineTo(PLOT.right, horizontal.y + 0.5);
631
+ context.textAlign = 'right';
632
+ context.fillText(String(Number(value.toPrecision(12))), PLOT.left - 9, horizontal.y + 3, PLOT.left - 12);
633
+ }
634
+ for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
635
+ const vertical = this.#plot(fraction, min);
636
+ context.moveTo(vertical.x + 0.5, PLOT.top);
637
+ context.lineTo(vertical.x + 0.5, PLOT.bottom);
638
+ context.textAlign = 'center';
639
+ context.fillText(String(fraction), vertical.x, PLOT.bottom + 15);
513
640
  }
514
641
 
515
- context.stroke();
642
+ context.stroke();
516
643
 
517
644
  // The curve itself, asked of the AnimationCurve a pixel at a time — the same evaluation the
518
645
  // rest of the engine does, so what is drawn is what the curve is.
519
- context.strokeStyle = '#7fb5ff';
520
- context.lineWidth = 1.5;
521
- context.beginPath();
522
-
523
- for (let x = MARGIN; x <= WIDTH - MARGIN; x++) {
524
- const time = (x - MARGIN) / (WIDTH - MARGIN * 2);
525
-
526
- const point = this.#plot(time, this.#curve.evaluate(time));
527
-
528
- if (x === MARGIN) {
646
+ context.strokeStyle = '#7fb5ff';
647
+ context.lineWidth = 1.5;
648
+ context.save();
649
+ context.beginPath();
650
+ context.rect(PLOT.left, PLOT.top, PLOT.right - PLOT.left, PLOT.bottom - PLOT.top);
651
+ context.clip();
652
+ context.beginPath();
653
+
654
+ for (let x = PLOT.left; x <= PLOT.right; x++) {
655
+ const time = (x - PLOT.left) / (PLOT.right - PLOT.left);
656
+
657
+ const value = this.#curve.evaluate(time);
658
+ const point = this.#plot(time, value);
659
+
660
+ if (x === PLOT.left) {
529
661
  context.moveTo(point.x, point.y);
530
662
  } else {
531
663
  context.lineTo(point.x, point.y);
532
664
  }
533
665
  }
534
666
 
535
- context.stroke();
536
-
537
- this.#curve.keys.forEach((key, index) => {
667
+ context.stroke();
668
+ context.restore();
669
+
670
+ const ordered = this.#curve.keys.map((key, index) => ({ key, index }))
671
+ .sort((a, b) => Number(a.index === this.#selected) - Number(b.index === this.#selected));
672
+ ordered.forEach(({ key, index }) => {
538
673
  const point = this.#plot(key.time, key.value);
539
674
 
540
675
  context.beginPath();
@@ -548,7 +683,55 @@ export class ParticleCurveEditorView extends View {
548
683
  context.stroke();
549
684
  });
550
685
  }
551
- }
686
+ }
687
+
688
+ /** @param {HTMLElement} parent @param {string} name @param {string} text
689
+ * @returns {HTMLInputElement}
690
+ */
691
+ function coordinate_field(parent, name, text) {
692
+ const label = document.createElement('label');
693
+ label.classList.add('ramp-field', `field-${name}`);
694
+ label.appendChild(document.createTextNode(text));
695
+ const input = document.createElement('input');
696
+ input.type = 'number';
697
+ input.classList.add(name);
698
+ input.step = 'any';
699
+ label.appendChild(input);
700
+ parent.appendChild(label);
701
+ return input;
702
+ }
703
+
704
+ /** Fit rounded value ticks to the entire spline, rather than just its keys or pixel samples.
705
+ * @param {AnimationCurve} curve
706
+ * @returns {{min: number, max: number, step: number}}
707
+ */
708
+ function value_range(curve) {
709
+ const keys = curve.keys;
710
+ if (keys.length === 0) return { min: 0, max: 1, step: 0.25 };
711
+
712
+ let min = keys[0].value;
713
+ let max = min;
714
+ const bounds = [0, 0];
715
+ for (let i = 1; i < keys.length; i++) {
716
+ const previous = keys[i - 1];
717
+ const key = keys[i];
718
+ const span = key.time - previous.time;
719
+ spline3_hermite_bounds(bounds, 0, 1, previous.value, key.value,
720
+ previous.outTangent * span, key.inTangent * span);
721
+ min = Math.min(min, bounds[0]);
722
+ max = Math.max(max, bounds[1]);
723
+ }
724
+ if (min === max) {
725
+ const padding = min === 0 ? 0.5 : Math.abs(min) * 0.1;
726
+ min -= padding;
727
+ max += padding;
728
+ }
729
+ const roughStep = (max - min) / 4;
730
+ const magnitude = 10 ** Math.floor(Math.log10(roughStep));
731
+ const multiple = [1, 2, 2.5, 5, 10].find(value => value * magnitude >= roughStep) ?? 10;
732
+ const step = multiple * magnitude;
733
+ return { min: Math.floor(min / step) * step, max: Math.ceil(max / step) * step, step };
734
+ }
552
735
 
553
736
  /**
554
737
  * Move one element of an already-sorted-but-for-it array back into place, and say where it went.