"use strict"; // Constraint solver for link-like tools. // Owns attachment resolution and distance constraints for flexible ropes and rigid rods. (function (global) { function reciprocatorAxis(item) { if (global.TarinaiMechanicalSystem?.axis) return global.TarinaiMechanicalSystem.axis(item); const fallback = typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); const a = typeof normalizedItemAngle === "function" ? normalizedItemAngle(item?.reciprocatorAxisAngle, fallback) : (Number.isFinite(Number(item?.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : fallback); return { x: Math.cos(a), y: Math.sin(a), angle: a }; } function reciprocatorHalfTravel(item) { return Math.max(24, Number(item?.reciprocatorTravel || 150) || 150) * 0.5; } function resetReciprocatorAnchorIfMissing(item) { if (!Number.isFinite(Number(item.reciprocatorAnchorX))) item.reciprocatorAnchorX = item.x || 0; if (!Number.isFinite(Number(item.reciprocatorAnchorY))) item.reciprocatorAnchorY = item.y || 0; } function reciprocatorPhaseFromPosition(item) { resetReciprocatorAnchorIfMissing(item); const axis = reciprocatorAxis(item); const travel = reciprocatorHalfTravel(item); const dx = (Number(item.x || 0) || 0) - (Number(item.reciprocatorAnchorX || 0) || 0); const dy = (Number(item.y || 0) || 0) - (Number(item.reciprocatorAnchorY || 0) || 0); return clamp((dx * axis.x + dy * axis.y) / Math.max(10, travel), -1, 1); } function resolveEndpointObject(endpoint, worldRef) { if (!endpoint || !worldRef) return null; const allowFuzzy = endpoint.fuzzyResolve === true || endpoint.legacyFuzzy === true; if (endpoint.kind === "tarinai") { let found = (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id); if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { found = (worldRef.tarinai || []).find(t => t && !t.dead && distXY(t.x, t.y, endpoint.x, endpoint.y) <= Math.max(40, (t.radius || 20) * 1.6)); if (found) { endpoint.id = found.id; endpoint.fuzzyResolve = false; endpoint.legacyFuzzy = false; } } return found || null; } let found = (worldRef.items || []).find(it => it && !it.dead && it.id === endpoint.id); if (!found && allowFuzzy && Number.isFinite(endpoint.x) && Number.isFinite(endpoint.y)) { found = (worldRef.items || []).find(it => it && !it.dead && it.type === endpoint.type && distXY(it.x, it.y, endpoint.x, endpoint.y) <= Math.max(44, (it.r || 20) * 1.8)); if (found) { endpoint.id = found.id; endpoint.fuzzyResolve = false; endpoint.legacyFuzzy = false; } } return found || null; } function itemAngle(item) { return typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item?.angle) || 0); } function isMechanicalLinkTarget(item) { return Boolean(item && !item.dead && global.TarinaiMechanicalSystem?.isMechanicalType?.(item.type)); } function localToWorld(item, lx, ly) { const a = itemAngle(item); const c = Math.cos(a), s = Math.sin(a); return { x: (Number(item?.x || 0) || 0) + lx * c - ly * s, y: (Number(item?.y || 0) || 0) + lx * s + ly * c, }; } function worldToLocal(item, x, y) { const a = itemAngle(item); const dx = (Number(x || 0) || 0) - (Number(item?.x || 0) || 0); const dy = (Number(y || 0) || 0) - (Number(item?.y || 0) || 0); const c = Math.cos(-a), s = Math.sin(-a); return { x: dx * c - dy * s, y: dx * s + dy * c }; } function nearestMechanicalLocalPoint(item, lx, ly) { if (!isMechanicalLinkTarget(item)) return null; const segments = global.TarinaiMechanicalSystem?.sanitizeSegments?.(item) || []; let best = null; for (const seg of segments) { if (!Array.isArray(seg) || seg.length < 4) continue; const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0; const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0; const vx = x2 - x1, vy = y2 - y1; const len2 = vx * vx + vy * vy; if (len2 < 16) continue; const t = clamp(((lx - x1) * vx + (ly - y1) * vy) / len2, 0, 1); const x = x1 + vx * t; const y = y1 + vy * t; const d = Math.hypot(lx - x, ly - y); if (!best || d < best.d) best = { x, y, d }; } return best; } function snapEndpointToTarget(endpoint, worldRef) { const obj = resolveEndpointObject(endpoint, worldRef); if (!endpoint || !isMechanicalLinkTarget(obj)) return endpoint; let local; if (!endpoint.center && (Number.isFinite(Number(endpoint.localX)) || Number.isFinite(Number(endpoint.localY)))) { local = { x: Number(endpoint.localX || 0) || 0, y: Number(endpoint.localY || 0) || 0 }; } else if (Number.isFinite(Number(endpoint.x)) && Number.isFinite(Number(endpoint.y))) { local = worldToLocal(obj, endpoint.x, endpoint.y); } else { local = { x: 0, y: 0 }; } const snapped = nearestMechanicalLocalPoint(obj, local.x, local.y); if (!snapped) return endpoint; const world = localToWorld(obj, snapped.x, snapped.y); endpoint.localX = snapped.x; endpoint.localY = snapped.y; endpoint.x = world.x; endpoint.y = world.y; endpoint.center = false; return endpoint; } function remapLinksForEditedMechanicalItem(worldRef, target, reason = "mechanical-shape-edited") { if (!worldRef || !isMechanicalLinkTarget(target)) return 0; let changed = 0; const remap = (endpoint) => { if (!endpoint || endpoint.kind !== "item" || endpoint.id !== target.id) return false; const beforeX = Number(endpoint.localX || 0) || 0; const beforeY = Number(endpoint.localY || 0) || 0; const beforeWorldX = Number(endpoint.x || 0) || 0; const beforeWorldY = Number(endpoint.y || 0) || 0; snapEndpointToTarget(endpoint, worldRef); const localDelta = Math.hypot((Number(endpoint.localX || 0) || 0) - beforeX, (Number(endpoint.localY || 0) || 0) - beforeY); const worldDelta = Math.hypot((Number(endpoint.x || 0) || 0) - beforeWorldX, (Number(endpoint.y || 0) || 0) - beforeWorldY); return localDelta > 0.001 || worldDelta > 0.001; }; for (const item of worldRef.items || []) { if (!item || item.dead || (item.type !== "rope" && item.type !== "rod")) continue; const a = remap(item.linkA); const b = remap(item.linkB); if (a || b) { item.world = worldRef; changed += 1; } } if (remap(worldRef.pendingLinkEndpoint)) changed += 1; if (changed) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.(reason); } return changed; } function endpointWorld(endpoint, worldRef, depth = 0, seen = null) { const obj = resolveEndpointObject(endpoint, worldRef); if (obj && (obj.type === "rope" || obj.type === "rod") && worldRef) obj.world = worldRef; if (!obj) return Number.isFinite(endpoint?.x) && Number.isFinite(endpoint?.y) ? { x: endpoint.x, y: endpoint.y, obj: null } : null; if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint.attachT))) { if (depth > 6) return { x: obj.x || 0, y: obj.y || 0, obj }; const guard = seen || new Set(); if (guard.has(obj.id)) return { x: obj.x || 0, y: obj.y || 0, obj }; guard.add(obj.id); const a = endpointWorld(obj.linkA, worldRef, depth + 1, guard); const b = endpointWorld(obj.linkB, worldRef, depth + 1, guard); guard.delete(obj.id); if (a && b) { const t = clamp(Number(endpoint.attachT || 0), 0, 1); return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, obj }; } } const lx = Number(endpoint.localX || 0) || 0; const ly = Number(endpoint.localY || 0) || 0; if (endpoint.center || (!lx && !ly)) return { x: obj.x || 0, y: obj.y || 0, obj }; const a = itemAngle(obj); const c = Math.cos(a), s = Math.sin(a); return { x: (obj.x || 0) + lx * c - ly * s, y: (obj.y || 0) + lx * s + ly * c, obj }; } function endpointMass(obj) { if (!obj || obj.dead) return Infinity; if (obj._heldByPlayer || obj.playerHeld) return Infinity; if (obj.radius && obj.name) return 1.0; if (obj.type === "ball") return 0.7; if (obj.type === "zunchi") return 0.55; if (obj.type === "rope" || obj.type === "rod") return 1.6; if (global.TarinaiMechanicalSystem?.isMechanicalType?.(obj.type)) return 3.8; if (obj.type && (obj.type.includes("fence") || obj.type === "bed" || obj.type === "nest_box")) return obj.type.includes("fence") ? Infinity : 5.5; return 2.2; } function moveEndpointObject(obj, dx, dy, endpoint, dt, strength = 1, depth = 0) { if (!obj || !Number.isFinite(dx) || !Number.isFinite(dy)) return false; if (obj._heldByPlayer || obj.playerHeld) return false; if (depth > 5) return false; const moveX = dx * strength; const moveY = dy * strength; if (Math.hypot(moveX, moveY) < 0.001) return false; if ((obj.type === "rope" || obj.type === "rod") && Number.isFinite(Number(endpoint?.attachT))) { const t = clamp(Number(endpoint.attachT || 0), 0, 1); let changed = false; const wA = 1 - t; const wB = t; if (obj.linkA) changed = moveEndpointObject(resolveEndpointObject(obj.linkA, obj.world || null), moveX * wA, moveY * wA, obj.linkA, dt, 0.72, depth + 1) || changed; if (obj.linkB) changed = moveEndpointObject(resolveEndpointObject(obj.linkB, obj.world || null), moveX * wB, moveY * wB, obj.linkB, dt, 0.72, depth + 1) || changed; return changed; } if (obj.radius && obj.name) { obj.x += moveX; obj.y += moveY; obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.18, -180, 180); obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.18, -180, 180); obj.target = null; return true; } if (obj.type === "reciprocator") { const mech = global.TarinaiMechanicalSystem; mech?.resetAnchor?.(obj) || resetReciprocatorAnchorIfMissing(obj); const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y }; if (mech?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 34, moveY * 34, 0.55)) return true; const axis = mech?.axis?.(obj) || reciprocatorAxis(obj); const along = moveX * axis.x + moveY * axis.y; obj.reciprocatorVelocity = clamp((obj.reciprocatorVelocity || 0) + along / Math.max(0.016, dt || 0.016) * 0.035, -150, 150); return Math.abs(along) > 0.001; } if (obj.type === "rotator") { const p = endpointWorld(endpoint, obj.world || null) || { x: obj.x, y: obj.y }; if (global.TarinaiMechanicalSystem?.applyImpulse?.(obj, p.x || obj.x, p.y || obj.y, moveX * 38, moveY * 38, 0.58)) return true; const rx = (p.x || obj.x) - obj.x; const ry = (p.y || obj.y) - obj.y; const torque = rx * moveY - ry * moveX; obj.rotatorAngularVelocity = clamp((obj.rotatorAngularVelocity || 0) + torque * 0.0009, -2.0, 2.0); return true; } if (obj.type && obj.type.includes("fence")) return false; obj.prevX = obj.x; obj.prevY = obj.y; obj.x += moveX; obj.y += moveY; obj.vx = clamp((obj.vx || 0) + moveX / Math.max(0.016, dt || 0.016) * 0.10, -140, 140); obj.vy = clamp((obj.vy || 0) + moveY / Math.max(0.016, dt || 0.016) * 0.10, -140, 140); return true; } function updateFlexibleMidParticle(item, a, b, dt, length) { if (!a || !b || item.type !== "rope") return false; const ax = a.x, ay = a.y, bx = b.x, by = b.y; const dx = bx - ax, dy = by - ay; const d = Math.max(0.001, Math.hypot(dx, dy)); if (!Number.isFinite(item.linkMidX) || !Number.isFinite(item.linkMidY)) { item.linkMidX = (ax + bx) * 0.5; item.linkMidY = (ay + by) * 0.5 + Math.max(4, (length - d) * 0.25); item.linkMidVX = 0; item.linkMidVY = 0; } const prevX = item.linkMidX; const prevY = item.linkMidY; const step = Math.max(0.008, Math.min(0.05, Number(dt || 0.016) || 0.016)); item.linkMidVX = (Number(item.linkMidVX || 0) || 0) * Math.pow(0.62, step * 60); item.linkMidVY = (Number(item.linkMidVY || 0) || 0) * Math.pow(0.62, step * 60) + 90 * step; item.linkMidX += item.linkMidVX * step; item.linkMidY += item.linkMidVY * step; const half = Math.max(12, length * 0.5); if (d >= length * 0.985) { item.linkMidX = (ax + bx) * 0.5; item.linkMidY = (ay + by) * 0.5; item.linkMidVX *= 0.25; item.linkMidVY *= 0.25; } else { for (let i = 0; i < 4; i += 1) { for (const pnt of [[ax, ay], [bx, by]]) { const vx = item.linkMidX - pnt[0]; const vy = item.linkMidY - pnt[1]; const dd = Math.max(0.001, Math.hypot(vx, vy)); if (dd > half) { item.linkMidX = pnt[0] + vx / dd * half; item.linkMidY = pnt[1] + vy / dd * half; } } } const minY = Math.min(ay, by) - length * 0.35; const maxY = Math.max(ay, by) + length * 0.70; item.linkMidY = clamp(item.linkMidY, minY, maxY); } item.linkMidVX = (item.linkMidX - prevX) / step; item.linkMidVY = (item.linkMidY - prevY) / step; return Math.hypot(item.linkMidX - prevX, item.linkMidY - prevY) > 0.01; } function applyDistanceConstraint(item, a, b, dt, length, mode) { const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.max(0.001, Math.hypot(dx, dy)); const delta = d - length; if (mode === "rope" && delta <= 0) return false; if (mode === "rod" && Math.abs(delta) < 0.35) return false; const nx = dx / d; const ny = dy / d; const ma = endpointMass(a.obj); const mb = endpointMass(b.obj); const ia = Number.isFinite(ma) ? 1 / Math.max(0.1, ma) : 0; const ib = Number.isFinite(mb) ? 1 / Math.max(0.1, mb) : 0; const sum = ia + ib || 1; let changed = false; if (mode === "rope") { const corr = Math.min(delta, Math.max(2, length * 0.08)); changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 0.85) || changed; changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 0.85) || changed; } else { const iterations = 3; for (let i = 0; i < iterations; i += 1) { const corr = clamp(delta / iterations, -Math.max(3, length * 0.18), Math.max(3, length * 0.18)); changed = moveEndpointObject(a.obj, nx * corr * (ia / sum), ny * corr * (ia / sum), item.linkA, dt, 1.05) || changed; changed = moveEndpointObject(b.obj, -nx * corr * (ib / sum), -ny * corr * (ib / sum), item.linkB, dt, 1.05) || changed; } } return changed; } function primeLinkState(item, a, b) { const d = Math.max(0.001, distXY(a.x, a.y, b.x, b.y)); const length = Math.max(24, Number(item.linkLength || d) || d); item.x = (a.x + b.x) * 0.5; item.y = (a.y + b.y) * 0.5; item.r = Math.max(18, length * 0.5); item.linkLength = length; item.linkA.x = a.x; item.linkA.y = a.y; item.linkB.x = b.x; item.linkB.y = b.y; return length; } function updateFlexibleLink(item, dt, worldRef) { if (!item || item.dead || item.type !== "rope") return false; item.amount = 999; item.world = worldRef || item.world || null; const a = endpointWorld(item.linkA, worldRef); const b = endpointWorld(item.linkB, worldRef); if (!a || !b || !a.obj || !b.obj) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; } const length = primeLinkState(item, a, b); let changed = updateFlexibleMidParticle(item, a, b, dt, length); changed = applyDistanceConstraint(item, a, b, dt, length, "rope") || changed; if (changed && worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rope-tension"); } return changed; } function updateRigidLink(item, dt, worldRef) { if (!item || item.dead || item.type !== "rod") return false; item.amount = 999; item.world = worldRef || item.world || null; const a = endpointWorld(item.linkA, worldRef); const b = endpointWorld(item.linkB, worldRef); if (!a || !b || !a.obj || !b.obj) { item.amount = 0; if (worldRef) worldRef.drawListDirty = true; return true; } const length = primeLinkState(item, a, b); const changed = applyDistanceConstraint(item, a, b, dt, length, "rod"); if (changed && worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("rod-constraint"); } return changed; } function updateLink(item, dt, worldRef) { if (item?.type === "rope") return updateFlexibleLink(item, dt, worldRef); if (item?.type === "rod") return updateRigidLink(item, dt, worldRef); return false; } function pointSegmentDistance(px, py, ax, ay, bx, by) { const vx = bx - ax; const vy = by - ay; const len2 = vx * vx + vy * vy || 1; const t = clamp(((px - ax) * vx + (py - ay) * vy) / len2, 0, 1); return Math.hypot(px - (ax + vx * t), py - (ay + vy * t)); } const api = Object.freeze({ endpointWorld, resolveEndpointObject, endpointMass, moveEndpointObject, snapEndpointToTarget, remapLinksForEditedMechanicalItem, updateFlexibleLink, updateRigidLink, updateLink, pointSegmentDistance, }); global.TarinaiConstraintSystem = api; global.TarinaiLinkRuntime = Object.freeze({ endpointWorld, pointSegmentDistance, snapEndpointToTarget, remapLinksForEditedMechanicalItem }); })(typeof window !== "undefined" ? window : globalThis);