grid-apps-cmms/src/void/sketch/geometry.js
2026-02-14 19:00:48 -05:00

1033 lines
40 KiB
JavaScript

/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../../ext/three.js';
import { space } from '../../moto/space.js';
import { api } from '../api.js';
import { isCircleCurve, isThreePointCircle, isCenterPointCircle } from './curve.js';
import {
SKETCH_HIT_POINT_PX,
SKETCH_HIT_LINE_PX,
SKETCH_MIN_LINE_LENGTH,
SKETCH_POINT_MERGE_EPS,
SKETCH_VIRTUAL_ORIGIN_ID
} from './constants.js';
function pointerDistance(event, pointerDown) {
if (!event || !pointerDown) return 0;
return Math.hypot((event.clientX || 0) - pointerDown.clientX, (event.clientY || 0) - pointerDown.clientY);
}
function collectInternalCircleEndpointIds(entities = []) {
const hidden = new Set();
for (const entity of entities) {
if (entity?.type !== 'arc' || !isCircleCurve(entity) || isThreePointCircle(entity)) continue;
const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null);
const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null);
if (aId) hidden.add(aId);
if (bId) hidden.add(bId);
}
return hidden;
}
function hitTestSketchEntity(event, feature) {
if (!event || !feature) {
return null;
}
const entities = Array.isArray(feature.entities) ? feature.entities : [];
const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities);
const basis = this.getSketchBasis(feature);
const screenPoint = this.getEventViewportXY(event);
if (!basis || !screenPoint) {
return null;
}
let bestPoint = null;
let bestLine = null;
const pointById = new Map();
const pointHitPx = SKETCH_HIT_POINT_PX;
for (const entity of entities) {
if (entity?.type === 'point' && entity.id) {
pointById.set(entity.id, entity);
}
}
for (const entity of entities) {
if (!entity?.id) continue;
if (entity.type === 'point') {
if (internalCircleEndpointIds.has(entity.id)) continue;
const world = this.sketchLocalToWorld(entity, basis);
const proj = api.overlay.project3Dto2D(world);
if (!proj?.visible) continue;
const dist = Math.hypot(screenPoint.x - proj.x, screenPoint.y - proj.y);
if (dist <= pointHitPx && (!bestPoint || dist < bestPoint.dist)) {
bestPoint = { id: entity.id, type: 'point', dist };
}
continue;
}
if (entity.type === 'line') {
const [a, b] = this.getLineEndpoints(entity, pointById);
if (!a || !b) continue;
const wa = this.sketchLocalToWorld(a, basis);
const wb = this.sketchLocalToWorld(b, basis);
const pa = api.overlay.project3Dto2D(wa);
const pb = api.overlay.project3Dto2D(wb);
if (!pa?.visible || !pb?.visible) continue;
const dist = this.distanceToSegmentPx(screenPoint.x, screenPoint.y, pa.x, pa.y, pb.x, pb.y);
if (dist <= SKETCH_HIT_LINE_PX && (!bestLine || dist < bestLine.dist)) {
bestLine = { id: entity.id, type: 'line', dist };
}
}
if (entity.type === 'arc') {
const center = this.getArcCenterLocalFromEntity(entity, pointById);
if (center && !isThreePointCircle(entity)) {
const wc = this.sketchLocalToWorld(center, basis);
const pc = api.overlay.project3Dto2D(wc);
if (pc?.visible) {
const cd = Math.hypot(screenPoint.x - pc.x, screenPoint.y - pc.y);
const bestIsArcCenter = bestPoint?.type === 'arc-center';
const sameDist = bestPoint ? Math.abs(cd - bestPoint.dist) <= 1e-6 : false;
if (cd <= pointHitPx && (
!bestPoint ||
cd < bestPoint.dist - 1e-6 ||
(bestIsArcCenter && sameDist)
)) {
bestPoint = { id: `arc-center:${entity.id}`, type: 'arc-center', dist: cd };
}
}
}
const [a, b] = this.getArcEndpoints(entity, pointById);
if (!a || !b) continue;
const sample = this.sampleArcPolyline(entity, a, b, 32);
let minDist = Infinity;
for (let i = 0; i < sample.length - 1; i++) {
const wa = this.sketchLocalToWorld(sample[i], basis);
const wb = this.sketchLocalToWorld(sample[i + 1], basis);
const pa = api.overlay.project3Dto2D(wa);
const pb = api.overlay.project3Dto2D(wb);
if (!pa?.visible || !pb?.visible) continue;
const dist = this.distanceToSegmentPx(screenPoint.x, screenPoint.y, pa.x, pa.y, pb.x, pb.y);
minDist = Math.min(minDist, dist);
}
if (minDist <= SKETCH_HIT_LINE_PX && (!bestLine || minDist < bestLine.dist)) {
bestLine = { id: entity.id, type: 'arc', dist: minDist };
}
}
}
const originProj = api.overlay.project3Dto2D(basis.origin);
if (originProj?.visible) {
const originDist = Math.hypot(screenPoint.x - originProj.x, screenPoint.y - originProj.y);
if (originDist <= pointHitPx && (!bestPoint || originDist < bestPoint.dist)) {
bestPoint = { id: SKETCH_VIRTUAL_ORIGIN_ID, type: 'point', dist: originDist };
}
}
return bestPoint || bestLine;
}
function getArcCenterLocalFromEntity(arc, pointById) {
const [a, b] = this.getArcEndpoints(arc, pointById);
if (!a || !b) return null;
if (isCircleCurve(arc)) {
const cx = Number(arc?.cx);
const cy = Number(arc?.cy);
if (Number.isFinite(cx) && Number.isFinite(cy)) {
return { x: cx, y: cy };
}
}
if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my)) {
const geom = this.computeArcGeometry(
{ x: a.x || 0, y: a.y || 0 },
{ x: b.x || 0, y: b.y || 0 },
{ x: arc.mx, y: arc.my }
);
if (geom) {
return { x: geom.cx, y: geom.cy };
}
}
if (Number.isFinite(arc?.cx) && Number.isFinite(arc?.cy)) {
return { x: arc.cx, y: arc.cy };
}
return null;
}
function getSketchEntityHitFromIntersections(intersections, feature) {
if (!intersections || !intersections.length) {
return null;
}
const rec = api.sketchRuntime?.getRecord?.(feature?.id);
const allowed = rec?.entityViews ? new Set(Array.from(rec.entityViews.keys())) : null;
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities);
let bestPoint = null;
let bestLine = null;
for (const hit of intersections) {
const id = hit?.object?.userData?.sketchEntityId;
if (!id) continue;
if (allowed && !allowed.has(id)) continue;
const type = hit.object.userData?.sketchEntityType || null;
if (type === 'profile') continue;
// Arc-center hits must keep their synthetic id (`arc-center:<arcId>`)
// so drag/snap code can resolve them unambiguously.
const refId = (type === 'arc-center') ? id : (hit.object.userData?.sketchEntityRefId || id);
if (type === 'point' && internalCircleEndpointIds.has(refId)) continue;
const cand = { id: refId, type, distance: hit.distance ?? Infinity };
if (type === 'point' || type === 'arc-center') {
const bestIsArcCenter = bestPoint?.type === 'arc-center';
const sameDist = bestPoint ? Math.abs((cand.distance ?? Infinity) - (bestPoint.distance ?? Infinity)) <= 1e-6 : false;
if (!bestPoint || cand.distance < bestPoint.distance - 1e-6 || (type === 'point' && bestIsArcCenter && sameDist)) {
bestPoint = cand;
}
} else if (!bestLine || cand.distance < bestLine.distance) {
bestLine = cand;
}
}
return bestPoint || bestLine || null;
}
function resolveSketchHit(event, intersections, feature) {
const rayHit = this.getSketchEntityHitFromIntersections(intersections, feature);
const screenHit = this.hitTestSketchEntity(event, feature);
if (screenHit?.type === 'point' || screenHit?.type === 'arc-center') {
return screenHit;
}
if (rayHit?.type === 'point' || rayHit?.type === 'arc-center') {
return rayHit;
}
return rayHit || screenHit || null;
}
function worldToSketchLocal(world, basis) {
if (!world || !basis) return null;
const rel = world.clone().sub(basis.origin);
return {
x: rel.dot(basis.xAxis),
y: rel.dot(basis.yAxis)
};
}
function resolveDerivedEdgeCandidate(event, intersections, feature) {
if (!event || !feature) return null;
const basis = this.getSketchBasis(feature);
if (!basis) return null;
const vp = this.getEventViewportXY(event);
if (!vp) return null;
const primary = this.getPrimarySurfaceHitFromIntersections?.(intersections || []) || null;
let faceKey = null;
let facePoint = null;
let solidId = '';
let faceId = NaN;
if (primary?.type === 'solid-face') {
const faceHit = primary.hit || null;
faceKey = String(faceHit?.key || '') || null;
facePoint = faceHit?.intersection?.point || null;
} else if (primary?.type === 'solid-edge') {
const edge = primary.hit || null;
solidId = String(edge?.solidId || '');
faceId = Number(edge?.faceId);
if (!solidId || !Number.isFinite(faceId)) {
const raw = String(edge?.key || '');
if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) {
const parts = raw.split(':');
faceId = Number(parts[parts.length - 2]);
solidId = parts.slice(1, -2).join(':');
}
}
if (solidId && Number.isFinite(faceId)) {
faceKey = `${solidId}:${faceId}`;
facePoint = edge?.intersection?.point || null;
}
}
if (!faceKey || !facePoint) {
const faceHit = api.solids?.getFaceHitFromIntersections?.(intersections || []) || null;
faceKey = String(faceHit?.key || '') || null;
facePoint = faceHit?.intersection?.point || null;
}
if (!faceKey || !facePoint) return null;
const splitAt = String(faceKey).lastIndexOf(':');
if (splitAt > 0) {
solidId = String(faceKey).substring(0, splitAt);
faceId = Number(String(faceKey).substring(splitAt + 1));
}
if (!solidId || !Number.isFinite(faceId)) return null;
const loops = api.solids?.getFaceBoundaryLoops?.(faceKey) || [];
if (!loops.length) return null;
const segments = [];
for (let li = 0; li < loops.length; li++) {
const loop = loops[li];
const points = Array.isArray(loop?.points) ? loop.points : [];
if (points.length < 2) continue;
const segIndices = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : [];
for (let si = 0; si + 1 < points.length; si++) {
const wa = points[si];
const wb = points[si + 1];
if (!wa || !wb) continue;
const pwa = api.overlay.project3Dto2D(wa);
const pwb = api.overlay.project3Dto2D(wb);
if (!pwa?.visible || !pwb?.visible) continue;
const dist = this.distanceToSegmentPx(vp.x, vp.y, pwa.x || 0, pwa.y || 0, pwb.x || 0, pwb.y || 0);
if (!Number.isFinite(dist)) continue;
const segIndex = Number(segIndices[si]);
segments.push({
loopIndex: li,
segPos: si,
segIndex: Number.isFinite(segIndex) ? segIndex : si,
closed: !!loop?.closed,
aWorld: wa.clone ? wa.clone() : new THREE.Vector3(Number(wa.x || 0), Number(wa.y || 0), Number(wa.z || 0)),
bWorld: wb.clone ? wb.clone() : new THREE.Vector3(Number(wb.x || 0), Number(wb.y || 0), Number(wb.z || 0)),
distPx: dist
});
}
}
if (!segments.length) return null;
segments.sort((l, r) =>
l.distPx - r.distPx
|| l.loopIndex - r.loopIndex
|| l.segPos - r.segPos
);
const bestSeg = segments[0];
const bestSegDist = Number(bestSeg?.distPx || Infinity);
// Only treat as edge-hover when pointer is genuinely near the edge.
// Otherwise keep face-hover path active so full boundary preview renders.
// Edge mode should only activate when genuinely near a boundary.
// Otherwise allow face mode to show the full boundary set.
const edgeHoverPx = Math.max(2.5, SKETCH_HIT_LINE_PX * 0.35);
if (!Number.isFinite(bestSegDist) || bestSegDist > edgeHoverPx) {
return null;
}
const a = bestSeg.aWorld;
const b = bestSeg.bWorld;
const mid = a.clone().add(b).multiplyScalar(0.5);
const aLocal = this.worldToSketchLocal(a, basis);
const bLocal = this.worldToSketchLocal(b, basis);
const midLocal = this.worldToSketchLocal(mid, basis);
if (!aLocal || !bLocal || !midLocal) return null;
const pwa = api.overlay.project3Dto2D(a);
const pwb = api.overlay.project3Dto2D(b);
const pwm = api.overlay.project3Dto2D(mid);
const pm = api.overlay.project3Dto2D(this.sketchLocalToWorld(midLocal, basis));
const pointHits = [];
if (pwa?.visible) pointHits.push({ kind: 'a', local: aLocal, dist: Math.hypot(vp.x - pwa.x, vp.y - pwa.y) });
if (pwb?.visible) pointHits.push({ kind: 'b', local: bLocal, dist: Math.hypot(vp.x - pwb.x, vp.y - pwb.y) });
if (pwm?.visible && pm?.visible) pointHits.push({ kind: 'mid', local: midLocal, dist: Math.hypot(vp.x - pwm.x, vp.y - pwm.y) });
pointHits.sort((l, r) => l.dist - r.dist);
const pointThreshold = Math.min(
SKETCH_HIT_POINT_PX * 0.45,
Math.max(2.25, bestSegDist * 0.35 + 0.5)
);
const hoverPoint = pointHits[0]
&& pointHits[0].dist <= pointThreshold
&& pointHits[0].dist <= (bestSegDist * 0.8)
? pointHits[0]
: null;
const hoverWorld = hoverPoint
? (hoverPoint.kind === 'a'
? { x: a.x, y: a.y, z: a.z }
: hoverPoint.kind === 'b'
? { x: b.x, y: b.y, z: b.z }
: { x: mid.x, y: mid.y, z: mid.z })
: null;
const loop = loops[bestSeg.loopIndex] || null;
const segLen = a.distanceTo(b);
const shortSegThreshold = 6;
const promoteLoop = !!(loop?.closed && Number.isFinite(segLen) && segLen <= shortSegThreshold);
const edgeKey = promoteLoop
? `faceedgeloop:${solidId}:${faceId}:${bestSeg.loopIndex}`
: `faceedge:${solidId}:${faceId}:${bestSeg.segIndex}`;
const solid = api.solids?.list?.().find?.(item => item?.id === solidId) || null;
const target = api.solids?.getSketchTargetForFaceKey?.(faceKey) || null;
const faceFrame = target?.frame || null;
const frameBasis = (() => {
if (!faceFrame?.origin || !faceFrame?.normal || !faceFrame?.x_axis) return null;
const origin = new THREE.Vector3(
Number(faceFrame.origin.x || 0),
Number(faceFrame.origin.y || 0),
Number(faceFrame.origin.z || 0)
);
const normal = new THREE.Vector3(
Number(faceFrame.normal.x || 0),
Number(faceFrame.normal.y || 0),
Number(faceFrame.normal.z || 1)
).normalize();
let xAxis = new THREE.Vector3(
Number(faceFrame.x_axis.x || 1),
Number(faceFrame.x_axis.y || 0),
Number(faceFrame.x_axis.z || 0)
);
xAxis.addScaledVector(normal, -xAxis.dot(normal));
if (xAxis.lengthSq() <= 1e-12) {
xAxis.set(1, 0, 0);
xAxis.addScaledVector(normal, -xAxis.dot(normal));
}
xAxis.normalize();
const yAxis = new THREE.Vector3().crossVectors(normal, xAxis).normalize();
return { origin, xAxis, yAxis };
})();
const toFaceLocal = world => {
if (!world || !frameBasis) return null;
const rel = world.clone().sub(frameBasis.origin);
return {
x: rel.dot(frameBasis.xAxis),
y: rel.dot(frameBasis.yAxis)
};
};
const localA = toFaceLocal(a);
const localB = toFaceLocal(b);
const localP = hoverWorld ? toFaceLocal(new THREE.Vector3(hoverWorld.x, hoverWorld.y, hoverWorld.z)) : null;
const pathWorldSegments = [];
const pathLocalSegments = [];
const pathSegmentKeys = [];
const pathSegmentEntityIds = [];
if (promoteLoop) {
const pts = Array.isArray(loop?.points) ? loop.points : [];
const segIdx = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : [];
for (let i = 0; i + 1 < pts.length; i++) {
const wa = pts[i];
const wb = pts[i + 1];
if (!wa || !wb) continue;
const la = this.worldToSketchLocal(wa, basis);
const lb = this.worldToSketchLocal(wb, basis);
if (!la || !lb) continue;
pathLocalSegments.push({ a: la, b: lb });
pathWorldSegments.push({
a: { x: Number(wa.x || 0), y: Number(wa.y || 0), z: Number(wa.z || 0) },
b: { x: Number(wb.x || 0), y: Number(wb.y || 0), z: Number(wb.z || 0) }
});
const segIndex = Number(segIdx?.[i]);
if (Number.isFinite(segIndex) && Number.isFinite(faceId) && solidId) {
const segKey = `faceedge:${solidId}:${faceId}:${segIndex}`;
pathSegmentKeys.push(segKey);
const ent = api.solids?.resolveCanonicalEdgeEntity?.(segKey) || null;
pathSegmentEntityIds.push(String(ent?.id || ''));
} else {
pathSegmentKeys.push('');
pathSegmentEntityIds.push('');
}
}
}
const canonicalEntity = api.solids?.resolveCanonicalEdgeEntity?.(edgeKey) || null;
const canonicalEntityId = String(canonicalEntity?.id || '');
const canonicalEntityKind = String(canonicalEntity?.kind || 'boundary-segment');
const out = {
type: 'solid-edge',
solidId,
solidFeatureId: solid?.source?.feature_id || null,
index: Number(bestSeg.segIndex ?? 0),
segDist: bestSegDist,
aLocal,
bLocal,
midLocal,
aWorld: { x: a.x, y: a.y, z: a.z },
bWorld: { x: b.x, y: b.y, z: b.z },
midWorld: { x: mid.x, y: mid.y, z: mid.z },
pathLocalSegments: pathLocalSegments.length ? pathLocalSegments : null,
pathWorldSegments: pathWorldSegments.length ? pathWorldSegments : null,
pathSegmentKeys: pathSegmentKeys.length ? pathSegmentKeys : null,
pathSegmentEntityIds: pathSegmentEntityIds.length ? pathSegmentEntityIds : null,
a: { x: a.x, y: a.y, z: a.z },
b: { x: b.x, y: b.y, z: b.z },
hoverPoint: hoverPoint ? { ...hoverPoint, world: hoverWorld } : null,
source: {
type: 'solid-edge',
entity: {
kind: canonicalEntityKind,
id: canonicalEntityId
},
solid_id: solidId,
solid_feature_id: solid?.source?.feature_id || null,
face_id: Number.isFinite(faceId) ? faceId : null,
face_frame: faceFrame || null,
face_key: faceKey || null,
boundary_segment_id: canonicalEntityId,
local_a: localA || null,
local_b: localB || null,
local_point: localP || null,
edge_key: String(edgeKey || ''),
edge_index: Number(bestSeg.segIndex ?? 0),
a: { x: a.x, y: a.y, z: a.z },
b: { x: b.x, y: b.y, z: b.z }
}
};
return out;
}
function projectFaceBoundaryToSketch(feature, faceKey) {
if (!feature || !faceKey) return null;
const basis = this.getSketchBasis(feature);
if (!basis) return null;
const loops = api.solids?.getFaceBoundaryLoops?.(faceKey) || [];
if (!loops.length) return null;
const out = [];
for (const loop of loops) {
const points = Array.isArray(loop?.points) ? loop.points : [];
if (points.length < 2) continue;
for (let i = 0; i + 1 < points.length; i++) {
const a = this.worldToSketchLocal(points[i], basis);
const b = this.worldToSketchLocal(points[i + 1], basis);
if (!a || !b) continue;
out.push({ a, b });
}
}
return out.length ? out : null;
}
function isSketchEventInViewport(event) {
if (!event) return true;
const { container } = space.internals();
if (!container) return true;
if (!event.target) return true;
return container.contains(event.target);
}
function getSketchHitLocalPoint(feature, hit) {
if (!hit?.id) {
return null;
}
const isArcCenter = hit?.type === 'arc-center' || String(hit.id).startsWith('arc-center:');
if (isArcCenter) {
const arcId = String(hit.id).startsWith('arc-center:')
? String(hit.id).substring('arc-center:'.length)
: String(hit.id);
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e]));
const arc = entities.find(entity => entity?.type === 'arc' && entity?.id === arcId) || null;
if (arc) {
const center = this.getArcCenterLocalFromEntity(arc, pointById);
if (center) return { x: center.x || 0, y: center.y || 0 };
}
}
if (hit.id === SKETCH_VIRTUAL_ORIGIN_ID) {
return { x: 0, y: 0 };
}
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
for (const entity of entities) {
if (entity?.type === 'point' && entity.id === hit.id) {
return { x: entity.x || 0, y: entity.y || 0 };
}
}
const rec = api.sketchRuntime?.getRecord?.(feature?.id);
const view = rec?.entityViews?.get?.(hit.id);
if (view?.type === 'point' && view.entity) {
return { x: view.entity.x || 0, y: view.entity.y || 0 };
}
return null;
}
function getSketchDragSnapTarget(event, feature, movedPointIds) {
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities);
const basis = this.getSketchBasis(feature);
const vp = this.getEventViewportXY(event);
if (!basis || !vp) {
return null;
}
const points = entities.filter(e => e?.type === 'point' && e.id && !internalCircleEndpointIds.has(e.id));
const moved = points.filter(p => movedPointIds?.has(p.id));
const others = points.filter(p => !movedPointIds?.has(p.id) && p.id !== SKETCH_VIRTUAL_ORIGIN_ID);
if (!moved.length) {
return null;
}
let target = null;
for (const p of others) {
const world = this.sketchLocalToWorld(p, basis);
const proj = api.overlay.project3Dto2D(world);
if (!proj?.visible) continue;
const d = Math.hypot(vp.x - proj.x, vp.y - proj.y);
if (d > SKETCH_HIT_POINT_PX * 1.8) continue;
if (!target || d < target.dist) {
target = { point: p, dist: d, type: 'point', hoveredId: p.id };
}
}
const byId = new Map(points.map(p => [p.id, p]));
for (const arc of entities) {
if (arc?.type !== 'arc' || !arc.id) continue;
if (isThreePointCircle(arc)) continue;
const center = this.getArcCenterLocalFromEntity(arc, byId);
if (!center) continue;
const world = this.sketchLocalToWorld(center, basis);
const proj = api.overlay.project3Dto2D(world);
if (!proj?.visible) continue;
const d = Math.hypot(vp.x - proj.x, vp.y - proj.y);
if (d > SKETCH_HIT_POINT_PX * 1.8) continue;
const arcCenterId = `arc-center:${arc.id}`;
if (!target || d < target.dist) {
target = { arc, center, dist: d, type: 'arc-center', hoveredId: arcCenterId };
}
}
if (!target) {
return null;
}
let nearestMoved = null;
const tx = target.type === 'arc-center' ? (target.center.x || 0) : (target.point.x || 0);
const ty = target.type === 'arc-center' ? (target.center.y || 0) : (target.point.y || 0);
for (const p of moved) {
const dx = (p.x || 0) - tx;
const dy = (p.y || 0) - ty;
const d = Math.hypot(dx, dy);
if (!nearestMoved || d < nearestMoved.dist) {
nearestMoved = { point: p, dist: d };
}
}
if (!nearestMoved) {
return null;
}
return {
targetType: target.type || 'point',
targetId: target.point?.id || null,
targetArcId: target.arc?.id || null,
hoveredId: target.hoveredId || target.point?.id || null,
movedId: nearestMoved.point.id
};
}
function findPointByCoord(feature, local, eps = SKETCH_POINT_MERGE_EPS) {
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
for (const entity of entities) {
if (entity?.type !== 'point' || !entity.id) continue;
if (Math.abs((entity.x || 0) - local.x) <= eps && Math.abs((entity.y || 0) - local.y) <= eps) {
return entity;
}
}
return null;
}
function ensureSketchPoint(sketch, local) {
sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : [];
const existing = this.findPointByCoord(sketch, local, SKETCH_POINT_MERGE_EPS);
if (existing) {
return existing;
}
const point = {
id: this.newSketchEntityId('point'),
type: 'point',
x: local.x,
y: local.y,
fixed: false
};
sketch.entities.push(point);
return point;
}
function getLineEndpoints(line, pointById) {
const aId = typeof line?.a === 'string' ? line.a : (typeof line?.p1_id === 'string' ? line.p1_id : null);
const bId = typeof line?.b === 'string' ? line.b : (typeof line?.p2_id === 'string' ? line.p2_id : null);
let a = null;
let b = null;
if (aId) {
a = pointById?.get(aId) || null;
} else if (line?.a && typeof line.a === 'object') {
a = line.a;
}
if (bId) {
b = pointById?.get(bId) || null;
} else if (line?.b && typeof line.b === 'object') {
b = line.b;
}
return [a, b];
}
function getArcEndpoints(arc, pointById) {
const aId = typeof arc?.a === 'string' ? arc.a : null;
const bId = typeof arc?.b === 'string' ? arc.b : null;
const a = aId ? (pointById?.get(aId) || null) : null;
const b = bId ? (pointById?.get(bId) || null) : null;
return [a, b];
}
function applyCircleDragKinematics(feature, dx = 0, dy = 0, local = null) {
const drag = this.sketchDrag;
if (!drag) return;
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e]));
const ctrlByArcId = new Map((drag.arcControlBaseline || []).map(rec => [rec.entity?.id, rec]));
for (const arc of entities) {
if (arc?.type !== 'arc' || !arc.id) continue;
const touchesCircle = drag.activeIds?.has?.(arc.id)
|| drag.movedPointIds?.has?.(arc.a)
|| drag.movedPointIds?.has?.(arc.b);
if (!touchesCircle) continue;
const a = byId.get(arc.a);
const b = byId.get(arc.b);
if (!a || !b) continue;
// Arc-center drag should move the whole arc rigidly, regardless of
// arc/circle definition, so the dragged center tracks the pointer.
if (drag.centerDrag) {
const cbase = ctrlByArcId.get(arc.id);
if (cbase) {
if (Number.isFinite(cbase.cx)) arc.cx = (cbase.cx || 0) + dx;
if (Number.isFinite(cbase.cy)) arc.cy = (cbase.cy || 0) + dy;
if (cbase.a) {
a.x = (cbase.a.x || 0) + dx;
a.y = (cbase.a.y || 0) + dy;
}
if (cbase.b) {
b.x = (cbase.b.x || 0) + dx;
b.y = (cbase.b.y || 0) + dy;
}
arc.mx = (cbase.mx || 0) + dx;
arc.my = (cbase.my || 0) + dy;
if (isCircleCurve(arc)) {
arc.radius = Number.isFinite(cbase.radius) ? cbase.radius : (arc.radius || 0);
arc.startAngle = 0;
arc.endAngle = Math.PI * 2;
arc.ccw = true;
}
continue;
}
}
if (!isDragResizableCircleArc(arc, byId)) continue;
let cx = Number(arc.cx || 0);
let cy = Number(arc.cy || 0);
const movedA = drag.movedPointIds?.has?.(a.id);
const movedB = drag.movedPointIds?.has?.(b.id);
cx = arc.cx;
cy = arc.cy;
const curveDrag = drag.circleCurveDragIds?.has?.(arc.id) && !drag.centerDrag;
const anchor = movedA ? a : (movedB ? b : null);
const vx = curveDrag && local ? ((local.x || 0) - cx) : ((anchor?.x || a.x || 0) - cx);
const vy = curveDrag && local ? ((local.y || 0) - cy) : ((anchor?.y || a.y || 0) - cy);
let radius = Math.hypot(vx, vy);
if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) {
radius = Number(arc.radius || 0);
}
if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) {
continue;
}
const angle = Math.atan2(vy, vx);
const px = cx + Math.cos(angle) * radius;
const py = cy + Math.sin(angle) * radius;
a.x = px;
a.y = py;
b.x = px;
b.y = py;
arc.radius = radius;
arc.mx = cx + Math.cos(angle + Math.PI / 2) * radius;
arc.my = cy + Math.sin(angle + Math.PI / 2) * radius;
arc.startAngle = 0;
arc.endAngle = Math.PI * 2;
arc.ccw = true;
}
}
function isDragResizableCircleArc(entity, pointById) {
if (entity?.type !== 'arc') return false;
if (isCenterPointCircle(entity)) return true;
if (!Number.isFinite(entity?.cx) || !Number.isFinite(entity?.cy) || !Number.isFinite(entity?.radius)) return false;
const start = Number(entity?.startAngle);
const end = Number(entity?.endAngle);
const full = Number.isFinite(start) && Number.isFinite(end) && Math.abs(start) < 1e-6 && Math.abs(end - Math.PI * 2) < 1e-6;
if (!full) return false;
const a = typeof entity?.a === 'string' ? pointById?.get?.(entity.a) : null;
const b = typeof entity?.b === 'string' ? pointById?.get?.(entity.b) : null;
if (!a || !b) return !!isCircleCurve(entity);
return Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)) < 1e-6;
}
function projectPointOnArcConstraintsForArcs(feature, arcIds) {
const idSet = arcIds instanceof Set ? arcIds : new Set(Array.isArray(arcIds) ? arcIds : []);
if (!idSet.size) return;
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const constraints = Array.isArray(feature?.constraints) ? feature.constraints : [];
const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e]));
const arcById = new Map(entities.filter(e => e?.type === 'arc' && e?.id).map(e => [e.id, e]));
for (const c of constraints) {
if (c?.type !== 'point_on_arc') continue;
const refs = Array.isArray(c.refs) ? c.refs : [];
if (refs.length < 2) continue;
const arcId = arcById.has(refs[0]) ? refs[0] : (arcById.has(refs[1]) ? refs[1] : null);
const pointId = pointById.has(refs[0]) ? refs[0] : (pointById.has(refs[1]) ? refs[1] : null);
if (!arcId || !pointId || !idSet.has(arcId)) continue;
const arc = arcById.get(arcId);
const point = pointById.get(pointId);
if (!isCircleCurve(arc) || !point) continue;
const cx = Number(arc.cx || 0);
const cy = Number(arc.cy || 0);
const radius = Number(arc.radius || 0);
if (!Number.isFinite(radius) || radius <= SKETCH_MIN_LINE_LENGTH) continue;
const vx = (point.x || 0) - cx;
const vy = (point.y || 0) - cy;
const len = Math.hypot(vx, vy);
if (!Number.isFinite(len) || len <= 1e-9) continue;
point.x = cx + (vx / len) * radius;
point.y = cy + (vy / len) * radius;
}
}
function rebaseSketchDragState(feature, local) {
const drag = this.sketchDrag;
if (!drag || !local) return;
drag.start = { x: local.x || 0, y: local.y || 0 };
if (drag.baseline instanceof Map) {
for (const ref of drag.baseline.keys()) {
drag.baseline.set(ref, { x: ref.x || 0, y: ref.y || 0 });
}
}
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
const entityById = new Map(entities.filter(e => e?.id).map(e => [e.id, e]));
const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e]));
drag.arcControlBaseline = [];
for (const id of (drag.activeIds || [])) {
const entity = entityById.get(id);
if (entity?.type !== 'arc' || !entity.id) continue;
if (!Number.isFinite(entity.mx) || !Number.isFinite(entity.my)) continue;
const pa = pointById.get(entity.a) || null;
const pb = pointById.get(entity.b) || null;
drag.arcControlBaseline.push({
entity,
mx: entity.mx,
my: entity.my,
cx: Number(entity.cx || 0),
cy: Number(entity.cy || 0),
radius: Number(entity.radius || 0),
a: pa ? { x: pa.x || 0, y: pa.y || 0 } : null,
b: pb ? { x: pb.x || 0, y: pb.y || 0 } : null
});
}
}
function sampleArcPolyline(arc, a, b, segments = 24) {
if (isCircleCurve(arc)) {
const cx = Number(arc?.cx);
const cy = Number(arc?.cy);
let radius = Number(arc?.radius);
if (!Number.isFinite(radius) || radius <= 0) {
radius = a ? Math.hypot((a.x || 0) - cx, (a.y || 0) - cy) : 0;
}
if (!Number.isFinite(cx) || !Number.isFinite(cy) || radius <= 0) {
return [];
}
const count = Math.max(24, segments * 2);
let start = 0;
if (a) {
start = Math.atan2((a.y || 0) - cy, (a.x || 0) - cx);
}
const pts = [];
for (let i = 0; i <= count; i++) {
const t = i / count;
const angle = start + t * Math.PI * 2;
pts.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
}
return pts;
}
let cx = Number(arc?.cx);
let cy = Number(arc?.cy);
let radius = Number(arc?.radius);
let startAngle = Number(arc?.startAngle);
let endAngle = Number(arc?.endAngle);
let ccw = arc?.ccw !== false;
if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) && a && b) {
const geomFromThree = this.computeArcGeometry(
{ x: a.x || 0, y: a.y || 0 },
{ x: b.x || 0, y: b.y || 0 },
{ x: arc.mx, y: arc.my }
);
if (geomFromThree) {
cx = geomFromThree.cx;
cy = geomFromThree.cy;
radius = geomFromThree.radius;
startAngle = geomFromThree.startAngle;
endAngle = geomFromThree.endAngle;
ccw = geomFromThree.ccw;
}
}
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(startAngle) || !Number.isFinite(endAngle)) {
if (!a || !b) return [];
const geom = this.computeArcGeometry(a, b, { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 + 1e-3 });
if (!geom) return [{ x: a.x || 0, y: a.y || 0 }, { x: b.x || 0, y: b.y || 0 }];
startAngle = geom.startAngle;
endAngle = geom.endAngle;
cx = geom.cx;
cy = geom.cy;
radius = geom.radius;
ccw = geom.ccw;
}
if (!Number.isFinite(radius) || radius <= 0) {
radius = a ? Math.hypot((a.x || 0) - cx, (a.y || 0) - cy) : 0;
}
if (radius <= 0) return [];
const tau = Math.PI * 2;
let sweep;
if (ccw) {
sweep = (endAngle - startAngle) % tau;
if (sweep < 0) sweep += tau;
} else {
sweep = (startAngle - endAngle) % tau;
if (sweep < 0) sweep += tau;
sweep = -sweep;
}
const count = Math.max(6, segments);
const pts = [];
for (let i = 0; i <= count; i++) {
const t = i / count;
const angle = startAngle + sweep * t;
pts.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius });
}
if (a) {
pts[0] = { x: a.x || 0, y: a.y || 0 };
}
if (b) {
pts[pts.length - 1] = { x: b.x || 0, y: b.y || 0 };
}
return pts;
}
function distanceToSegmentPx(px, py, ax, ay, bx, by) {
const abx = bx - ax;
const aby = by - ay;
const apx = px - ax;
const apy = py - ay;
const abLenSq = abx * abx + aby * aby;
if (abLenSq <= 1e-9) {
return Math.hypot(px - ax, py - ay);
}
const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq));
const cx = ax + abx * t;
const cy = ay + aby * t;
return Math.hypot(px - cx, py - cy);
}
function getEventViewportXY(event) {
const { renderer } = space.internals();
const canvas = renderer?.domElement;
if (!canvas || !event) {
return null;
}
const rect = canvas.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
width: rect.width,
height: rect.height
};
}
function getSketchBasis(feature) {
const rec = api.sketchRuntime?.getRecord?.(feature?.id);
const entitiesGroup = rec?.entitiesGroup;
if (entitiesGroup) {
entitiesGroup.updateMatrixWorld(true);
const xAxis = new THREE.Vector3();
const yAxis = new THREE.Vector3();
const normal = new THREE.Vector3();
entitiesGroup.matrixWorld.extractBasis(xAxis, yAxis, normal);
xAxis.normalize();
yAxis.normalize();
normal.normalize();
// Re-orthogonalize in case parent transforms introduce drift.
yAxis.copy(new THREE.Vector3().crossVectors(normal, xAxis).normalize());
xAxis.copy(new THREE.Vector3().crossVectors(yAxis, normal).normalize());
const origin = new THREE.Vector3();
entitiesGroup.getWorldPosition(origin);
return { origin, normal, xAxis, yAxis };
}
const frame = feature?.plane;
if (!frame) return null;
const origin = new THREE.Vector3(
frame.origin?.x || 0,
frame.origin?.y || 0,
frame.origin?.z || 0
);
const normal = new THREE.Vector3(
frame.normal?.x ?? 0,
frame.normal?.y ?? 0,
frame.normal?.z ?? 1
).normalize();
const xAxis = new THREE.Vector3(
frame.x_axis?.x ?? 1,
frame.x_axis?.y ?? 0,
frame.x_axis?.z ?? 0
).normalize();
const yAxis = new THREE.Vector3().crossVectors(normal, xAxis).normalize();
return { origin, normal, xAxis, yAxis };
}
function sketchLocalToWorld(local, basis) {
return basis.origin.clone()
.addScaledVector(basis.xAxis, local.x || 0)
.addScaledVector(basis.yAxis, local.y || 0);
}
function projectEventToSketchLocal(event, feature) {
const basis = this.getSketchBasis(feature);
const vp = this.getEventViewportXY(event);
if (!basis || !vp) {
return null;
}
const { camera } = space.internals();
if (!camera) {
return null;
}
const ndc = new THREE.Vector2(
(vp.x / vp.width) * 2 - 1,
-(vp.y / vp.height) * 2 + 1
);
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(ndc, camera);
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(basis.normal, basis.origin);
const world = new THREE.Vector3();
const hit = raycaster.ray.intersectPlane(plane, world);
if (!hit) {
return null;
}
const rel = world.clone().sub(basis.origin);
return {
x: rel.dot(basis.xAxis),
y: rel.dot(basis.yAxis)
};
}
export {
pointerDistance,
hitTestSketchEntity,
getArcCenterLocalFromEntity,
getSketchEntityHitFromIntersections,
resolveSketchHit,
resolveDerivedEdgeCandidate,
projectFaceBoundaryToSketch,
worldToSketchLocal,
isSketchEventInViewport,
getSketchHitLocalPoint,
getSketchDragSnapTarget,
findPointByCoord,
ensureSketchPoint,
getLineEndpoints,
getArcEndpoints,
applyCircleDragKinematics,
projectPointOnArcConstraintsForArcs,
rebaseSketchDragState,
sampleArcPolyline,
distanceToSegmentPx,
getEventViewportXY,
getSketchBasis,
sketchLocalToWorld,
projectEventToSketchLocal
};