Files
forma-engine/engine/geometry.ts
T

253 lines
7.7 KiB
TypeScript

import {
entity,
uid,
type Vec3,
type Geometry,
validateGeometry,
} from "./schema.ts";
const cross = (a: number[], b: number[], c: number[]) =>
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
export function extrude(profile: number[][], depth = 1): Geometry {
if (
!Array.isArray(profile) ||
profile.length < 3 ||
profile.length > 256 ||
!Number.isFinite(depth) ||
depth <= 0 ||
!profile.every((p) => p.length === 2 && p.every(Number.isFinite))
)
throw Error("Нужен простой замкнутый профиль из 3–256 точек");
const points = profile.map((p) => [...p]);
let area = points.reduce(
(s, p, i) =>
s +
p[0] * points[(i + 1) % points.length][1] -
points[(i + 1) % points.length][0] * p[1],
0,
);
if (Math.abs(area) < 1e-8) throw Error("Нулевая площадь");
if (area < 0) points.reverse();
const n = points.length;
for (let i = 0; i < n; i++)
for (let j = i + 2; j < n; j++) {
if (i === 0 && j === n - 1) continue;
const a = points[i],
b = points[(i + 1) % n],
c = points[j],
d = points[(j + 1) % n];
if (
cross(a, b, c) * cross(a, b, d) < 0 &&
cross(c, d, a) * cross(c, d, b) < 0
)
throw Error("Профиль пересекает себя");
}
const indices: number[] = [],
left = points.map((_, i) => i);
let guard = 0;
while (left.length > 3) {
let clipped = false;
for (let j = 0; j < left.length; j++) {
const a = left[(j + left.length - 1) % left.length],
b = left[j],
c = left[(j + 1) % left.length];
if (cross(points[a], points[b], points[c]) <= 1e-8) continue;
if (
left.some(
(k) =>
k !== a &&
k !== b &&
k !== c &&
cross(points[a], points[b], points[k]) >= 0 &&
cross(points[b], points[c], points[k]) >= 0 &&
cross(points[c], points[a], points[k]) >= 0,
)
)
continue;
indices.push(a, b, c, a + n, c + n, b + n);
left.splice(j, 1);
clipped = true;
break;
}
if (!clipped || guard++ > 256)
throw Error("Невозможно триангулировать профиль");
}
indices.push(
left[0],
left[1],
left[2],
left[0] + n,
left[2] + n,
left[1] + n,
);
const positions: number[] = [];
for (const y of [0, depth])
for (const [x, z] of points) positions.push(x, y, z);
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
indices.push(i, i + n, j + n, i, j + n, j);
}
const g = { positions, indices };
validateGeometry(g);
return g;
}
export function lathe(profile: number[][], segments = 24): Geometry {
if (
!Number.isInteger(segments) ||
segments < 3 ||
segments > 128 ||
!Array.isArray(profile) ||
profile.length < 2 ||
profile.length > 256 ||
!profile.every(
(p) => p.length === 2 && p.every(Number.isFinite) && p[0] >= 0,
)
)
throw Error("Нужен профиль [радиус, высота] и 3–128 сегментов");
const positions: number[] = [],
indices: number[] = [];
for (const [r, y] of profile)
for (let j = 0; j < segments; j++) {
const a = (j / segments) * Math.PI * 2;
positions.push(Math.cos(a) * r, y, Math.sin(a) * r);
}
for (let i = 0; i < profile.length - 1; i++)
for (let j = 0; j < segments; j++) {
const a = i * segments + j,
b = i * segments + ((j + 1) % segments),
c = a + segments,
d = b + segments;
indices.push(a, c, b, b, c, d);
}
const g = { positions, indices };
validateGeometry(g);
return g;
}
export function transformed(
g: Geometry,
offset: Vec3 = [0, 0, 0],
scale: Vec3 = [1, 1, 1],
): Geometry {
const positions = g.positions.map((v, i) => v * scale[i % 3] + offset[i % 3]),
indices = [...g.indices];
if (scale[0] * scale[1] * scale[2] < 0)
for (let i = 0; i < indices.length; i += 3)
[indices[i + 1], indices[i + 2]] = [indices[i + 2], indices[i + 1]];
return { positions, indices };
}
export function arena({
width = 22,
depth = 18,
seed = 42,
obstacles = 8,
}: any = {}) {
width = Math.max(8, Math.min(80, width));
depth = Math.max(8, Math.min(80, depth));
obstacles = Math.max(0, Math.min(80, Math.round(obstacles)));
let v = seed | 0;
const random = () => {
v = (Math.imul(v, 1664525) + 1013904223) | 0;
return (v >>> 0) / 4294967296;
};
const root = entity("Сад · уровень"),
nodes = [root];
const add = (
name: string,
pos: Vec3,
size: Vec3,
color: string,
type = "box",
collision = true,
) => {
const n = entity(
name,
{
mesh: { type, size },
material: { color, roughness: 0.92 },
...(collision
? { collider: { shape: "box", size }, rigidbody: { type: "fixed" } }
: {}),
},
pos,
);
n.parentId = root.id;
nodes.push(n);
return n;
};
add("Каменное основание", [0, -0.5, 0], [width, 1, depth], "#b6aa91");
add(
"Светлый песок",
[0, 0.025, 0],
[width - 0.5, 0.05, depth - 0.5],
"#d1c9ae",
"box",
false,
);
add("Северная стена", [0, 0.6, depth / 2], [width, 1.2, 0.45], "#b0a48a");
add("Южная стена", [0, 0.6, -depth / 2], [width, 1.2, 0.45], "#b0a48a");
add("Западная стена", [-width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
add("Восточная стена", [width / 2, 0.6, 0], [0.45, 1.2, depth], "#b0a48a");
for (let z = -Math.floor(depth / 2) + 1; z < depth / 2; z += 1.1)
add(
"Плитка тропы",
[0, 0.07, z],
[1.7, 0.09, 0.9],
"#b6b49c",
"box",
false,
);
for (const x of [-width / 2 + 1.2, width / 2 - 1.2])
for (const z of [-depth / 2 + 1.2, depth / 2 - 1.2]) {
add("Основание колонны", [x, 0.15, z], [1.4, 0.3, 1.4], "#a9a187");
add("Колонна", [x, 1.6, z], [0.7, 2.7, 0.7], "#c5bca2", "cylinder");
add("Капитель", [x, 3, z], [1.2, 0.3, 1.2], "#cfc5ab");
}
for (let i = 0; i < obstacles; i++) {
const x = (i % 2 ? -1 : 1) * (2 + random() * (width / 2 - 4)),
z = (random() - 0.5) * (depth - 5);
if (i % 3 === 0) {
add("Ствол", [x, 0.7, z], [0.25, 1.4, 0.25], "#80765c", "cylinder");
add("Крона", [x, 1.7, z], [2, 1.7, 2], "#859887", "icosphere", false);
} else {
const n = add(
"Обломок",
[x, 0.35, z],
[0.9 + random(), 0.7, 0.8 + random()],
"#999b84",
);
n.transform.rotation[1] = random() * 2;
}
}
return nodes;
}
export function character({
height = 1.8,
color = "#7d9b8a",
name = "Персонаж · модель",
}: any = {}) {
const root = entity(name),
nodes = [root],
scale = height / 1.8;
const add = (name: string, p: Vec3, size: Vec3, c: string, type = "box") => {
const n = entity(
name,
{
mesh: { type, size: size.map((v) => v * scale) },
material: { color: c, roughness: 0.8 },
},
p.map((v) => v * scale) as Vec3,
);
n.parentId = root.id;
nodes.push(n);
};
add("Торс", [0, 1.1, 0], [0.6, 0.65, 0.35], color);
add("Голова", [0, 1.65, 0], [0.4, 0.4, 0.4], "#d5b994", "sphere");
add("Капюшон", [0, 1.8, -0.04], [0.5, 0.25, 0.48], color, "icosphere");
for (const sign of [-1, 1]) {
add("Нога", [sign * 0.16, 0.37, 0], [0.24, 0.7, 0.25], "#61685f");
add("Сапог", [sign * 0.16, 0.09, 0.09], [0.28, 0.18, 0.42], "#55594f");
add("Рука", [sign * 0.42, 1.05, 0], [0.18, 0.65, 0.2], color);
}
add("Клинок", [0.53, 0.98, 0.28], [0.08, 0.85, 0.12], "#c4cac0");
return nodes;
}