Skip to content

Commit fcfdbc4

Browse files
dgreenheckclaude
andcommitted
Fix branch growth force to pull toward force.direction as a world axis
The old rotateTowards(qForce, step) form was degenerate when force.direction equalled the section's natural growth direction (e.g. (0,1,0) on a vertical trunk): qForce collapsed to identity, and with negative strength the slerp extrapolated along whatever tiny tilt the section had drifted into — pushing branches in random seed-dependent directions instead of toward -force.direction. Rotate directly around the (sectionUp × force.direction) axis so force.direction is a real world axis: positive strength pulls toward it, negative pushes away, and the rotation is zero exactly when growth is already aligned with the target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28c1650 commit fcfdbc4

2 files changed

Lines changed: 27 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Updated Dockerfile to Node 24 and removed the obsolete `version` attribute from `docker-compose.yml`.
1313
- Use custom rounded normals for leaves for softer shading (#43).
1414

15+
### Fixed
16+
17+
- `branch.force.direction` now behaves as a real world axis for both positive and negative `strength`. The previous `rotateTowards(qForce, step)` form was degenerate when `force.direction = (0, 1, 0)` (a vertical trunk's natural growth direction): `qForce` collapsed to the identity quaternion, so negative strength extrapolated slerp along whatever tiny tilt the section had drifted into, pushing branches in random seed-dependent directions instead of toward `-force.direction`. The rotation is now applied directly around the `(sectionUp × force.direction)` axis, which is zero exactly when growth is already aligned with the target.
18+
1519
## [1.1.0] - 2026-01-14
1620

1721
### Added

src/lib/tree.js

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,30 @@ export class Tree extends THREE.Group {
206206
this.options.branch.twist[branch.level],
207207
);
208208

209-
const qForce = new THREE.Quaternion().setFromUnitVectors(
210-
new THREE.Vector3(0, 1, 0),
211-
new THREE.Vector3().copy(this.options.branch.force.direction),
212-
);
213-
214209
qSection.multiply(qTwist);
215-
qSection.rotateTowards(
216-
qForce,
217-
this.options.branch.force.strength / sectionRadius,
218-
);
210+
211+
// Rotate the section's growth direction toward force.direction (positive
212+
// strength) or away from it (negative). The (sectionUp × target) axis
213+
// makes force.direction behave as a real world axis: when sectionUp is
214+
// already aligned with target the rotation is zero, so a vertical trunk
215+
// with force=(0,1,0) doesn't get gnarliness drift amplified — the old
216+
// slerp form was degenerate at qForce=identity and pushed branches in
217+
// whatever random direction the section had drifted.
218+
const sectionUp = new THREE.Vector3(0, 1, 0).applyQuaternion(qSection);
219+
const target = new THREE.Vector3()
220+
.copy(this.options.branch.force.direction)
221+
.normalize();
222+
const axis = new THREE.Vector3().crossVectors(sectionUp, target);
223+
const sinFull = axis.length();
224+
if (sinFull > 1e-6) {
225+
axis.divideScalar(sinFull);
226+
const fullAngle = Math.atan2(sinFull, sectionUp.dot(target));
227+
const step = this.options.branch.force.strength / sectionRadius;
228+
const clamped = Math.max(-fullAngle, Math.min(fullAngle, step));
229+
qSection.premultiply(
230+
new THREE.Quaternion().setFromAxisAngle(axis, clamped),
231+
);
232+
}
219233

220234
// Apply trellis force if enabled
221235
if (this.options.trellis.enabled) {

0 commit comments

Comments
 (0)