Skip to content

Commit ef5b925

Browse files
Merge pull request #2 from GoetzDeBouville/dev
Refactor stepping & units; add polygon utilities (v2.0.0)
2 parents 1bce6af + cf33fd0 commit ef5b925

18 files changed

Lines changed: 294 additions & 179 deletions

File tree

docs/shapes.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,73 @@ Modifier.physicsBody(
3939
)
4040
```
4141

42+
### Polygon utilities
43+
44+
#### regularPolygonNormalized(...)
45+
`regularPolygonNormalized(...)` returns a regular convex `PhysicsShape.Polygon` in
46+
`PhysicsShape.Polygon.VertexSpace.Normalized`.
47+
48+
In normalized space, `x` and `y` values in `-0.5..0.5` map to the Composable's width and height.
49+
On non-square Composables, the polygon stretches with the measured bounds in the same way as the
50+
physics fixture.
51+
52+
Vertices use screen-oriented coordinates. Because of that, `rotationDegrees` is clockwise. The default `-90f` places the first vertex at the top.
53+
54+
- `sides` must be in `3..8` (backend limit).
55+
- `radius` must be finite and `> 0`.
56+
- `rotationDegrees` must be finite.
57+
58+
```kotlin
59+
val hex = regularPolygonNormalized(sides = 6)
60+
61+
Modifier.physicsBody(key = "hex", shape = hex)
62+
```
63+
64+
```kotlin
65+
val tri = regularPolygonNormalized(
66+
sides = 3,
67+
radius = 0.45f,
68+
rotationDegrees = 0f,
69+
)
70+
71+
Modifier.physicsBody(key = "tri", shape = tri)
72+
```
73+
74+
#### polygonComposeShape(...)
75+
`polygonComposeShape(...)` converts a `PhysicsShape.Polygon` to a Compose `Shape`
76+
(`GenericShape`) so the visual clip can match the collision geometry.
77+
78+
It supports both `PhysicsShape.Polygon.VertexSpace.Normalized` and
79+
`PhysicsShape.Polygon.VertexSpace.Px`, using the same local-origin mapping as the physics fixture
80+
adapter.
81+
82+
If the polygon has fewer than 3 vertices, or if the Composable size is zero, the resulting path is
83+
empty and the shape is effectively a no-op.
84+
85+
```kotlin
86+
val poly = regularPolygonNormalized(6)
87+
88+
Modifier
89+
.clip(polygonComposeShape(poly))
90+
.physicsBody(key = "poly", shape = poly)
91+
```
92+
93+
```kotlin
94+
val pxPoly = PhysicsShape.Polygon(
95+
vertices = listOf(
96+
PhysicsVector2(-40f, -30f),
97+
PhysicsVector2(40f, -30f),
98+
PhysicsVector2(20f, 50f),
99+
PhysicsVector2(-20f, 50f),
100+
),
101+
space = PhysicsShape.Polygon.VertexSpace.Px,
102+
)
103+
104+
Modifier
105+
.clip(polygonComposeShape(pxPoly))
106+
.physicsBody(key = "pxPoly", shape = pxPoly)
107+
```
108+
42109
### Constraints
43110
- **Convex only** (no concave or self‑intersecting shapes).
44111

gradle/libs.versions.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,13 @@ compileSdk = "36"
44
targetSdk = "36"
55
minSdk = "23"
66

7-
packageVersion = "1.0.3"
7+
packageVersion = "1.0.4"
88

99
kotlin = "2.3.0"
10-
compose-multiplatform = "1.10.0"
10+
compose-multiplatform = "1.10.1"
1111
material3 = "1.10.0-alpha05"
1212
agp = "8.13.2"
13-
androidx-activityCompose = "1.12.3"
14-
kermit = "2.0.8"
13+
androidx-activityCompose = "1.12.4"
1514
kotlinx-coroutines = "1.10.2"
1615

1716
jbox2d = "2.2.1.1"
@@ -32,7 +31,6 @@ compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.r
3231
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
3332

3433
androidx-activityCompose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
35-
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
3634

3735
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
3836
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/PhysicsBoxState.kt

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -174,24 +174,25 @@ class PhysicsBoxState internal constructor(
174174
/**
175175
* Queues a linear impulse for the body associated with [key].
176176
*
177-
* Units are interpreted by the runtime (typically physics units).
177+
* Impulse components are expressed in container pixels (Px) and converted internally
178+
* to physics impulse units by the runtime [dev.zinchenko.physicsbox.units.PhysicsUnits].
178179
*
179180
* @param key Body key used in `Modifier.physicsBody(key = ...)`.
180-
* @param impulseX Impulse X component.
181-
* @param impulseY Impulse Y component.
181+
* @param impulseXPx Impulse X component in container pixels (Px).
182+
* @param impulseYPx Impulse Y component in container pixels (Px).
182183
* @param wake Whether to wake the body if the backend supports sleeping.
183184
*/
184185
fun enqueueImpulse(
185186
key: Any,
186-
impulseX: Float,
187-
impulseY: Float,
187+
impulseXPx: Float,
188+
impulseYPx: Float,
188189
wake: Boolean = true,
189190
) {
190191
enqueueCommand(
191192
PhysicsCommand.EnqueueImpulse(
192193
key = key,
193-
impulseX = impulseX,
194-
impulseY = impulseY,
194+
impulseXPx = impulseXPx,
195+
impulseYPx = impulseYPx,
195196
wake = wake,
196197
),
197198
)
@@ -200,12 +201,13 @@ class PhysicsBoxState internal constructor(
200201
/**
201202
* Queues a linear velocity update for the body associated with [key].
202203
*
203-
* Units are interpreted by the runtime (some implementations may treat these as physics units,
204-
* others as pixel units; prefer runtime-specific helpers if available).
204+
* Velocity components are expressed in container pixels per second (Px/s) and converted
205+
* internally to meters per second by the runtime
206+
* [dev.zinchenko.physicsbox.units.PhysicsUnits].
205207
*
206208
* @param key Body key used in `Modifier.physicsBody(key = ...)`.
207-
* @param velocityX Velocity X component.
208-
* @param velocityY Velocity Y component.
209+
* @param velocityX Velocity X component in container pixels per second (Px/s).
210+
* @param velocityY Velocity Y component in container pixels per second (Px/s).
209211
*/
210212
fun enqueueVelocity(
211213
key: Any,
@@ -215,8 +217,8 @@ class PhysicsBoxState internal constructor(
215217
enqueueCommand(
216218
PhysicsCommand.EnqueueVelocity(
217219
key = key,
218-
velocityX = velocityX,
219-
velocityY = velocityY,
220+
velocityXPxPerSec = velocityX,
221+
velocityYPxPerSec = velocityY,
220222
),
221223
)
222224
}
@@ -278,10 +280,6 @@ class PhysicsBoxState internal constructor(
278280
callbacksByKey.remove(key)
279281
}
280282

281-
internal fun dispatchCollisionToBody(event: CollisionEvent) {
282-
callbacksByKey[event.selfKey]?.onCollision?.invoke(event)
283-
}
284-
285283
internal fun dispatchDragToBody(event: DragEvent) {
286284
val callbacks = callbacksByKey[event.key] ?: return
287285
when (event.phase) {
@@ -290,10 +288,6 @@ class PhysicsBoxState internal constructor(
290288
DragPhase.End, DragPhase.Cancel -> callbacks.onDragEnd?.invoke(event)
291289
}
292290
}
293-
294-
internal fun dispatchSleepToBody(key: Any, isSleeping: Boolean) {
295-
callbacksByKey[key]?.onSleepChanged?.invoke(isSleeping)
296-
}
297291
}
298292

299293
/**

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/PhysicsCommand.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,28 +18,32 @@ sealed interface PhysicsCommand {
1818
* Applies a linear impulse to a body.
1919
*
2020
* @param key Body key used in `Modifier.physicsBody(key = ...)`.
21-
* @param impulseX X component of impulse (runtime-defined units).
22-
* @param impulseY Y component of impulse (runtime-defined units).
21+
* @param impulseXPx X component of impulse in container pixels (Px).
22+
* Converted internally to physics impulse units by [dev.zinchenko.physicsbox.units.PhysicsUnits].
23+
* @param impulseYPx Y component of impulse in container pixels (Px).
24+
* Converted internally to physics impulse units by [dev.zinchenko.physicsbox.units.PhysicsUnits].
2325
* @param wake Whether the backend should wake the body if supported.
2426
*/
2527
data class EnqueueImpulse(
2628
val key: Any,
27-
val impulseX: Float,
28-
val impulseY: Float,
29+
val impulseXPx: Float,
30+
val impulseYPx: Float,
2931
val wake: Boolean = true,
3032
) : PhysicsCommand
3133

3234
/**
3335
* Sets/overwrites linear velocity of a body.
3436
*
3537
* @param key Body key used in `Modifier.physicsBody(key = ...)`.
36-
* @param velocityX X component (runtime-defined units).
37-
* @param velocityY Y component (runtime-defined units).
38+
* @param velocityXPxPerSec X component in container pixels per second (Px/s).
39+
* Converted internally to meters per second by [dev.zinchenko.physicsbox.units.PhysicsUnits].
40+
* @param velocityYPxPerSec Y component in container pixels per second (Px/s).
41+
* Converted internally to meters per second by [dev.zinchenko.physicsbox.units.PhysicsUnits].
3842
*/
3943
data class EnqueueVelocity(
4044
val key: Any,
41-
val velocityX: Float,
42-
val velocityY: Float,
45+
val velocityXPxPerSec: Float,
46+
val velocityYPxPerSec: Float,
4347
) : PhysicsCommand
4448

4549
/**

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/engine/PhysicsEventSink.kt

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,3 @@ internal interface PhysicsEventSink {
1111

1212
fun onDrag(event: DragEvent)
1313
}
14-
15-
internal object NoOpEventSink : PhysicsEventSink {
16-
override fun onCollision(event: CollisionEvent) = Unit
17-
18-
override fun onStep(event: StepEvent) = Unit
19-
20-
override fun onDrag(event: DragEvent) = Unit
21-
}

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/engine/PhysicsWorldEngine.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dev.zinchenko.physicsbox.PhysicsBoxConfig
55
import dev.zinchenko.physicsbox.PhysicsCommand
66
import dev.zinchenko.physicsbox.PhysicsWorldSnapshot
77
import dev.zinchenko.physicsbox.SolverIterations
8+
import dev.zinchenko.physicsbox.StepConfig
89
import dev.zinchenko.physicsbox.physicsbody.CollisionFilter
910
import dev.zinchenko.physicsbox.physicsbody.PhysicsBodyConfig
1011
import dev.zinchenko.physicsbox.physicsbody.PhysicsBodyRegistration
@@ -20,7 +21,11 @@ internal expect class PhysicsWorldEngine(
2021
) {
2122
fun setPaused(paused: Boolean)
2223

23-
fun step(deltaSeconds: Float): StepResult
24+
fun step(
25+
deltaSeconds: Float,
26+
stepConfig: StepConfig,
27+
solverIterations: SolverIterations,
28+
): StepResult
2429

2530
fun apply(command: PhysicsCommand)
2631

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/events/CollisionEvent.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ package dev.zinchenko.physicsbox.events
77
* registration keys from `Modifier.physicsBody(key = ...)`.
88
*
99
* ### Impulse and normal
10-
* - [impulse] is a scalar magnitude representing the strength of the contact resolution for this
11-
* event (engine-specific; typically proportional to momentum exchange).
10+
* - [impulse] is a scalar collision impulse magnitude that has already been converted for UI-space
11+
* scaling.
12+
* - In the JVM backend it is produced by taking the Box2D impulse in physics units and converting
13+
* it via [dev.zinchenko.physicsbox.units.PhysicsUnits] (PxPerMeter/worldScale).
14+
* - Treat this as a px-scaled magnitude suitable for UX effects (sound/haptics/FX thresholds),
15+
* not as raw physics units.
16+
* - Treat as px-scaled magnitude (UI scale), not N·s and not m·kg/s.
1217
* - ([normalX], [normalY]) is the contact normal in world/container space as reported by the engine.
1318
* In the current backend the normal points from this body toward the other body; conventions may
1419
* differ across physics engines.
@@ -18,7 +23,7 @@ package dev.zinchenko.physicsbox.events
1823
*
1924
* @param selfKey Key of the body that owns the listener (the “this” body).
2025
* @param otherKey Key of the other body involved in the collision.
21-
* @param impulse Contact impulse magnitude (engine-defined units).
26+
* @param impulse Contact impulse magnitude converted to UI px-scale via PhysicsUnits.
2227
* @param normalX X component of the reported collision normal.
2328
* @param normalY Y component of the reported collision normal.
2429
*/

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/layout/PhysicsBox.kt

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ private fun PhysicsBoxImpl(
114114
}
115115
val solverIterations = state.solverIterations
116116
val units = remember(runtimeConfig.worldScale) { runtimeConfig.units() }
117+
val engineSyncState = remember { EngineSyncState() }
117118

118119
val eventSink = remember(state) {
119120
object : PhysicsEventSink {
@@ -127,9 +128,9 @@ private fun PhysicsBoxImpl(
127128
}
128129
}
129130

130-
val engine = remember(runtimeConfig, solverIterations, units, eventSink) {
131+
val engine = remember(runtimeConfig.boundaries, units, eventSink) {
131132
PhysicsWorldEngine(
132-
config = runtimeConfig,
133+
config = config.copy(step = state.stepConfig),
133134
solverIterations = solverIterations,
134135
boundariesConfig = runtimeConfig.boundaries,
135136
units = units,
@@ -140,6 +141,10 @@ private fun PhysicsBoxImpl(
140141
val isPaused = state.isPaused
141142
SideEffect {
142143
engine.setPaused(isPaused)
144+
if (engineSyncState.gravitySyncedEngine !== engine) {
145+
engine.apply(PhysicsCommand.SetWorldGravity(state.gravity))
146+
engineSyncState.gravitySyncedEngine = engine
147+
}
143148
if (pendingCommandVersion >= 0L) {
144149
val commands = state.drainPendingCommands()
145150
if (commands.isNotEmpty()) {
@@ -222,8 +227,8 @@ interface PhysicsBoxScope {
222227
* @param isDraggable Enables pointer dragging for this body.
223228
* @param dragConfig Drag tuning parameters (max force, spring frequency, damping, fling limits).
224229
* @param onCollision Optional body-level collision callback.
225-
* @param onSleepChanged Optional callback invoked when the body enters/leaves “sleep” state
226-
* (engine-specific; typically means it stopped moving and is excluded from simulation work).
230+
* @param onSleepChanged Optional callback invoked when the body enters/leaves “sleep” state.
231+
* Callback parameter is `isSleeping` (`true` when sleeping, `false` when awake).
227232
* @param onDragStart Optional callback invoked when a drag starts for this body.
228233
* @param onDragEnd Optional callback invoked when a drag ends (including cancel).
229234
*/
@@ -276,3 +281,7 @@ internal val LocalPhysicsBoxConfig = staticCompositionLocalOf { PhysicsBoxConfig
276281

277282
@Deprecated("Not yet implented")
278283
internal val LocalPhysicsDebugConfig = staticCompositionLocalOf { PhysicsDebugConfig() }
284+
285+
private class EngineSyncState {
286+
var gravitySyncedEngine: PhysicsWorldEngine? = null
287+
}

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/layout/PhysicsSimulationLoop.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package dev.zinchenko.physicsbox.layout
22

33
import androidx.compose.runtime.withFrameNanos
44
import dev.zinchenko.physicsbox.PhysicsBoxState
5+
import dev.zinchenko.physicsbox.SolverIterations
56
import dev.zinchenko.physicsbox.StepConfig
67
import dev.zinchenko.physicsbox.engine.PhysicsWorldEngine
78
import kotlinx.coroutines.currentCoroutineContext
@@ -43,6 +44,10 @@ internal class PhysicsSimulationLoop(
4344

4445
val stepConfig = stepConfigProvider()
4546
val fixedDeltaSeconds = 1f / stepConfig.hz
47+
val solverIterations = SolverIterations(
48+
velocity = stepConfig.velocityIterations,
49+
position = stepConfig.positionIterations,
50+
)
4651

4752
val frameDeltaNanos = (frameTimeNanos - lastFrameNanos).coerceAtLeast(0L)
4853
lastFrameNanos = frameTimeNanos
@@ -58,7 +63,11 @@ internal class PhysicsSimulationLoop(
5863
accumulatorSeconds + FIXED_STEP_EPSILON >= fixedDeltaSeconds &&
5964
subSteps < stepConfig.maxSubSteps
6065
) {
61-
engine.step(fixedDeltaSeconds)
66+
engine.step(
67+
deltaSeconds = fixedDeltaSeconds,
68+
stepConfig = stepConfig,
69+
solverIterations = solverIterations,
70+
)
6271
accumulatorSeconds -= fixedDeltaSeconds
6372
subSteps++
6473
}

physicsbox/src/commonMain/kotlin/dev/zinchenko/physicsbox/physicsbody/PhysicsBody.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import dev.zinchenko.physicsbox.events.DragEvent
3232
* @param isDraggable Enables pointer-driven dragging for this body.
3333
* @param dragConfig Drag tuning parameters.
3434
* @param onCollision Optional callback for contact events involving this body.
35-
* @param onSleepChanged Optional callback when sleeping state changes (engine-defined).
35+
* @param onSleepChanged Optional callback when sleeping state changes.
36+
* Callback parameter is `isSleeping` (`true` when the body is sleeping).
3637
* @param onDragStart Optional callback when drag interaction starts.
3738
* @param onDragEnd Optional callback when drag interaction ends (including cancel).
3839
*/

0 commit comments

Comments
 (0)