tarinai/js/geometry_helpers.js
2026-07-16 22:12:03 +09:00

235 lines
9.6 KiB
JavaScript

"use strict";
// Layer: math/geometry
// Shared primitive geometry helpers used by placement, collision, mechanical bodies,
// editor hit-testing, and world obstacle queries.
(function (global) {
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
});
const clampValue = global.TarinaiCoreHelpers?.clampNumber || ((v, min, max) => Math.max(min, Math.min(max, v)));
function orientedRectAabb(cx, cy, halfW, halfH, angle) {
const c = Math.cos(num(angle));
const s = Math.sin(num(angle));
const ex = Math.abs(c) * num(halfW) + Math.abs(s) * num(halfH);
const ey = Math.abs(s) * num(halfW) + Math.abs(c) * num(halfH);
return { left: num(cx) - ex, right: num(cx) + ex, top: num(cy) - ey, bottom: num(cy) + ey, cos: c, sin: s };
}
function rectAabb(rect) {
if (!rect) return null;
if (!rect.oriented) return rect;
const aabb = orientedRectAabb(rect.cx, rect.cy, rect.halfW, rect.halfH, rect.angle);
return {
left: num(rect.left, aabb.left), right: num(rect.right, aabb.right), top: num(rect.top, aabb.top), bottom: num(rect.bottom, aabb.bottom),
cx: num(rect.cx), cy: num(rect.cy), halfW: num(rect.halfW), halfH: num(rect.halfH), angle: num(rect.angle),
cos: Number.isFinite(rect.cos) ? rect.cos : aabb.cos,
sin: Number.isFinite(rect.sin) ? rect.sin : aabb.sin,
oriented: true, type: rect.type, item: rect.item,
};
}
function rectLocalPoint(rect, x, y) {
const dx = num(x) - num(rect?.cx);
const dy = num(y) - num(rect?.cy);
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
function rectWorldVector(rect, x, y) {
const c = Number.isFinite(rect?.cos) ? rect.cos : Math.cos(num(rect?.angle));
const s = Number.isFinite(rect?.sin) ? rect.sin : Math.sin(num(rect?.angle));
return { x: num(x) * c - num(y) * s, y: num(x) * s + num(y) * c };
}
function rectWorldNormal(rect, nx, ny) {
return rectWorldVector(rect, nx, ny);
}
function rectCorners(rect, padding = 0) {
const r = rectAabb(rect);
const pad = Math.max(0, num(padding));
if (!r) return [];
if (!r.oriented) {
return [
{ x: num(r.left) - pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.top) - pad },
{ x: num(r.right) + pad, y: num(r.bottom) + pad },
{ x: num(r.left) - pad, y: num(r.bottom) + pad },
];
}
const c = Number.isFinite(r.cos) ? r.cos : Math.cos(num(r.angle));
const s = Number.isFinite(r.sin) ? r.sin : Math.sin(num(r.angle));
const hw = num(r.halfW) + pad;
const hh = num(r.halfH) + pad;
const out = [];
for (const [lx, ly] of [[-hw, -hh], [hw, -hh], [hw, hh], [-hw, hh]]) {
out.push({ x: num(r.cx) + lx * c - ly * s, y: num(r.cy) + lx * s + ly * c });
}
return out;
}
function rectAxes(rect) {
if (!rect?.oriented) return [{ x: 1, y: 0 }, { x: 0, y: 1 }];
const c = Number.isFinite(rect.cos) ? rect.cos : Math.cos(num(rect.angle));
const s = Number.isFinite(rect.sin) ? rect.sin : Math.sin(num(rect.angle));
return [{ x: c, y: s }, { x: -s, y: c }];
}
function projectPoints(points, axis) {
let min = Infinity;
let max = -Infinity;
for (const p of points || []) {
const v = num(p.x) * axis.x + num(p.y) * axis.y;
if (v < min) min = v;
if (v > max) max = v;
}
return { min, max };
}
function aabbOverlap(a, b, padding = 0) {
const pad = Math.max(0, num(padding));
return Boolean(a && b && num(a.left) <= num(b.right) + pad && num(a.right) >= num(b.left) - pad
&& num(a.top) <= num(b.bottom) + pad && num(a.bottom) >= num(b.top) - pad);
}
function rectOverlapInfo(a, b, padding = 0) {
if (!aabbOverlap(a, b, padding)) return null;
const ptsA = rectCorners(a, padding);
const ptsB = rectCorners(b, padding);
if (!ptsA.length || !ptsB.length) return null;
let bestOverlap = Infinity;
let bestAxis = null;
for (const axis of [...rectAxes(a), ...rectAxes(b)]) {
const len = Math.hypot(axis.x, axis.y) || 1;
const n = { x: axis.x / len, y: axis.y / len };
const pa = projectPoints(ptsA, n);
const pb = projectPoints(ptsB, n);
const overlap = Math.min(pa.max, pb.max) - Math.max(pa.min, pb.min);
if (overlap <= 0) return null;
if (overlap < bestOverlap) { bestOverlap = overlap; bestAxis = n; }
}
if (!bestAxis) return null;
const acx = num(a.cx, (num(a.left) + num(a.right)) * 0.5);
const acy = num(a.cy, (num(a.top) + num(a.bottom)) * 0.5);
const bcx = num(b.cx, (num(b.left) + num(b.right)) * 0.5);
const bcy = num(b.cy, (num(b.top) + num(b.bottom)) * 0.5);
if ((bcx - acx) * bestAxis.x + (bcy - acy) * bestAxis.y < 0) bestAxis = { x: -bestAxis.x, y: -bestAxis.y };
return {
overlap: bestOverlap,
nx: bestAxis.x,
ny: bestAxis.y,
x: (Math.max(num(a.left), num(b.left)) + Math.min(num(a.right), num(b.right))) * 0.5,
y: (Math.max(num(a.top), num(b.top)) + Math.min(num(a.bottom), num(b.bottom))) * 0.5,
};
}
function rectsOverlap(a, b, margin = 0) {
return Boolean(rectOverlapInfo(a, b, margin));
}
function pointInRect(x, y, rect, padding = 0) {
if (!rect) return false;
const pad = Math.max(0, num(padding));
if (rect.oriented) {
const p = rectLocalPoint(rect, x, y);
return Math.abs(p.x) <= num(rect.halfW) + pad && Math.abs(p.y) <= num(rect.halfH) + pad;
}
return num(x) >= num(rect.left) - pad && num(x) <= num(rect.right) + pad
&& num(y) >= num(rect.top) - pad && num(y) <= num(rect.bottom) + pad;
}
function circleOverlapsRect(x, y, radius, rect, padding = 0) {
if (!rect) return false;
const rr = Math.max(0, num(radius)) + Math.max(0, num(padding));
if (rect.oriented) {
const local = rectLocalPoint(rect, x, y);
const clx = clampValue(local.x, -num(rect.halfW), num(rect.halfW));
const cly = clampValue(local.y, -num(rect.halfH), num(rect.halfH));
return Math.hypot(local.x - clx, local.y - cly) < rr;
}
const cx = clampValue(num(x), num(rect.left), num(rect.right));
const cy = clampValue(num(y), num(rect.top), num(rect.bottom));
return Math.hypot(num(x) - cx, num(y) - cy) < rr;
}
function pointSegmentDistance(px, py, ax, ay, bx, by) {
const dx = num(bx) - num(ax);
const dy = num(by) - num(ay);
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(num(px) - num(ax), num(py) - num(ay));
const u = clampValue(((num(px) - num(ax)) * dx + (num(py) - num(ay)) * dy) / lenSq, 0, 1);
return Math.hypot(num(px) - (num(ax) + dx * u), num(py) - (num(ay) + dy * u));
}
function segmentAabbHit(x1, y1, x2, y2, left, top, right, bottom) {
if ((x1 >= left && x1 <= right && y1 >= top && y1 <= bottom) || (x2 >= left && x2 <= right && y2 >= top && y2 <= bottom)) return true;
const minX = Math.min(left, right), maxX = Math.max(left, right);
const minY = Math.min(top, bottom), maxY = Math.max(top, bottom);
let t0 = 0, t1 = 1;
const clip = (p, q) => {
if (Math.abs(p) < 1e-9) return q >= 0;
const t = q / p;
if (p < 0) { if (t > t1) return false; if (t > t0) t0 = t; }
else { if (t < t0) return false; if (t < t1) t1 = t; }
return true;
};
const dx = num(x2) - num(x1);
const dy = num(y2) - num(y1);
return clip(-dx, num(x1) - minX) && clip(dx, maxX - num(x1)) && clip(-dy, num(y1) - minY) && clip(dy, maxY - num(y1));
}
function segmentIntersectsOrientedRect(x1, y1, x2, y2, r, padding = 0) {
if (!r) return false;
if (!r.oriented) return segmentAabbHit(x1, y1, x2, y2, r.left - padding, r.top - padding, r.right + padding, r.bottom + padding);
const a = rectLocalPoint(r, x1, y1);
const b = rectLocalPoint(r, x2, y2);
return segmentAabbHit(a.x, a.y, b.x, b.y, -(r.halfW || 0) - padding, -(r.halfH || 0) - padding, (r.halfW || 0) + padding, (r.halfH || 0) + padding);
}
function oneWayFenceAllowsMotion(rect, prevX, prevY, motionX, motionY) {
if (!rect?.oneWay) return false;
const nx = num(rect.oneWayNx);
const ny = num(rect.oneWayNy, -1);
const prevSide = (num(prevX) - num(rect.cx)) * nx + (num(prevY) - num(rect.cy)) * ny;
const motion = num(motionX) * nx + num(motionY) * ny;
const sourceMargin = Math.max(1, num(rect.halfH) * 0.2);
return motion > 0.01 || (prevSide < -sourceMargin && motion >= -0.01);
}
function oneWayFenceAllows(obj, rect, motionX = null, motionY = null) {
if (!rect?.oneWay || !obj) return false;
const hinted = Number.isFinite(Number(motionX)) || Number.isFinite(Number(motionY));
const vx = hinted ? num(motionX) : num(obj.vx) + num(obj.impulseVx);
const vy = hinted ? num(motionY) : num(obj.vy) + num(obj.impulseVy);
const prevX = Number.isFinite(Number(obj.prevX)) ? Number(obj.prevX) : num(obj.x) - vx * 0.016;
const prevY = Number.isFinite(Number(obj.prevY)) ? Number(obj.prevY) : num(obj.y) - vy * 0.016;
return oneWayFenceAllowsMotion(rect, prevX, prevY, vx, vy);
}
global.TarinaiGeometry = Object.freeze({
num,
clampValue,
orientedRectAabb,
rectAabb,
rectLocalPoint,
rectWorldVector,
rectWorldNormal,
rectCorners,
rectAxes,
projectPoints,
aabbOverlap,
rectOverlapInfo,
rectsOverlap,
pointInRect,
circleOverlapsRect,
pointSegmentDistance,
segmentAabbHit,
segmentIntersectsOrientedRect,
oneWayFenceAllowsMotion,
oneWayFenceAllows,
});
})(typeof window !== "undefined" ? window : globalThis);