Quick update, I started to explore the generalization of your idea @Evgeni_Popov but it was too complex and maybe not solvable. First, angles over 90° need a scale that’s not uniform. Second, with large shapes and short path segments, the positioning of the extra points is problematic.
I ended up using the mitre extrusion technique, and dropping scale/rotation capabilities when this approach is used, which is consistent with the choices made in BJS itself. Sharing here in case anyone else goes through the same thought process.
Also, I’ve converted the code to Typescript and added cap option, sharing below in case it’s useful to anyone:
import { Ray } from '@babylonjs/core/Culling/ray'
import { Vector3 } from '@babylonjs/core/Maths/math'
import { Plane } from '@babylonjs/core/Maths/math.plane'
import { RibbonBuilder } from '@babylonjs/core/Meshes/Builders/ribbonBuilder'
import { Mesh } from '@babylonjs/core/Meshes/mesh'
import { Scene } from '@babylonjs/core/scene'
interface MiterExtrudeOptions {
shape: Vector3[]
path: Vector3[]
scale: number
cap: boolean
}
export const miterExtrude = (name: string, options: MiterExtrudeOptions, scene: Scene): Mesh => {
const { shape: originalShape, path, scale, cap } = options
// Apply scale to shape
const shape = originalShape.map((pt) => new Vector3(pt.x * scale, pt.y * scale, 0))
const nbPoints = path.length
const line = Vector3.Zero()
const nextLine = Vector3.Zero()
let axisX = Vector3.Zero()
let axisY = Vector3.Zero()
let axisZ = Vector3.Zero()
const nextAxisX = Vector3.Zero()
const nextAxisY = Vector3.Zero()
const nextAxisZ = Vector3.Zero()
let startPoint = Vector3.Zero()
const nextStartPoint = Vector3.Zero()
const bisector = Vector3.Zero()
let planeParallel: Vector3
let planeNormal: Vector3
let plane: Plane
let ray: Ray
let distance: number
const allPaths: Vector3[][] = []
const tempPaths: Vector3[][] = []
// Initial camera up vector (use world up as default)
const cameraUp = scene.activeCamera?.position || Vector3.Up()
for (let s = 0; s < shape.length; s++) {
path[1].subtractToRef(path[0], line)
axisZ = line.clone().normalize()
axisX = Vector3.Cross(cameraUp, axisZ).normalize()
axisY = Vector3.Cross(axisZ, axisX)
startPoint = path[0].add(axisX.scale(shape[s].x)).add(axisY.scale(shape[s].y))
const ribbonPath = [startPoint.clone()]
for (let p = 0; p < nbPoints - 2; p++) {
path[p + 2].subtractToRef(path[p + 1], nextLine)
nextAxisZ.copyFrom(nextLine.normalize())
Vector3.CrossToRef(cameraUp, nextAxisZ, nextAxisX)
nextAxisX.normalize()
Vector3.CrossToRef(nextAxisZ, nextAxisX, nextAxisY)
nextAxisZ.subtractToRef(axisZ, bisector)
planeParallel = Vector3.Cross(nextAxisZ, axisZ)
planeNormal = Vector3.Cross(planeParallel, bisector)
plane = Plane.FromPositionAndNormal(path[p + 1], planeNormal)
ray = new Ray(startPoint, axisZ)
distance = ray.intersectsPlane(plane) || 0
startPoint.addToRef(axisZ.scale(distance), nextStartPoint)
ribbonPath.push(nextStartPoint.clone())
axisX = nextAxisX.clone()
axisY = nextAxisY.clone()
axisZ = nextAxisZ.clone()
startPoint = nextStartPoint.clone()
}
// Last Point
planeNormal = axisZ
plane = Plane.FromPositionAndNormal(path[nbPoints - 1], planeNormal)
ray = new Ray(startPoint, axisZ)
distance = ray.intersectsPlane(plane) || 0
startPoint.addToRef(axisZ.scale(distance), nextStartPoint)
ribbonPath.push(nextStartPoint.clone())
tempPaths.push(ribbonPath)
}
// If caps are enabled, add center points at start and end
if (cap) {
// Calculate center point at start
const startCapPoints = tempPaths.map((p) => p[0])
const startCenter = startCapPoints.reduce((sum, pt) => sum.add(pt), Vector3.Zero()).scale(1 / startCapPoints.length)
// Calculate center point at end
const endCapPoints = tempPaths.map((p) => p[p.length - 1])
const endCenter = endCapPoints.reduce((sum, pt) => sum.add(pt), Vector3.Zero()).scale(1 / endCapPoints.length)
// Add center points to each ribbon path
for (const ribbonPath of tempPaths) {
allPaths.push([startCenter, ...ribbonPath, endCenter])
}
} else {
allPaths.push(...tempPaths)
}
return RibbonBuilder.CreateRibbon(
name,
{
pathArray: allPaths,
sideOrientation: Mesh.DOUBLESIDE,
closeArray: false,
closePath: false,
},
scene
)
}