/**
 * Scene methods split from scene.js.
 */

/**
 * Register a tree mesh for procedural wind bending.
 *
 * @param {THREE.Mesh} mesh
 * @param {object=} options
 * @param {number=} options.stiffness
 * @param {number=} options.max_sway_ratio
 */
beestat.component.scene.prototype.register_tree_wind_mesh_ = function(mesh, options) {
  if (
    mesh === undefined ||
    mesh.geometry === undefined ||
    mesh.geometry.attributes === undefined ||
    mesh.geometry.attributes.position === undefined
  ) {
    return;
  }

  const position_attribute = mesh.geometry.attributes.position;
  const source_positions = position_attribute.array;
  if (source_positions === undefined || source_positions.length === 0) {
    return;
  }

  if (this.tree_wind_meshes_ === undefined) {
    this.tree_wind_meshes_ = [];
  }

  const count = position_attribute.count;
  const base_positions = new Float32Array(source_positions.length);
  base_positions.set(source_positions);
  const weights = new Float32Array(count);
  const phase_offsets = new Float32Array(count);

  const mesh_offset_z = Number(mesh.position !== undefined ? mesh.position.z : 0);
  let min_world_z = Infinity;
  let max_world_z = -Infinity;
  for (let i = 0; i < count; i++) {
    const world_z = base_positions[(i * 3) + 2] + mesh_offset_z;
    min_world_z = Math.min(min_world_z, world_z);
    max_world_z = Math.max(max_world_z, world_z);
  }
  const height = Math.max(0.0001, max_world_z - min_world_z);

  for (let i = 0; i < count; i++) {
    const offset = i * 3;
    const x = base_positions[offset];
    const y = base_positions[offset + 1];
    const world_z = base_positions[offset + 2] + mesh_offset_z;
    const height_ratio = (max_world_z - world_z) / height;
    const clamped_ratio = Math.max(0, Math.min(1, height_ratio));

    // Keep trunk/branch bases anchored and increase bending toward tips.
    weights[i] = Math.pow(clamped_ratio, 1.75);
    phase_offsets[i] = ((x * 0.02) + (y * 0.015)) * 0.4;
  }

  const resolved_options = options || {};
  const stiffness = Math.max(0.1, Number(resolved_options.stiffness || 1));
  const max_sway_ratio = Math.max(
    0,
    Number(resolved_options.max_sway_ratio === undefined ? 0.03 : resolved_options.max_sway_ratio)
  );

  this.tree_wind_meshes_.push({
    'mesh': mesh,
    'base_positions': base_positions,
    'weights': weights,
    'phase_offsets': phase_offsets,
    'height': height,
    'stiffness': stiffness,
    'max_sway_ratio': max_sway_ratio,
    'phase_seed': Math.random() * Math.PI * 2
  });
};


/**
 * Update tree vertex sway for current wind speed.
 * Wind direction is single-axis to keep motion physically directional.
 */
beestat.component.scene.prototype.update_tree_wind_ = function() {
  if (this.tree_wind_meshes_ === undefined || this.tree_wind_meshes_.length === 0) {
    return;
  }

  const wind_speed = Math.max(0, Math.min(2, Number(this.get_scene_setting_('wind_speed') || 0)));
  const wind_direction = Math.max(0, Math.min(360, Number(this.get_scene_setting_('wind_direction') || 0)));
  const tree_wobble_enabled = this.get_scene_setting_('tree_wobble') !== false;
  // Keep overall tree effect lower than prior tuning while preserving responsiveness.
  const wind_strength = wind_speed * 1.25;
  const time_seconds = window.performance.now() / 1000;
  const wind_radians = THREE.MathUtils.degToRad(wind_direction);
  const wind_direction_x = Math.cos(wind_radians);
  const wind_direction_y = Math.sin(wind_radians);
  const gust = 0.78 + (0.22 * Math.sin(time_seconds * 0.16));

  for (let i = 0; i < this.tree_wind_meshes_.length; i++) {
    const wind_mesh = this.tree_wind_meshes_[i];
    const mesh = wind_mesh.mesh;
    if (
      mesh === undefined ||
      mesh.geometry === undefined ||
      mesh.geometry.attributes === undefined ||
      mesh.geometry.attributes.position === undefined
    ) {
      continue;
    }

    const position_attribute = mesh.geometry.attributes.position;
    const positions = position_attribute.array;
    const base_positions = wind_mesh.base_positions;
    const weights = wind_mesh.weights;
    const phase_offsets = wind_mesh.phase_offsets;
    const count = position_attribute.count;

    if (tree_wobble_enabled !== true || wind_strength <= 0) {
      for (let vertex_index = 0; vertex_index < count; vertex_index++) {
        const offset = vertex_index * 3;
        positions[offset] = base_positions[offset];
        positions[offset + 1] = base_positions[offset + 1];
        positions[offset + 2] = base_positions[offset + 2];
      }
      position_attribute.needsUpdate = true;
      continue;
    }

    const max_sway = wind_mesh.height * wind_mesh.max_sway_ratio * (wind_strength / wind_mesh.stiffness);
    const steady_lean = max_sway * 0.28;
    const oscillation_strength = max_sway * (0.58 + (0.24 * gust));
    const frequency = (0.75 + (wind_strength * 0.42)) / Math.max(0.25, wind_mesh.stiffness);

    for (let vertex_index = 0; vertex_index < count; vertex_index++) {
      const offset = vertex_index * 3;
      const weight = weights[vertex_index];
      const phase = (time_seconds * frequency) + wind_mesh.phase_seed + phase_offsets[vertex_index];
      const oscillation =
        Math.sin(phase) +
        (Math.sin((phase * 2.1) + 0.6) * 0.25);
      const along_wind = (steady_lean + (oscillation * oscillation_strength)) * weight;

      positions[offset] = base_positions[offset] + (wind_direction_x * along_wind);
      positions[offset + 1] = base_positions[offset + 1] + (wind_direction_y * along_wind);
      positions[offset + 2] = base_positions[offset + 2];
    }

    position_attribute.needsUpdate = true;
  }
};


/**
 * Build a radial alpha texture used for soft tree-ground contact decals.
 *
 * @return {?THREE.CanvasTexture}
 */
beestat.component.scene.prototype.create_tree_ground_contact_texture_ = function() {
  const size = 64;
  const canvas = document.createElement('canvas');
  canvas.width = size;
  canvas.height = size;
  const context = canvas.getContext('2d');

  if (context === null) {
    return null;
  }

  const gradient = context.createRadialGradient(
    size / 2,
    size / 2,
    size * 0.06,
    size / 2,
    size / 2,
    size / 2
  );
  gradient.addColorStop(0, 'rgba(0, 0, 0, 0.48)');
  gradient.addColorStop(0.45, 'rgba(0, 0, 0, 0.2)');
  gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');

  context.clearRect(0, 0, size, size);
  context.fillStyle = gradient;
  context.fillRect(0, 0, size, size);

  const texture = new THREE.CanvasTexture(canvas);
  texture.generateMipmaps = true;
  texture.needsUpdate = true;

  return texture;
};


/**
 * Get shared material for soft trunk-to-ground blending.
 *
 * @return {THREE.MeshBasicMaterial}
 */
beestat.component.scene.prototype.get_tree_ground_contact_material_ = function() {
  if (this.tree_ground_contact_material_ !== undefined) {
    return this.tree_ground_contact_material_;
  }

  const texture = this.create_tree_ground_contact_texture_();
  this.tree_ground_contact_material_ = new THREE.MeshBasicMaterial({
    'color': 0x1a1208,
    'map': texture,
    'transparent': true,
    'opacity': 0.3,
    'depthWrite': false,
    'polygonOffset': true,
    'polygonOffsetFactor': -1,
    'polygonOffsetUnits': -2,
    'side': THREE.DoubleSide
  });

  return this.tree_ground_contact_material_;
};


/**
 * Add stylized root collar + soft contact shadow to blend tree base into terrain.
 *
 * @param {THREE.Group} tree
 * @param {number} trunk_radius
 * @param {number} trunk_color
 */
beestat.component.scene.prototype.add_tree_ground_contact_ = function(tree, trunk_radius, trunk_color) {
  const base_radius = Math.max(0.8, trunk_radius);
  const collar_height = Math.max(1.8, base_radius * 0.8);
  const collar_geometry = new THREE.CylinderGeometry(
    Math.max(1.1, base_radius * 1.5),
    Math.max(1.6, base_radius * 2.2),
    collar_height,
    7
  );
  collar_geometry.rotateX(-Math.PI / 2);
  const collar_color = new THREE.Color(trunk_color);
  collar_color.multiplyScalar(0.84 + (Math.random() * 0.08));
  const collar = new THREE.Mesh(
    collar_geometry,
    new THREE.MeshStandardMaterial({
      'color': collar_color,
      'roughness': 1.0,
      'metalness': 0.0
    })
  );
  collar.position.z = (collar_height / 2) - (Math.max(0.2, base_radius * 0.08));
  collar.rotation.z = Math.random() * Math.PI * 2;
  collar.castShadow = true;
  collar.receiveShadow = false;
  collar.userData.is_environment = true;
  tree.add(collar);

  const contact_radius = Math.max(2, base_radius * 2.05);
  const contact_geometry = new THREE.CircleGeometry(contact_radius, 14);
  const contact = new THREE.Mesh(
    contact_geometry,
    this.get_tree_ground_contact_material_()
  );
  contact.position.z = 0.06;
  contact.castShadow = false;
  contact.receiveShadow = false;
  contact.userData.is_environment = true;
  tree.add(contact);
};


/**
 * Create a low-poly conical tree with slight procedural variation.
 *
 * @param {number} height Total tree height.
 * @param {number} max_diameter Maximum foliage diameter.
 * @param {boolean} has_foliage Whether foliage should be rendered.
 *
 * @return {THREE.Group}
 */
beestat.component.scene.prototype.create_conical_tree_ = function(height, max_diameter, has_foliage) {
  const clamped_height = Math.max(40, height || 120);
  const clamped_diameter = Math.max(18, max_diameter || 48);
  const tree = new THREE.Group();
  tree.userData.is_environment = true;
  tree.userData.is_tree = true;

  const trunk_height_ratio = 0.2 + (Math.random() * 0.08);
  const trunk_height = clamped_height * trunk_height_ratio;
  const trunk_radius_top = Math.max(1.2, clamped_diameter * (0.045 + (Math.random() * 0.015)));
  const trunk_radius_bottom = trunk_radius_top * (1.25 + (Math.random() * 0.2));
  const trunk_geometry = new THREE.CylinderGeometry(
    trunk_radius_top,
    trunk_radius_bottom,
    trunk_height,
    6
  );
  trunk_geometry.rotateX(-Math.PI / 2);
  const trunk_material = new THREE.MeshStandardMaterial({
    'color': 0x5d4226,
    'roughness': 0.9,
    'metalness': 0.0
  });
  const trunk = new THREE.Mesh(trunk_geometry, trunk_material);
  trunk.position.z = -(trunk_height / 2) + Math.max(0.6, trunk_radius_bottom * 0.1);
  trunk.castShadow = true;
  trunk.receiveShadow = true;
  trunk.userData.is_environment = true;
  tree.add(trunk);
  this.register_tree_wind_mesh_(trunk, {
    'stiffness': 2.4,
    'max_sway_ratio': 0.01
  });
  this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x5d4226);

  if (has_foliage === false) {
    return tree;
  }

  const clamp01 = function(value) {
    return Math.max(0, Math.min(1, value));
  };

  const crown_height_target = Math.max(10, clamped_height - trunk_height);
  const base_foliage_color = new THREE.Color(0x2f7d2d);
  const base_hsl = {};
  base_foliage_color.getHSL(base_hsl);
  const tree_foliage_color = new THREE.Color().setHSL(
    clamp01(base_hsl.h + ((Math.random() - 0.5) * 0.03)),
    clamp01(base_hsl.s + ((Math.random() - 0.5) * 0.08)),
    clamp01(base_hsl.l + ((Math.random() - 0.5) * 0.08))
  );
  const foliage_material = new THREE.MeshStandardMaterial({
    'color': tree_foliage_color,
    'roughness': 0.85,
    'metalness': 0.0,
    'flatShading': true
  });
  const max_tilt_radians = Math.PI * 0.02;
  const max_segments = 10;
  let previous_apex_height = null;
  let previous_radius = null;
  let previous_segment_height = null;

  for (let i = 0; i < max_segments; i++) {
    let segment_height;
    let segment_base_height;
    if (i === 0) {
      segment_height = crown_height_target * (0.34 + (Math.random() * 0.14));
      segment_base_height = trunk_height * (0.9 + (Math.random() * 0.08));
    } else {
      segment_height = previous_segment_height * (0.94 + (Math.random() * 0.02));
      segment_height = Math.max(8, segment_height);
      const overlap = previous_segment_height * (0.5 + ((Math.random() - 0.5) * 0.06));
      segment_base_height = previous_apex_height - overlap;
    }

    const progress = Math.max(
      0,
      Math.min(1, (segment_base_height - trunk_height) / Math.max(1, crown_height_target))
    );
    const radius_variation = 0.9 + (Math.random() * 0.16);
    let radius = Math.max(
      2,
      ((clamped_diameter / 2) * (1 - (progress * 0.75))) * radius_variation
    );
    if (previous_radius !== null) {
      const overlap = previous_apex_height - segment_base_height;
      const previous_overlap_ratio = Math.max(
        0,
        Math.min(1, overlap / previous_segment_height)
      );
      const previous_overlap_radius = previous_radius * previous_overlap_ratio;
      const min_radius_for_overlap = previous_overlap_radius * (1.06 + (Math.random() * 0.05));
      const max_radius_for_taper = previous_radius * (0.94 + (Math.random() * 0.03));
      radius = Math.max(radius, min_radius_for_overlap);
      radius = Math.min(radius, max_radius_for_taper);
      if (radius < min_radius_for_overlap) {
        radius = min_radius_for_overlap;
      }
    }
    radius = Math.max(2, radius);

    const foliage_geometry = new THREE.ConeGeometry(radius, segment_height, 6);
    foliage_geometry.rotateX(-Math.PI / 2);
    const cone_material = foliage_material.clone();
    cone_material.color.offsetHSL(
      (Math.random() - 0.5) * 0.01,
      (Math.random() - 0.5) * 0.03,
      (Math.random() - 0.5) * 0.03
    );
    const foliage_mesh = new THREE.Mesh(foliage_geometry, cone_material);
    foliage_mesh.position.z = -(segment_base_height + (segment_height / 2));
    const tilt_direction = Math.random() * Math.PI * 2;
    const tilt_amount = Math.random() * max_tilt_radians;
    foliage_mesh.rotation.x = Math.cos(tilt_direction) * tilt_amount;
    foliage_mesh.rotation.y = Math.sin(tilt_direction) * tilt_amount;
    foliage_mesh.rotation.z = (Math.random() - 0.5) * 0.2;
    foliage_mesh.castShadow = true;
    foliage_mesh.receiveShadow = false;
    foliage_mesh.userData.is_environment = true;
    foliage_mesh.userData.is_tree_foliage = true;
    foliage_mesh.userData.base_tree_foliage_color = foliage_mesh.material.color.getHex();
    tree.add(foliage_mesh);
    this.register_tree_wind_mesh_(foliage_mesh, {
      'stiffness': 1.1,
      'max_sway_ratio': 0.045
    });

    previous_apex_height = segment_base_height + segment_height;
    previous_radius = radius;
    previous_segment_height = segment_height;

    if (previous_apex_height >= clamped_height) {
      break;
    }
  }

  return tree;
};


/**
 * Sample XY offset from a stick curve at a height measured from the stick base.
 *
 * @param {object} curve
 * @param {Array<{x: number, y: number}>} curve.controls
 * @param {number} curve.height
 * @param {number} height_from_base
 *
 * @return {{x: number, y: number}}
 */
beestat.component.scene.prototype.sample_stick_curve_offset_ = function(curve, height_from_base) {
  if (
    curve === undefined ||
    curve.controls === undefined ||
    curve.controls.length < 2 ||
    curve.height === undefined ||
    curve.height <= 0
  ) {
    return {'x': 0, 'y': 0};
  }

  const t = Math.max(0, Math.min(1, height_from_base / curve.height));
  const scaled = t * (curve.controls.length - 1);
  const index = Math.floor(scaled);
  const next_index = Math.min(curve.controls.length - 1, index + 1);
  const blend = scaled - index;

  return {
    'x': THREE.MathUtils.lerp(curve.controls[index].x, curve.controls[next_index].x, blend),
    'y': THREE.MathUtils.lerp(curve.controls[index].y, curve.controls[next_index].y, blend)
  };
};


/**
 * Create a low-poly tapered stick mesh with slight bend.
 *
 * @param {object} config
 *
 * @return {{mesh: THREE.Mesh, curve: {controls: Array<{x: number, y: number}>, height: number}, radius_top: number, radius_bottom: number, height: number}}
 */
beestat.component.scene.prototype.create_stick_mesh_ = function(config) {
  const height = Math.max(1, config.height || 10);
  const radius_bottom = Math.max(0.15, config.radius_bottom || 1);
  const taper_end_ratio = config.taper_end_ratio === undefined
    ? null
    : Math.max(0, Math.min(1, config.taper_end_ratio));
  const taper_max_ratio = config.taper_max_ratio === undefined
    ? null
    : Math.max(0, Math.min(1, config.taper_max_ratio));
  const resolved_top_ratio = taper_max_ratio === null
    ? taper_end_ratio
    : (1 - taper_max_ratio);
  const radius_top = Math.max(
    0,
    resolved_top_ratio === null
      ? (config.radius_top === undefined ? (radius_bottom * 0.7) : config.radius_top)
      : (radius_bottom * resolved_top_ratio)
  );
  const radial_segments = Math.max(3, config.radial_segments || 7);
  const segments = Math.max(1, Math.round(height / 12));
  const control_count = Math.max(2, config.control_count || 5);
  const max_drift = Math.max(0, config.max_drift || 0);
  const direction_jitter = config.direction_jitter || (radius_bottom * 0.15);
  const straight_start_ratio = Math.max(0, Math.min(0.9, config.straight_start_ratio || 0));
  const taper_start_ratio = Math.max(0, Math.min(0.95, config.taper_start_ratio || 0));

  const controls = [{'x': 0, 'y': 0}];
  let drift_x = 0;
  let drift_y = 0;
  for (let i = 1; i < control_count; i++) {
    const progress = i / (control_count - 1);
    drift_x += (Math.random() - 0.5) * direction_jitter;
    drift_y += (Math.random() - 0.5) * direction_jitter;
    const drift_length = Math.sqrt((drift_x * drift_x) + (drift_y * drift_y));
    const drift_limit = max_drift * progress;
    if (drift_length > drift_limit && drift_length > 0) {
      const scale = drift_limit / drift_length;
      drift_x *= scale;
      drift_y *= scale;
    }
    controls.push({'x': drift_x, 'y': drift_y});
  }

  const curve = {
    'controls': controls,
    'height': height
  };

  const geometry = new THREE.CylinderGeometry(
    radius_bottom,
    radius_bottom,
    height,
    radial_segments,
    segments
  );
  geometry.rotateX(-Math.PI / 2);

  const position = geometry.attributes.position;
  for (let i = 0; i < position.count; i++) {
    const vertex_z = position.getZ(i);
    const height_from_base = (height / 2) - vertex_z;
    const height_ratio = Math.max(0, Math.min(1, height_from_base / height));
    const taper_progress = height_ratio <= taper_start_ratio
      ? 0
      : (height_ratio - taper_start_ratio) / Math.max(0.0001, 1 - taper_start_ratio);
    const target_radius = THREE.MathUtils.lerp(radius_bottom, radius_top, taper_progress);
    const taper_scale = target_radius / radius_bottom;
    position.setX(i, position.getX(i) * taper_scale);
    position.setY(i, position.getY(i) * taper_scale);

    const offset = this.sample_stick_curve_offset_(curve, height_from_base);
    if (straight_start_ratio > 0) {
      const straight_height = height * straight_start_ratio;
      const bend_blend = height_from_base <= straight_height
        ? (height_from_base / Math.max(0.0001, straight_height))
        : 1;
      position.setX(i, position.getX(i) + (offset.x * bend_blend));
      position.setY(i, position.getY(i) + (offset.y * bend_blend));
    } else {
      position.setX(i, position.getX(i) + offset.x);
      position.setY(i, position.getY(i) + offset.y);
    }
  }
  position.needsUpdate = true;
  geometry.computeVertexNormals();

  const mesh = new THREE.Mesh(geometry, config.material);
  mesh.castShadow = config.cast_shadow !== false;
  mesh.receiveShadow = config.receive_shadow === true;
  mesh.userData.is_environment = true;

  return {
    'mesh': mesh,
    'curve': curve,
    'radius_top': radius_top,
    'radius_bottom': radius_bottom,
    'height': height
  };
};


/**
 * Get round/oval branch count from tree height.
 *
 * @param {number} height Total tree height.
 *
 * @return {number}
 */
beestat.component.scene.prototype.get_round_tree_branch_count_ = function(height) {
  return Math.max(1, Math.round(beestat.component.scene.round_tree_branches_per_height * Math.max(0, height || 0)));
};


/**
 * Get normalized branch length factor f(x) for round/oval trees.
 *
 * `x` is normalized distance from trunk base to top in [0, 1]:
 * - 0 = trunk base (ground side)
 * - 1 = trunk top
 *
 * @param {string} tree_type round|oval
 * @param {number} x Normalized distance up trunk [0, 1]
 * @param {number} profile_start_ratio Lower bound for canopy profile in [0, 1).
 *   Lower values use more trunk height, higher values use less.
 *
 * @return {number} Branch length factor in [0, 1]
 */
beestat.component.scene.prototype.get_branch_length = function(tree_type, x, profile_start_ratio = 0.5) {
  switch (tree_type) {
    case 'oval':
      // Same circular profile as round, but start lower to elongate canopy.
      const oval_start = Math.max(0, Math.min(0.9999, profile_start_ratio - 0.2));
      const oval_span = Math.max(0.0001, 1 - oval_start);
      const oval_u = (x - oval_start) / oval_span;
      const oval_t = (oval_u * 2) - 1;
      return x < oval_start || x > 1
        ? 0
        : Math.max(0, Math.min(1, Math.sqrt(Math.max(0, 1 - (oval_t * oval_t)))));
    case 'round':
    default:
      // Round equation over x in [start, 1] using a true circle cross-section:
      // u = (x - start) / (1 - start), t = 2u - 1, f(x) = sqrt(max(0, 1 - t^2))
      // `start` lowers from 0.5 toward 0 for wide/short trees.
      const start = Math.max(0, Math.min(0.9999, profile_start_ratio));
      const span = Math.max(0.0001, 1 - start);
      const u = (x - start) / span;
      const t = (u * 2) - 1;
      return x < start || x > 1
        ? 0
        : Math.max(0, Math.min(1, Math.sqrt(Math.max(0, 1 - (t * t)))));
  }
};



/**
 * Create a low-poly round canopy tree scaffold (trunk + first-level branches).
 *
 * @param {number} height Total tree height.
 * @param {number} max_diameter Maximum canopy diameter.
 * @param {boolean} has_foliage Whether foliage should be rendered.
 * @param {string=} canopy_shape Canopy profile passed only to get_branch_length().
 *
 * @return {THREE.Group}
 */
beestat.component.scene.prototype.create_round_tree_ = function(height, max_diameter, has_foliage, canopy_shape = 'round') {
  const self = this;
  const tree = new THREE.Group();
  tree.userData.is_environment = true;
  tree.userData.is_tree = true;
  const max_canopy_radius = Math.max(0.5, max_diameter / 2);
  // Use more of trunk height for round profiles when canopy is wide/short.
  // If height == diameter, start reaches 0 (full [0, 1] range).
  const round_canopy_span_ratio = Math.max(0, Math.min(1, max_diameter / Math.max(1, height)));
  const round_canopy_start_ratio = Math.max(0, 1 - round_canopy_span_ratio);
  const canopy_profile_start_ratio = Math.max(0, Math.min(0.9999, round_canopy_start_ratio));

  const wood_material = new THREE.MeshStandardMaterial({
    'color': 0x6a4d2f,
    'roughness': 0.9,
    'metalness': 0.0,
    'flatShading': true
  });

  const trunk_height = height;
  const trunk_radius_bottom = Math.max(1.5, trunk_height * 0.03);
  const trunk_stick = this.create_stick_mesh_({
    'height': trunk_height,
    'radius_bottom': trunk_radius_bottom,
    'radial_segments': 7,
    'control_count': 6,
    'max_drift': 8,
    'direction_jitter': 3,
    'taper_start_ratio': 0.35,
    'taper_max_ratio': 0.72,
    'material': wood_material,
    'receive_shadow': true
  });
  const trunk = trunk_stick.mesh;
  trunk.position.z = -(trunk_height / 2) + Math.max(0.7, trunk_radius_bottom * 0.14);
  tree.add(trunk);
  this.register_tree_wind_mesh_(trunk, {
    'stiffness': 2.2,
    'max_sway_ratio': 0.012
  });
  this.add_tree_ground_contact_(tree, trunk_radius_bottom, 0x6a4d2f);

  // Single branch layer: starts halfway up trunk and thins/shortens toward the top.
  const branch_count = this.get_round_tree_branch_count_(height);
  const branches = new THREE.Group();
  branches.userData.is_environment = true;
  const branch_axis = new THREE.Vector3(0, 0, -1);
  const foliage = new THREE.Group();
  foliage.userData.is_environment = true;
  const foliage_material = new THREE.MeshStandardMaterial({
    'color': 0x4f9f2f,
    'roughness': 0.82,
    'metalness': 0.0,
    'flatShading': true,
    'side': THREE.DoubleSide
  });
  const create_canopy_from_branch_function_ = function() {
    const center_height = trunk_height * 0.7;
    const center_offset_raw = self.sample_stick_curve_offset_(trunk_stick.curve, center_height);
    const top_offset = self.sample_stick_curve_offset_(trunk_stick.curve, trunk_height);
    // Base canopy center; upper canopy vertices are additionally aligned per-vertex to trunk tip.
    const center_offset = {
      'x': THREE.MathUtils.lerp(center_offset_raw.x, top_offset.x, 0.45),
      'y': THREE.MathUtils.lerp(center_offset_raw.y, top_offset.y, 0.45)
    };
    const center = new THREE.Vector3(center_offset.x, center_offset.y, -center_height);
    const base_radius = Math.max(4, max_canopy_radius * 0.96);
    const geometry = new THREE.IcosahedronGeometry(1, 2);
    const positions = geometry.attributes.position;
    const irregularity = 0.08 + (Math.random() * 0.08);
    const noise_phase_a = Math.random() * Math.PI * 2;
    const noise_phase_b = Math.random() * Math.PI * 2;
    const noise_phase_c = Math.random() * Math.PI * 2;
    const noise_freq_a = 2.7 + (Math.random() * 1.2);
    const noise_freq_b = 2.3 + (Math.random() * 1.2);
    const noise_freq_c = 1.2 + (Math.random() * 0.9);
    const lobe_count = 2 + Math.floor(Math.random() * 4);
    const lobe_amplitude = 0.05 + (Math.random() * 0.08);
    const lobe_phase = Math.random() * Math.PI * 2;
    const squash_x = 0.93 + (Math.random() * 0.14);
    const squash_y = 0.93 + (Math.random() * 0.14);
    const z_wobble = trunk_height * (0.007 + (Math.random() * 0.007));
    const tip_cap_strength = trunk_radius_bottom * (0.85 + (Math.random() * 0.45));
    const tip_round_power = 1.6 + (Math.random() * 1.1);
    const tip_bump_strength = 0.16 + (Math.random() * 0.24);
    const canopy_drift_theta = Math.random() * Math.PI * 2;
    const canopy_drift_radius = max_canopy_radius * 0.02;
    const canopy_drift_x = Math.cos(canopy_drift_theta) * canopy_drift_radius;
    const canopy_drift_y = Math.sin(canopy_drift_theta) * canopy_drift_radius;
    // Derive where canopy profile actually begins from the active branch-length
    // function so canopy bottom matches branches for all shapes.
    let canopy_geometry_start_ratio = canopy_profile_start_ratio;
    const canopy_start_samples = 40;
    const canopy_start_threshold = 0.005;
    for (let sample_index = 0; sample_index <= canopy_start_samples; sample_index++) {
      const sample_ratio = (sample_index / canopy_start_samples) * canopy_profile_start_ratio;
      const sample_length = self.get_branch_length(
        canopy_shape,
        sample_ratio,
        canopy_profile_start_ratio
      );
      if (sample_length > canopy_start_threshold) {
        canopy_geometry_start_ratio = sample_ratio;
        break;
      }
    }

    for (let i = 0; i < positions.count; i++) {
      const x = positions.getX(i);
      const y = positions.getY(i);
      const z = positions.getZ(i);
      const normalized_height = Math.max(0, Math.min(1, (z + 1) / 2));
      // Sample canopy directly in the same profile range as branches.
      const canopy_ratio =
        canopy_geometry_start_ratio +
        (normalized_height * (1 - canopy_geometry_start_ratio));
      const canopy_z = -trunk_height * canopy_ratio;
      // Avoid exact-zero sampling at the profile start to prevent rare base pinches.
      const canopy_sample_ratio = Math.min(1, Math.max(
        canopy_ratio,
        canopy_geometry_start_ratio + 0.012
      ));
      const base_factor = Math.max(
        0,
        Math.min(1, self.get_branch_length(canopy_shape, canopy_sample_ratio, canopy_profile_start_ratio))
      );
      const canopy_factor = base_factor;

      const radial_length = Math.sqrt((x * x) + (y * y));
      const hx = radial_length > 0.0001 ? x / radial_length : 1;
      const hy = radial_length > 0.0001 ? y / radial_length : 0;
      const theta = Math.atan2(hy, hx);

      const noise =
        (Math.sin((hx * noise_freq_a) + (hy * (noise_freq_b - 0.4)) + (canopy_ratio * (noise_freq_a + noise_freq_b)) + noise_phase_a) * 0.5) +
        (Math.cos((hx * (noise_freq_b + 0.3)) - (hy * noise_freq_a) - (canopy_ratio * (noise_freq_b + 1.6)) + noise_phase_b) * 0.35) +
        (Math.sin((canopy_ratio * (noise_freq_c + 6.6)) + (hx * noise_freq_c) + noise_phase_c) * 0.15);
      const lobe = 1 + (Math.sin((theta * lobe_count) + (canopy_ratio * 4.4) + lobe_phase) * lobe_amplitude);
      const organic_scale = canopy_factor <= 0 ? 1 : (1 + (noise * irregularity));
      const radius = base_radius * canopy_factor * organic_scale * lobe;
      // Minimal base guard in the first canopy slice to avoid rare pinches
      // without materially changing taller-tree profiles.
      const base_cover_t = Math.max(0, Math.min(1, normalized_height / 0.12));
      const min_radius_for_base_cover =
        trunk_radius_bottom * 0.18 * (1 - base_cover_t);
      // Ensure the canopy retains a small cap around the tip so trunk never pokes through.
      const top_cover_t = Math.max(0, Math.min(1, (canopy_ratio - 0.84) / 0.16));
      const min_radius_for_tip_cover = trunk_radius_bottom * 0.55 * top_cover_t;
      const covered_radius = Math.max(
        radius,
        min_radius_for_base_cover,
        min_radius_for_tip_cover
      );
      const radius_x = covered_radius * squash_x;
      const radius_y = covered_radius * squash_y;
      const canopy_z_offset = Math.sin((theta * (lobe_count + 1)) + lobe_phase) * z_wobble * canopy_factor;
      const top_alignment_t = Math.max(0, Math.min(1, (canopy_ratio - 0.72) / 0.28));
      const center_x = THREE.MathUtils.lerp(
        center.x + canopy_drift_x,
        top_offset.x,
        top_alignment_t
      );
      const center_y = THREE.MathUtils.lerp(
        center.y + canopy_drift_y,
        top_offset.y,
        top_alignment_t
      );
      const top_center_weight = Math.pow(Math.max(0, 1 - radial_length), tip_round_power);
      const tip_bump = 0.5 + (0.5 * Math.sin((theta * (lobe_count + 2)) + lobe_phase + noise_phase_c));
      const tip_cap_lift = tip_cap_strength * top_cover_t * top_center_weight * (1 + (tip_bump * tip_bump_strength));
      const capped_z_offset = canopy_z_offset * (1 - (top_cover_t * 0.85));

      positions.setXYZ(
        i,
        center_x + (hx * radius_x),
        center_y + (hy * radius_y),
        canopy_z + capped_z_offset - tip_cap_lift
      );
    }
    positions.needsUpdate = true;
    geometry.computeVertexNormals();

    const canopy_mesh = new THREE.Mesh(geometry, foliage_material.clone());
    canopy_mesh.userData.is_tree_foliage = true;
    canopy_mesh.userData.base_tree_foliage_color = canopy_mesh.material.color.getHex();
    return {
      'mesh': canopy_mesh
    };
  };
  const branch_height_samples = [];
  const recursive_depth_limit = Math.max(
    0,
    Math.round(Number(beestat.component.scene.fixed_tree_branch_depth || 0))
  );
  const children_per_branch = 1;
  if (has_foliage === true && this.tree_foliage_meshes_ === undefined) {
    this.tree_foliage_meshes_ = [];
  }
  if (has_foliage === true && this.tree_branch_groups_ === undefined) {
    this.tree_branch_groups_ = [];
  }

  const min_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(15);
  const max_sub_branch_fork_angle_radians = THREE.MathUtils.degToRad(30);
  const recent_primary_branch_angle_limit = 10;
  const get_angle_distance = function(angle_a, angle_b) {
    return Math.abs(Math.atan2(Math.sin(angle_a - angle_b), Math.cos(angle_a - angle_b)));
  };
  const get_weighted_primary_branch_theta = function(existing_angles) {
    if (existing_angles.length === 0) {
      return Math.random() * Math.PI * 2;
    }

    const candidate_count = Math.max(18, Math.min(56, branch_count * 4));
    const candidates = [];
    let total_weight = 0;
    for (let i = 0; i < candidate_count; i++) {
      const theta = Math.random() * Math.PI * 2;
      let min_distance = Math.PI;
      for (let j = 0; j < existing_angles.length; j++) {
        min_distance = Math.min(min_distance, get_angle_distance(theta, existing_angles[j]));
      }
      // Any angle is possible, but wider gaps get a higher chance.
      const normalized_distance = min_distance / Math.PI;
      const weight = 0.08 + Math.pow(normalized_distance, 2.4);
      total_weight += weight;
      candidates.push({
        'theta': theta,
        'weight': weight
      });
    }

    let pick = Math.random() * total_weight;
    for (let i = 0; i < candidates.length; i++) {
      pick -= candidates[i].weight;
      if (pick <= 0) {
        return candidates[i].theta;
      }
    }

    return candidates[candidates.length - 1].theta;
  };
  const get_primary_branch_direction = function(existing_angles) {
    const theta = get_weighted_primary_branch_theta(existing_angles);
    const upward_bias = -(0.34 + (Math.random() * 0.26));
    return {
      'theta': theta,
      'direction': new THREE.Vector3(Math.cos(theta), Math.sin(theta), upward_bias).normalize()
    };
  };
  const get_forked_child_direction = function(parent_direction, child_index, child_count) {
    const parent = parent_direction.clone().normalize();
    const world_up = new THREE.Vector3(0, 0, 1);
    let fork_axis = new THREE.Vector3().crossVectors(parent, world_up);
    if (fork_axis.lengthSq() < 1e-6) {
      fork_axis = new THREE.Vector3().crossVectors(parent, new THREE.Vector3(1, 0, 0));
    }
    fork_axis.normalize();

    const side = child_count <= 1 ? 1 : (child_index % 2 === 0 ? -1 : 1);
    const fork_angle =
      min_sub_branch_fork_angle_radians +
      (Math.random() * (max_sub_branch_fork_angle_radians - min_sub_branch_fork_angle_radians));
    const forked = parent.clone().applyQuaternion(
      new THREE.Quaternion().setFromAxisAngle(fork_axis, side * fork_angle)
    );
    // Add a small roll around the parent axis so forks don't look planar.
    const roll_angle = (Math.random() - 0.5) * THREE.MathUtils.degToRad(8);
    forked.applyQuaternion(
      new THREE.Quaternion().setFromAxisAngle(parent, roll_angle)
    );
    return forked.normalize();
  };
  for (let i = 0; i < branch_count; i++) {
    const stratified = branch_count <= 1 ? 0.5 : (i / (branch_count - 1));
    const jittered = stratified + ((Math.random() - 0.5) * 0.25 / branch_count);
    branch_height_samples.push(Math.max(0, Math.min(1, jittered)));
  }
  for (let i = branch_height_samples.length - 1; i > 0; i--) {
    const swap_index = Math.floor(Math.random() * (i + 1));
    const temp = branch_height_samples[i];
    branch_height_samples[i] = branch_height_samples[swap_index];
    branch_height_samples[swap_index] = temp;
  }

  const get_stick_point_world = function(branch_info, ratio) {
    const clamped_ratio = Math.max(0, Math.min(1, ratio));
    const along_height = branch_info.length * clamped_ratio;
    const local_offset = self.sample_stick_curve_offset_(branch_info.stick.curve, along_height);
    const local_point = new THREE.Vector3(
      local_offset.x,
      local_offset.y,
      (branch_info.length / 2) - along_height
    );
    return branch_info.mesh.localToWorld(local_point);
  };

  const create_branch = function(base, direction, length, radius_bottom) {
    const horizontal_direction_length = Math.sqrt(
      (direction.x * direction.x) + (direction.y * direction.y)
    );
    if (horizontal_direction_length > 0) {
      const base_horizontal_radius = Math.sqrt((base.x * base.x) + (base.y * base.y));
      const max_length_from_diameter =
        (max_canopy_radius - base_horizontal_radius) / horizontal_direction_length;
      if (Number.isFinite(max_length_from_diameter) === true) {
        length = Math.max(0, Math.min(length, max_length_from_diameter));
      }
    }
    length = Math.max(0, length);
    if (length < 1) {
      return null;
    }

    const branch_stick = self.create_stick_mesh_({
      'height': length,
      'radius_bottom': radius_bottom,
      'radial_segments': 7,
      'control_count': 6,
      'max_drift': length * 0.24,
      'direction_jitter': length * 0.12,
      'straight_start_ratio': 0.2,
      'taper_start_ratio': 0.2,
      'taper_max_ratio': 1,
      'material': wood_material,
      'receive_shadow': false
    });
    const branch = branch_stick.mesh;
    branch.position.copy(base).addScaledVector(direction, (length / 2) - (radius_bottom * 0.45));
    branch.quaternion.setFromUnitVectors(branch_axis, direction);
    branches.add(branch);
    branch.updateMatrixWorld(true);
    self.register_tree_wind_mesh_(branch, {
      'stiffness': 1.7,
      'max_sway_ratio': 0.02
    });

    return {
      'mesh': branch,
      'stick': branch_stick,
      'length': length,
      'radius_bottom': radius_bottom,
      'direction': direction.clone()
    };
  };

  const add_sub_branches = function(parent_branch, depth) {
    if (depth >= recursive_depth_limit) {
      return;
    }

    for (let j = 0; j < children_per_branch; j++) {
      const attach_ratio = children_per_branch <= 1
        ? 0.6
        : 0.35 + (j / (children_per_branch - 1)) * 0.3;
      const attach_point = get_stick_point_world(parent_branch, attach_ratio);
      const child_length = parent_branch.length * 0.62;
      const child_radius_bottom = Math.max(0.15, parent_branch.radius_bottom * 0.62);
      const child_direction = get_forked_child_direction(
        parent_branch.direction,
        j,
        children_per_branch
      );
      const child_branch = create_branch(
        attach_point,
        child_direction,
        child_length,
        child_radius_bottom
      );
      if (child_branch === null) {
        continue;
      }
      add_sub_branches(child_branch, depth + 1);
    }
  };

  const primary_branch_angles = [];
  for (let i = 0; i < branch_count; i++) {
    const base_height_ratio = branch_height_samples[i];
    const base_height = trunk_height * base_height_ratio;
    const base_offset = this.sample_stick_curve_offset_(trunk_stick.curve, base_height);
    const branch_length_factor = this.get_branch_length(
      canopy_shape,
      base_height_ratio,
      canopy_profile_start_ratio,
    );
    const branch_length = max_canopy_radius * branch_length_factor;
    if (branch_length <= 0) {
      continue;
    }
    const branch_radius_bottom = Math.max(0.35, trunk_radius_bottom * (0.42 - (base_height_ratio * 0.26)));
    const base = new THREE.Vector3(
      base_offset.x,
      base_offset.y,
      trunk.position.z + (trunk_height / 2) - base_height
    );
    const primary_direction_result = get_primary_branch_direction(primary_branch_angles);
    const primary_direction = primary_direction_result.direction;

    const primary_branch = create_branch(
      base,
      primary_direction,
      branch_length,
      branch_radius_bottom
    );
    if (primary_branch === null) {
      continue;
    }
    primary_branch_angles.push(primary_direction_result.theta);
    if (primary_branch_angles.length > recent_primary_branch_angle_limit) {
      primary_branch_angles.shift();
    }
    add_sub_branches(primary_branch, 0);
  }

  if (has_foliage === true) {
    const canopy_seed = this.get_seed_from_parts_([
      this.active_tree_seed_ === undefined ? this.get_scene_setting_('random_seed') : this.active_tree_seed_,
      'canopy'
    ]);
    const canopy_result = this.with_random_seed_(canopy_seed, function() {
      return create_canopy_from_branch_function_();
    });
    const canopy_mesh = canopy_result.mesh;
    canopy_mesh.castShadow = true;
    canopy_mesh.receiveShadow = false;
    canopy_mesh.userData.is_environment = true;
    foliage.add(canopy_mesh);
    this.tree_foliage_meshes_.push(canopy_mesh);
    this.register_tree_wind_mesh_(canopy_mesh, {
      'stiffness': 1.0,
      'max_sway_ratio': 0.04
    });
  }

  if (has_foliage === true) {
    this.tree_branch_groups_.push(branches);
  }
  branches.visible = true;
  tree.add(branches);
  if (has_foliage === true) {
    tree.add(foliage);
  }

  return tree;
};


/**
 * Create a low-poly oval canopy tree.
 *
 * @param {number} height Total tree height.
 * @param {number} max_diameter Maximum canopy diameter.
 * @param {boolean} has_foliage Whether foliage should be rendered.
 *
 * @return {THREE.Group}
 */
beestat.component.scene.prototype.create_oval_tree_ = function(height, max_diameter, has_foliage) {
  return this.create_round_tree_(height, max_diameter, has_foliage, 'oval');
};
