merge experimental void app
This commit is contained in:
parent
9e142fbe1e
commit
02fb3cd30d
80 changed files with 30437 additions and 813 deletions
36
src/void/sketch/api.js
Normal file
36
src/void/sketch/api.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
function createSketchApi(getApi, idFactory) {
|
||||
return {
|
||||
createFromTarget(target) {
|
||||
const api = getApi();
|
||||
const doc = api.document.current;
|
||||
if (!doc || !target?.frame) {
|
||||
return null;
|
||||
}
|
||||
const sketchCount = (doc.features || []).filter(f => f?.type === 'sketch').length;
|
||||
const feature = {
|
||||
id: idFactory(),
|
||||
type: 'sketch',
|
||||
name: `Sketch ${sketchCount + 1}`,
|
||||
created_at: Date.now(),
|
||||
plane: JSON.parse(JSON.stringify(target.frame)),
|
||||
entities: [],
|
||||
constraints: [],
|
||||
dimensions: [],
|
||||
target: {
|
||||
kind: target.kind || 'plane',
|
||||
id: target.id || null,
|
||||
name: target.name || null,
|
||||
label: target.label || null,
|
||||
source: target.source || null,
|
||||
offset: Number(target?.offset || 0)
|
||||
}
|
||||
};
|
||||
api.features.add(feature);
|
||||
return feature;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { createSketchApi };
|
||||
17
src/void/sketch/constants.js
Normal file
17
src/void/sketch/constants.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
const SKETCH_HIT_POINT_PX = 11;
|
||||
const SKETCH_HIT_LINE_PX = 10;
|
||||
const SKETCH_DRAG_START_PX = 1;
|
||||
const SKETCH_MIN_LINE_LENGTH = 1e-4;
|
||||
const SKETCH_POINT_MERGE_EPS = 1e-4;
|
||||
const SKETCH_VIRTUAL_ORIGIN_ID = '__sketch-origin__';
|
||||
|
||||
export {
|
||||
SKETCH_HIT_POINT_PX,
|
||||
SKETCH_HIT_LINE_PX,
|
||||
SKETCH_DRAG_START_PX,
|
||||
SKETCH_MIN_LINE_LENGTH,
|
||||
SKETCH_POINT_MERGE_EPS,
|
||||
SKETCH_VIRTUAL_ORIGIN_ID
|
||||
};
|
||||
358
src/void/sketch/constraints.js
Normal file
358
src/void/sketch/constraints.js
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { make_gcs_wrapper, Algorithm, SolveStatus } from '../solver/planegcs.js';
|
||||
import {
|
||||
enforceWithFallback,
|
||||
applyThreePointCircleDefinitions,
|
||||
captureFixedAnchors,
|
||||
getLineEndpointId,
|
||||
applyPolygonPatternConstraints,
|
||||
applyCircularPatternConstraints,
|
||||
applyGridPatternConstraints,
|
||||
applyPointOnArcConstraints,
|
||||
applyArcCenterCoincidentConstraints,
|
||||
applyMidpointConstraints,
|
||||
applyTangentConstraints,
|
||||
applyMirrorConstraints
|
||||
} from './constraints_fallback.js';
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
let gcsWrapper = null;
|
||||
let gcsInitPromise = null;
|
||||
let gcsInitError = null;
|
||||
|
||||
function withQuietRedundantLogs(fn) {
|
||||
const origLog = console.log;
|
||||
const origErr = console.error;
|
||||
const isRedundantMsg = msg => {
|
||||
const text = String(msg || '');
|
||||
return text.includes('Redundant solving:')
|
||||
|| text.includes('RedundantSolving-DogLeg-');
|
||||
};
|
||||
console.log = (...args) => {
|
||||
if (args.some(isRedundantMsg)) return;
|
||||
origLog(...args);
|
||||
};
|
||||
console.error = (...args) => {
|
||||
if (args.some(isRedundantMsg)) return;
|
||||
origErr(...args);
|
||||
};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
console.log = origLog;
|
||||
console.error = origErr;
|
||||
}
|
||||
}
|
||||
|
||||
function initSketchConstraintsSolver() {
|
||||
if (gcsWrapper) {
|
||||
return Promise.resolve(gcsWrapper);
|
||||
}
|
||||
if (gcsInitPromise) {
|
||||
return gcsInitPromise;
|
||||
}
|
||||
gcsInitPromise = make_gcs_wrapper().then(wrapper => {
|
||||
gcsWrapper = wrapper;
|
||||
return wrapper;
|
||||
}).catch(error => {
|
||||
gcsInitError = error;
|
||||
console.warn('sketch_constraints: planegcs init failed, using fallback solver', error);
|
||||
return null;
|
||||
});
|
||||
return gcsInitPromise;
|
||||
}
|
||||
|
||||
function enforceSketchConstraintsInPlace(sketch, opts = {}) {
|
||||
if (opts?.useFallback) {
|
||||
return enforceWithFallback(sketch, opts);
|
||||
}
|
||||
|
||||
if (gcsWrapper) {
|
||||
try {
|
||||
return enforceWithPlanegcs(sketch, opts);
|
||||
} catch (error) {
|
||||
console.warn('sketch_constraints: planegcs solve failed, using fallback solver', error);
|
||||
return enforceWithFallback(sketch, opts);
|
||||
}
|
||||
}
|
||||
|
||||
if (!gcsInitPromise && !gcsInitError) {
|
||||
// Fire-and-forget lazy initialization; callers remain synchronous.
|
||||
initSketchConstraintsSolver();
|
||||
}
|
||||
|
||||
return enforceWithFallback(sketch, opts);
|
||||
}
|
||||
|
||||
function enforceWithPlanegcs(sketch, opts = {}) {
|
||||
const entities = Array.isArray(sketch?.entities) ? sketch.entities : [];
|
||||
const constraints = Array.isArray(sketch?.constraints) ? sketch.constraints : [];
|
||||
if (!entities.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const primitives = [];
|
||||
const pointById = new Map();
|
||||
const lineById = new Map();
|
||||
const arcById = new Map();
|
||||
|
||||
for (const entity of entities) {
|
||||
if (entity?.type === 'point' && entity.id) {
|
||||
const p = {
|
||||
id: String(entity.id),
|
||||
type: 'point',
|
||||
x: Number(entity.x || 0),
|
||||
y: Number(entity.y || 0),
|
||||
fixed: false
|
||||
};
|
||||
pointById.set(entity.id, p);
|
||||
primitives.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const aId = getLineEndpointId(entity, 'a');
|
||||
const bId = getLineEndpointId(entity, 'b');
|
||||
if (entity?.type === 'line' && entity.id && pointById.has(aId) && pointById.has(bId)) {
|
||||
const l = {
|
||||
id: String(entity.id),
|
||||
type: 'line',
|
||||
p1_id: String(aId),
|
||||
p2_id: String(bId)
|
||||
};
|
||||
lineById.set(entity.id, l);
|
||||
primitives.push(l);
|
||||
}
|
||||
if (entity?.type === 'arc' && entity.id && pointById.has(aId) && pointById.has(bId)) {
|
||||
arcById.set(entity.id, entity);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of constraints) {
|
||||
const gc = toPlanegcsConstraint(c, pointById, lineById);
|
||||
if (Array.isArray(gc)) {
|
||||
primitives.push(...gc);
|
||||
} else if (gc) {
|
||||
primitives.push(gc);
|
||||
}
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const pointEntityById = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e]));
|
||||
|
||||
if (primitives.length) {
|
||||
gcsWrapper.clear_data();
|
||||
gcsWrapper.push_primitives_and_params(primitives);
|
||||
const status = withQuietRedundantLogs(() => gcsWrapper.solve(Algorithm.DogLeg));
|
||||
if (!(status === SolveStatus.Success || status === SolveStatus.Converged)) {
|
||||
throw new Error(`planegcs solve status=${status}`);
|
||||
}
|
||||
|
||||
gcsWrapper.apply_solution();
|
||||
|
||||
const solvedPrimitives = gcsWrapper?.sketch_index?.get_primitives?.() || [];
|
||||
const solvedPointById = new Map(
|
||||
solvedPrimitives
|
||||
.filter(e => e?.type === 'point' && e.id)
|
||||
.map(e => [e.id, e])
|
||||
);
|
||||
for (const [id, p] of pointEntityById.entries()) {
|
||||
const solved = solvedPointById.get(id);
|
||||
if (!solved) continue;
|
||||
const nx = Number(solved.x || 0);
|
||||
const ny = Number(solved.y || 0);
|
||||
if (Math.abs((p.x || 0) - nx) > EPS || Math.abs((p.y || 0) - ny) > EPS) {
|
||||
p.x = nx;
|
||||
p.y = ny;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixed = captureFixedAnchors(constraints, pointEntityById);
|
||||
const dragged = new Set(Array.isArray(opts?.draggedPointIds) ? opts.draggedPointIds : []);
|
||||
const draggedArcs = new Set(Array.isArray(opts?.draggedArcIds) ? opts.draggedArcIds : []);
|
||||
changed = applyPolygonPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged) || changed;
|
||||
changed = applyCircularPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed;
|
||||
changed = applyGridPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed;
|
||||
changed = applyThreePointCircleDefinitions(arcById, pointEntityById, fixed) || changed;
|
||||
changed = applyPointOnArcConstraints(constraints, pointEntityById, arcById, fixed) || changed;
|
||||
changed = applyArcCenterCoincidentConstraints(constraints, pointEntityById, lineById, arcById, fixed) || changed;
|
||||
changed = applyMidpointConstraints(constraints, pointEntityById, fixed, dragged) || changed;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const tChanged = applyTangentConstraints(constraints, pointEntityById, lineById, arcById, fixed);
|
||||
if (!tChanged) break;
|
||||
changed = true;
|
||||
}
|
||||
changed = applyMirrorConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed;
|
||||
|
||||
// Final polish: reconcile constraints that are handled outside planegcs
|
||||
// (for example point_on_arc groups used by inscribed/circumscribed polygons)
|
||||
// so the sketch settles immediately after constraint application.
|
||||
const fallbackChanged = enforceWithFallback(sketch, {
|
||||
...opts,
|
||||
useFallback: true,
|
||||
iterations: Math.max(16, opts?.iterations || 24)
|
||||
});
|
||||
return fallbackChanged || changed;
|
||||
}
|
||||
|
||||
function toPlanegcsConstraint(c, pointById, lineById) {
|
||||
if (!c?.id || !c?.type) return null;
|
||||
const id = String(c.id);
|
||||
const refs = Array.isArray(c.refs) ? c.refs : [];
|
||||
|
||||
if (c.type === 'coincident') {
|
||||
if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'p2p_coincident',
|
||||
p1_id: String(refs[0]),
|
||||
p2_id: String(refs[1])
|
||||
};
|
||||
}
|
||||
if (c.type === 'point_on_line') {
|
||||
if (refs.length < 2) return null;
|
||||
const pId = pointById.has(refs[0]) ? refs[0] : (pointById.has(refs[1]) ? refs[1] : null);
|
||||
const lId = lineById.has(refs[0]) ? refs[0] : (lineById.has(refs[1]) ? refs[1] : null);
|
||||
if (!pId || !lId) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'point_on_line_pl',
|
||||
p_id: String(pId),
|
||||
l_id: String(lId)
|
||||
};
|
||||
}
|
||||
if (c.type === 'dimension') {
|
||||
if (c?.data?.mode === 'driven') return null;
|
||||
const value = Number(c?.data?.value);
|
||||
if (!Number.isFinite(value) || value <= EPS) return null;
|
||||
if (refs.length === 1 && lineById.has(refs[0])) {
|
||||
const line = lineById.get(refs[0]);
|
||||
if (!line) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'p2p_distance',
|
||||
p1_id: String(line.p1_id),
|
||||
p2_id: String(line.p2_id),
|
||||
distance: value
|
||||
};
|
||||
}
|
||||
if (refs.length >= 2 && pointById.has(refs[0]) && pointById.has(refs[1])) {
|
||||
return {
|
||||
id,
|
||||
type: 'p2p_distance',
|
||||
p1_id: String(refs[0]),
|
||||
p2_id: String(refs[1]),
|
||||
distance: value
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (c.type === 'horizontal') {
|
||||
const lId = refs[0];
|
||||
if (!lId || !lineById.has(lId)) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'horizontal_l',
|
||||
l_id: String(lId)
|
||||
};
|
||||
}
|
||||
if (c.type === 'horizontal_points') {
|
||||
if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null;
|
||||
const lId = `${id}:hl`;
|
||||
return [
|
||||
{ id: lId, type: 'line', p1_id: String(refs[0]), p2_id: String(refs[1]) },
|
||||
{ id: `${id}:c`, type: 'horizontal_l', l_id: lId }
|
||||
];
|
||||
}
|
||||
|
||||
if (c.type === 'vertical') {
|
||||
const lId = refs[0];
|
||||
if (!lId || !lineById.has(lId)) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'vertical_l',
|
||||
l_id: String(lId)
|
||||
};
|
||||
}
|
||||
if (c.type === 'vertical_points') {
|
||||
if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null;
|
||||
const lId = `${id}:vl`;
|
||||
return [
|
||||
{ id: lId, type: 'line', p1_id: String(refs[0]), p2_id: String(refs[1]) },
|
||||
{ id: `${id}:c`, type: 'vertical_l', l_id: lId }
|
||||
];
|
||||
}
|
||||
|
||||
if (c.type === 'perpendicular') {
|
||||
if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'perpendicular_ll',
|
||||
l1_id: String(refs[0]),
|
||||
l2_id: String(refs[1])
|
||||
};
|
||||
}
|
||||
if (c.type === 'equal') {
|
||||
if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null;
|
||||
return {
|
||||
id,
|
||||
type: 'equal_length',
|
||||
l1_id: String(refs[0]),
|
||||
l2_id: String(refs[1])
|
||||
};
|
||||
}
|
||||
if (c.type === 'collinear') {
|
||||
if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null;
|
||||
const l1 = lineById.get(refs[0]);
|
||||
const l2 = lineById.get(refs[1]);
|
||||
if (!l1 || !l2) return null;
|
||||
return [
|
||||
{
|
||||
id: `${id}:parallel`,
|
||||
type: 'parallel',
|
||||
l1_id: String(refs[0]),
|
||||
l2_id: String(refs[1])
|
||||
},
|
||||
{
|
||||
id: `${id}:point_on`,
|
||||
type: 'point_on_line_pl',
|
||||
p_id: String(l2.p1_id),
|
||||
l_id: String(refs[0])
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (c.type === 'fixed') {
|
||||
const pId = refs[0];
|
||||
if (!pId || !pointById.has(pId)) return null;
|
||||
const p = pointById.get(pId);
|
||||
const anchor = c.data?.anchors?.[pId] || { x: p.x, y: p.y };
|
||||
const cxId = `${id}:x`;
|
||||
const cyId = `${id}:y`;
|
||||
// Return as paired constraints; caller accepts arrays from mapper.
|
||||
return [
|
||||
{
|
||||
id: cxId,
|
||||
type: 'coordinate_x',
|
||||
p_id: String(pId),
|
||||
x: Number(anchor.x || 0)
|
||||
},
|
||||
{
|
||||
id: cyId,
|
||||
type: 'coordinate_y',
|
||||
p_id: String(pId),
|
||||
y: Number(anchor.y || 0)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
export { initSketchConstraintsSolver, enforceSketchConstraintsInPlace };
|
||||
882
src/void/sketch/constraints_actions.js
Normal file
882
src/void/sketch/constraints_actions.js
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
import { enforceSketchConstraintsInPlace } from './constraints.js';
|
||||
import * as sketchCreate from './create.js';
|
||||
import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js';
|
||||
|
||||
function getConstraintMode(constraint) {
|
||||
const mode = constraint?.data?.mode;
|
||||
return mode === 'driven' ? 'driven' : 'driving';
|
||||
}
|
||||
|
||||
function buildPatternCloneToSourceMap(feature) {
|
||||
const map = new Map();
|
||||
const constraints = Array.isArray(feature?.constraints) ? feature.constraints : [];
|
||||
for (const c of constraints) {
|
||||
if (c?.type !== 'circular_pattern' && c?.type !== 'grid_pattern') continue;
|
||||
const data = c?.data || {};
|
||||
const sourceIds = Array.isArray(data?.sourceIds) ? data.sourceIds.filter(id => typeof id === 'string' && id) : [];
|
||||
const copies = Array.isArray(data?.copies) ? data.copies : [];
|
||||
const pointMaps = Array.isArray(data?.pointMaps) ? data.pointMaps : [];
|
||||
for (const copyRec of copies) {
|
||||
const ids = Array.isArray(copyRec)
|
||||
? copyRec
|
||||
: (Array.isArray(copyRec?.ids) ? copyRec.ids : []);
|
||||
for (let i = 0; i < sourceIds.length; i++) {
|
||||
const srcId = sourceIds[i];
|
||||
const dstId = ids[i];
|
||||
if (typeof srcId === 'string' && srcId && typeof dstId === 'string' && dstId) {
|
||||
map.set(dstId, srcId);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const pointRec of pointMaps) {
|
||||
const pairs = Array.isArray(pointRec)
|
||||
? pointRec
|
||||
: (Array.isArray(pointRec?.pairs) ? pointRec.pairs : []);
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair) || pair.length < 2) continue;
|
||||
const srcId = pair[0];
|
||||
const dstId = pair[1];
|
||||
if (typeof srcId === 'string' && srcId && typeof dstId === 'string' && dstId) {
|
||||
map.set(dstId, srcId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapPatternRefToSource(ref, cloneToSource) {
|
||||
if (ref === SKETCH_VIRTUAL_ORIGIN_ID) return ref;
|
||||
if (typeof ref !== 'string' || !ref) return ref;
|
||||
if (ref.startsWith('arc-center:')) {
|
||||
const arcId = ref.substring('arc-center:'.length);
|
||||
const srcArcId = cloneToSource.get(arcId) || arcId;
|
||||
return `arc-center:${srcArcId}`;
|
||||
}
|
||||
return cloneToSource.get(ref) || ref;
|
||||
}
|
||||
|
||||
function applyConstraintDisplayRefs(constraint, displayRefs = null) {
|
||||
if (!constraint) return;
|
||||
const refs = Array.isArray(displayRefs) ? displayRefs.filter(Boolean) : [];
|
||||
if (!refs.length) return;
|
||||
constraint.ui = constraint.ui || {};
|
||||
constraint.ui.display_refs = refs;
|
||||
}
|
||||
|
||||
function clearSketchTransientInputState(ctx) {
|
||||
if (!ctx) return;
|
||||
ctx.sketchPointerDown = null;
|
||||
ctx.sketchDrag = null;
|
||||
if (api?.sketchRuntime) {
|
||||
api.sketchRuntime._glyphDrag = null;
|
||||
}
|
||||
// Native prompt can swallow pointer-up events; flush camera/input controls
|
||||
// so trackball/orbit state cannot remain latched into drag mode.
|
||||
try {
|
||||
const ctrl = space?.view?.ctrl;
|
||||
ctrl?.onMouseUp?.({ button: 0 });
|
||||
ctrl?.resetInputState?.();
|
||||
const doc = self.document;
|
||||
const evtInit = { bubbles: true, cancelable: true, button: 0, buttons: 0, clientX: 0, clientY: 0 };
|
||||
doc?.dispatchEvent?.(new MouseEvent('mouseup', evtInit));
|
||||
if (typeof PointerEvent !== 'undefined') {
|
||||
doc?.dispatchEvent?.(new PointerEvent('pointerup', evtInit));
|
||||
}
|
||||
} catch (e) {
|
||||
// no-op
|
||||
}
|
||||
space?.update?.();
|
||||
}
|
||||
|
||||
function getLineEndpointIds(line) {
|
||||
if (!line) return [null, null];
|
||||
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);
|
||||
return [aId, bId];
|
||||
}
|
||||
|
||||
function getArcEndpoints(arc) {
|
||||
if (!arc) return [null, null];
|
||||
const aId = typeof arc?.a === 'string' ? arc.a : (typeof arc?.p1_id === 'string' ? arc.p1_id : null);
|
||||
const bId = typeof arc?.b === 'string' ? arc.b : (typeof arc?.p2_id === 'string' ? arc.p2_id : null);
|
||||
return [aId, bId];
|
||||
}
|
||||
|
||||
function resolvePointLike(byId, ref) {
|
||||
if (!ref) return null;
|
||||
if (ref === SKETCH_VIRTUAL_ORIGIN_ID) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
if (typeof ref === 'string' && ref.startsWith('arc-center:')) {
|
||||
const arcId = ref.substring('arc-center:'.length);
|
||||
const arc = byId.get(arcId);
|
||||
if (arc?.type !== 'arc') return null;
|
||||
const cx = Number(arc?.cx);
|
||||
const cy = Number(arc?.cy);
|
||||
if (Number.isFinite(cx) && Number.isFinite(cy)) {
|
||||
return { x: cx, y: cy };
|
||||
}
|
||||
const [aId, bId] = getArcEndpoints(arc);
|
||||
const a = byId.get(aId);
|
||||
const b = byId.get(bId);
|
||||
if (!a || !b) return null;
|
||||
const mx = Number(arc?.mx);
|
||||
const my = Number(arc?.my);
|
||||
if (!Number.isFinite(mx) || !Number.isFinite(my)) return null;
|
||||
const x1 = a.x || 0; const y1 = a.y || 0;
|
||||
const x2 = b.x || 0; const y2 = b.y || 0;
|
||||
const x3 = mx; const y3 = my;
|
||||
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2));
|
||||
if (Math.abs(d) < 1e-8) return null;
|
||||
const x1sq = x1 * x1 + y1 * y1;
|
||||
const x2sq = x2 * x2 + y2 * y2;
|
||||
const x3sq = x3 * x3 + y3 * y3;
|
||||
const cx2 = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d;
|
||||
const cy2 = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d;
|
||||
if (!Number.isFinite(cx2) || !Number.isFinite(cy2)) return null;
|
||||
return { x: cx2, y: cy2 };
|
||||
}
|
||||
const point = byId.get(ref);
|
||||
if (point?.type === 'point') {
|
||||
return { x: point.x || 0, y: point.y || 0 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function measureDimensionValue(entities, refs = []) {
|
||||
const byId = new Map((entities || []).map(e => [e?.id, e]));
|
||||
if (refs.length === 1) {
|
||||
const ent = byId.get(refs[0]);
|
||||
if (ent?.type === 'line') {
|
||||
const [aId, bId] = getLineEndpointIds(ent);
|
||||
const a = byId.get(aId);
|
||||
const b = byId.get(bId);
|
||||
if (a?.type !== 'point' || b?.type !== 'point') return NaN;
|
||||
return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0));
|
||||
}
|
||||
if (ent?.type === 'arc') {
|
||||
const center = resolvePointLike(byId, `arc-center:${ent.id}`);
|
||||
if (!center) return NaN;
|
||||
const [aId] = getArcEndpoints(ent);
|
||||
const a = byId.get(aId);
|
||||
if (a?.type !== 'point') return NaN;
|
||||
return Math.hypot((a.x || 0) - center.x, (a.y || 0) - center.y) * 2;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
if (refs.length >= 2) {
|
||||
const a = resolvePointLike(byId, refs[0]);
|
||||
const b = resolvePointLike(byId, refs[1]);
|
||||
if (!a || !b) return NaN;
|
||||
return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0));
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
function deleteSelectedSketchConstraints() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !this.selectedSketchConstraints?.size) {
|
||||
return false;
|
||||
}
|
||||
const removeIds = new Set(this.selectedSketchConstraints);
|
||||
let removed = 0;
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
|
||||
const keep = [];
|
||||
for (const c of sketch.constraints) {
|
||||
if (c?.id && removeIds.has(c.id)) {
|
||||
removed++;
|
||||
} else {
|
||||
keep.push(c);
|
||||
}
|
||||
}
|
||||
sketch.constraints = keep;
|
||||
enforceSketchConstraintsInPlace(sketch);
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: { field: 'constraints.remove', ids: Array.from(removeIds) }
|
||||
});
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
this.selectedSketchConstraints.clear();
|
||||
this.hoveredSketchConstraintId = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function deleteSelectedSketchEntities() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !this.selectedSketchEntities.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const removeIds = new Set(this.selectedSketchEntities);
|
||||
let removed = 0;
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : [];
|
||||
sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
|
||||
|
||||
const endpointCandidates = new Set();
|
||||
for (const entity of sketch.entities) {
|
||||
if (!removeIds.has(entity?.id)) continue;
|
||||
if (entity?.type !== 'line' && entity?.type !== 'arc') continue;
|
||||
if (typeof entity.a === 'string') endpointCandidates.add(entity.a);
|
||||
if (typeof entity.b === 'string') endpointCandidates.add(entity.b);
|
||||
if (entity?.type === 'arc') {
|
||||
const threePointIds = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds : [];
|
||||
for (const pid of threePointIds) {
|
||||
if (typeof pid === 'string') endpointCandidates.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (endpointCandidates.size) {
|
||||
const prospectiveRemove = new Set([...removeIds, ...endpointCandidates]);
|
||||
const usedByRemainingCurve = new Set();
|
||||
for (const entity of sketch.entities) {
|
||||
if (removeIds.has(entity?.id)) continue;
|
||||
if (entity?.type !== 'line' && entity?.type !== 'arc') continue;
|
||||
if (typeof entity.a === 'string') usedByRemainingCurve.add(entity.a);
|
||||
if (typeof entity.b === 'string') usedByRemainingCurve.add(entity.b);
|
||||
}
|
||||
const usedByRemainingConstraint = new Set();
|
||||
for (const constraint of sketch.constraints) {
|
||||
const refs = Array.isArray(constraint?.refs) ? constraint.refs : [];
|
||||
if (refs.some(ref => prospectiveRemove.has(ref))) continue;
|
||||
for (const ref of refs) {
|
||||
usedByRemainingConstraint.add(ref);
|
||||
}
|
||||
}
|
||||
for (const pointId of endpointCandidates) {
|
||||
if (usedByRemainingCurve.has(pointId)) continue;
|
||||
if (usedByRemainingConstraint.has(pointId)) continue;
|
||||
removeIds.add(pointId);
|
||||
}
|
||||
}
|
||||
|
||||
const keep = [];
|
||||
for (const entity of sketch.entities) {
|
||||
if (removeIds.has(entity.id)) {
|
||||
removed++;
|
||||
} else {
|
||||
keep.push(entity);
|
||||
}
|
||||
}
|
||||
sketch.entities = keep;
|
||||
sketch.constraints = sketch.constraints.filter(constraint => {
|
||||
const refs = Array.isArray(constraint?.refs) ? constraint.refs : [];
|
||||
if (refs.some(ref => removeIds.has(ref))) return false;
|
||||
if (constraint?.type === 'circular_pattern') {
|
||||
const data = constraint?.data || {};
|
||||
for (const arr of (data?.copies || [])) {
|
||||
const ids = Array.isArray(arr) ? arr : (Array.isArray(arr?.ids) ? arr.ids : []);
|
||||
for (const id of ids) {
|
||||
if (removeIds.has(id)) return false;
|
||||
}
|
||||
}
|
||||
for (const pairs of (data?.pointMaps || [])) {
|
||||
const recPairs = Array.isArray(pairs) ? pairs : (Array.isArray(pairs?.pairs) ? pairs.pairs : []);
|
||||
for (const pair of recPairs) {
|
||||
if (Array.isArray(pair) && removeIds.has(pair[1])) return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
enforceSketchConstraintsInPlace(sketch);
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: { field: 'entities.remove', ids: Array.from(removeIds) }
|
||||
});
|
||||
|
||||
if (!removed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.selectedSketchEntities.clear();
|
||||
this.selectedSketchArcCenters?.clear?.();
|
||||
this.hoveredSketchEntityId = null;
|
||||
this.setSketchTool('select');
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function toggleSelectedConstruction() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !this.selectedSketchEntities.size) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = (feature.entities || []).filter(entity =>
|
||||
this.selectedSketchEntities.has(entity.id) && (entity.type === 'line' || entity.type === 'arc'));
|
||||
if (!selected.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const setConstruction = selected.some(entity => !entity.construction);
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : [];
|
||||
for (const entity of sketch.entities) {
|
||||
if (!this.selectedSketchEntities.has(entity.id) || (entity.type !== 'line' && entity.type !== 'arc')) {
|
||||
continue;
|
||||
}
|
||||
entity.construction = setConstruction;
|
||||
}
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: { field: 'construction', value: setConstruction }
|
||||
});
|
||||
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySketchConstraint(type) {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) {
|
||||
return false;
|
||||
}
|
||||
const entities = Array.isArray(feature.entities) ? feature.entities : [];
|
||||
const cloneToSource = buildPatternCloneToSourceMap(feature);
|
||||
const selectedEntityIds = new Set(
|
||||
Array.from(this.selectedSketchEntities || [])
|
||||
.map(id => mapPatternRefToSource(id, cloneToSource))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const selectedArcCenterIds = new Set(
|
||||
Array.from(this.selectedSketchArcCenters || [])
|
||||
.map(id => mapPatternRefToSource(id, cloneToSource))
|
||||
.map(ref => typeof ref === 'string' && ref.startsWith('arc-center:') ? ref.substring('arc-center:'.length) : ref)
|
||||
.filter(id => typeof id === 'string' && id)
|
||||
);
|
||||
const sourceToDisplay = new Map();
|
||||
for (const raw of Array.from(this.selectedSketchEntities || [])) {
|
||||
const mapped = mapPatternRefToSource(raw, cloneToSource);
|
||||
if (typeof mapped === 'string' && mapped && !sourceToDisplay.has(mapped)) {
|
||||
sourceToDisplay.set(mapped, raw);
|
||||
}
|
||||
}
|
||||
for (const rawArcId of Array.from(this.selectedSketchArcCenters || [])) {
|
||||
const raw = `arc-center:${rawArcId}`;
|
||||
const mapped = mapPatternRefToSource(raw, cloneToSource);
|
||||
if (typeof mapped === 'string' && mapped && !sourceToDisplay.has(mapped)) {
|
||||
sourceToDisplay.set(mapped, raw);
|
||||
}
|
||||
}
|
||||
const displayRefsFor = refs => (Array.isArray(refs) ? refs.map(ref => sourceToDisplay.get(ref) || ref) : []);
|
||||
const selected = entities.filter(entity => selectedEntityIds.has(entity.id));
|
||||
const hasOriginSelected = selectedEntityIds.has(SKETCH_VIRTUAL_ORIGIN_ID);
|
||||
const hasArcCenterSelected = selectedArcCenterIds.size > 0;
|
||||
if (!selected.length && !hasOriginSelected && !hasArcCenterSelected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lines = selected.filter(entity => entity.type === 'line');
|
||||
const arcs = selected.filter(entity => entity.type === 'arc');
|
||||
const points = selected.filter(entity => entity.type === 'point');
|
||||
const entitiesById = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity]));
|
||||
const arcCenters = Array.from(selectedArcCenterIds || [])
|
||||
.map(id => entitiesById.get(id))
|
||||
.filter(entity => entity?.type === 'arc');
|
||||
const pointLikeRefs = [];
|
||||
for (const point of points) pointLikeRefs.push(point.id);
|
||||
for (const arc of arcCenters) pointLikeRefs.push(`arc-center:${arc.id}`);
|
||||
if (hasOriginSelected) pointLikeRefs.push(SKETCH_VIRTUAL_ORIGIN_ID);
|
||||
const specs = [];
|
||||
|
||||
if (type === 'horizontal' || type === 'vertical') {
|
||||
if (lines.length) {
|
||||
for (const line of lines) {
|
||||
specs.push({ type, refs: [line.id], displayRefs: displayRefsFor([line.id]) });
|
||||
}
|
||||
} else if (pointLikeRefs.length === 2) {
|
||||
const refs = [pointLikeRefs[0], pointLikeRefs[1]];
|
||||
specs.push({ type: `${type}_points`, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (type === 'perpendicular') {
|
||||
if (lines.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
const refs = [lines[0].id, lines[1].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
}
|
||||
} else if (type === 'equal') {
|
||||
if (lines.length >= 2) {
|
||||
const base = lines[0];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const refs = [base.id, lines[i].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
}
|
||||
} else if (arcs.length >= 2) {
|
||||
const base = arcs[0];
|
||||
for (let i = 1; i < arcs.length; i++) {
|
||||
const refs = [base.id, arcs[i].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (type === 'collinear') {
|
||||
if (lines.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
const refs = [lines[0].id, lines[1].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
}
|
||||
} else if (type === 'tangent') {
|
||||
if (lines.length === 1 && arcs.length === 1) {
|
||||
const refs = [lines[0].id, arcs[0].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (lines.length === 0 && arcs.length === 2) {
|
||||
const refs = [arcs[0].id, arcs[1].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (type === 'midpoint') {
|
||||
if (points.length === 3) {
|
||||
const refs = [points[0].id, points[1].id, points[2].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (points.length === 1 && lines.length === 1) {
|
||||
const line = lines[0];
|
||||
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);
|
||||
if (!aId || !bId) {
|
||||
return false;
|
||||
}
|
||||
const refs = [points[0].id, aId, bId];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (type === 'coincident') {
|
||||
if (points.length === 2) {
|
||||
const circleArc = this.findArcWithEndpoints(feature, points[0].id, points[1].id);
|
||||
if (circleArc) {
|
||||
const converted = this.convertArcToCircle(feature, circleArc.id, points[0].id, points[1].id);
|
||||
if (converted) {
|
||||
this.clearSketchSelection?.();
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
const refs = [points[0].id, points[1].id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (points.length === 1 && lines.length === 1) {
|
||||
const refs = [points[0].id, lines[0].id];
|
||||
specs.push({ type: 'point_on_line', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (points.length === 1 && arcs.length === 1) {
|
||||
const refs = [points[0].id, arcs[0].id];
|
||||
specs.push({ type: 'point_on_arc', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (points.length === 1 && arcCenters.length === 1) {
|
||||
const refs = [arcCenters[0].id, points[0].id];
|
||||
specs.push({ type: 'arc_center_coincident', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (arcCenters.length === 1 && arcs.length === 1 && points.length === 0 && lines.length === 0) {
|
||||
const sourceArcId = arcCenters[0].id;
|
||||
const targetArcId = arcs[0].id;
|
||||
if (!sourceArcId || !targetArcId || sourceArcId === targetArcId) {
|
||||
return false;
|
||||
}
|
||||
const refs = [sourceArcId, targetArcId];
|
||||
specs.push({ type: 'arc_center_on_arc', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (points.length === 1 && hasOriginSelected && lines.length === 0 && arcs.length === 0 && arcCenters.length === 0) {
|
||||
const refs = [points[0].id];
|
||||
specs.push({
|
||||
type: 'fixed',
|
||||
refs,
|
||||
displayRefs: displayRefsFor(refs),
|
||||
data: { anchors: { [points[0].id]: { x: 0, y: 0 } } }
|
||||
});
|
||||
} else if (arcCenters.length === 1 && lines.length === 1 && points.length === 0 && arcs.length === 0) {
|
||||
const refs = [arcCenters[0].id, lines[0].id];
|
||||
specs.push({ type: 'arc_center_on_line', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else if (arcCenters.length === 1 && hasOriginSelected && points.length === 0 && lines.length === 0 && arcs.length === 0) {
|
||||
const refs = [arcCenters[0].id];
|
||||
specs.push({ type: 'arc_center_fixed_origin', refs, displayRefs: displayRefsFor(refs) });
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (type === 'dimension') {
|
||||
let dimRefs = null;
|
||||
if (lines.length === 1 && points.length === 0 && arcs.length === 0) {
|
||||
dimRefs = [lines[0].id];
|
||||
} else if (arcs.length === 1 && lines.length === 0 && points.length === 0 && arcCenters.length === 0 && !hasOriginSelected) {
|
||||
dimRefs = [arcs[0].id];
|
||||
} else if (pointLikeRefs.length === 2 && lines.length === 0 && arcs.length === 0) {
|
||||
dimRefs = [pointLikeRefs[0], pointLikeRefs[1]];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
const normalized = this.normalizeConstraintRefs('dimension', dimRefs);
|
||||
const current = this.findSketchConstraintInList?.(feature, 'dimension', normalized);
|
||||
const currentValue = Number(current?.data?.value);
|
||||
const measured = measureDimensionValue(entities, normalized);
|
||||
const seed = Number.isFinite(currentValue) && currentValue > 0
|
||||
? currentValue
|
||||
: (Number.isFinite(measured) && measured > 0 ? measured : 10);
|
||||
clearSketchTransientInputState(this);
|
||||
const input = window.prompt('Dimension value', String(Number(seed.toFixed(4))));
|
||||
clearSketchTransientInputState(this);
|
||||
if (input === null) {
|
||||
return false;
|
||||
}
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return false;
|
||||
}
|
||||
specs.push({
|
||||
type,
|
||||
refs: normalized,
|
||||
displayRefs: displayRefsFor(normalized),
|
||||
data: { value, mode: getConstraintMode(current) }
|
||||
});
|
||||
} else if (type === 'min_distance' || type === 'max_distance') {
|
||||
let refs = null;
|
||||
if (arcs.length === 1 && pointLikeRefs.length === 1 && lines.length === 0) {
|
||||
refs = [arcs[0].id, pointLikeRefs[0]];
|
||||
} else if (arcs.length === 1 && lines.length === 1 && pointLikeRefs.length === 0) {
|
||||
refs = [arcs[0].id, lines[0].id];
|
||||
} else if (arcs.length === 2 && lines.length === 0 && pointLikeRefs.length === 0) {
|
||||
refs = [arcs[0].id, arcs[1].id];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
const normalized = this.normalizeConstraintRefs(type, refs);
|
||||
const current = this.findSketchConstraintInList?.(feature, type, normalized);
|
||||
const currentValue = Number(current?.data?.value);
|
||||
const seed = Number.isFinite(currentValue) && currentValue > 0 ? currentValue : 1;
|
||||
clearSketchTransientInputState(this);
|
||||
const promptLabel = type === 'min_distance' ? 'Min distance value' : 'Max distance value';
|
||||
const input = window.prompt(promptLabel, String(Number(seed.toFixed(4))));
|
||||
clearSketchTransientInputState(this);
|
||||
if (input === null) {
|
||||
return false;
|
||||
}
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return false;
|
||||
}
|
||||
specs.push({
|
||||
type,
|
||||
refs: normalized,
|
||||
displayRefs: displayRefsFor(normalized),
|
||||
data: { value }
|
||||
});
|
||||
} else if (type === 'fixed') {
|
||||
for (const point of points) {
|
||||
const refs = [point.id];
|
||||
specs.push({ type, refs, displayRefs: displayRefsFor(refs) });
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!specs.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
|
||||
for (const spec of specs) {
|
||||
if (this.toggleSketchConstraintInList(sketch, sketch.constraints, spec.type, spec.refs, spec.data || null, spec.displayRefs || null)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
// Apply-path should settle quickly without over-driving fallback.
|
||||
enforceSketchConstraintsInPlace(sketch, { iterations: 48 });
|
||||
enforceSketchConstraintsInPlace(sketch, {
|
||||
useFallback: true,
|
||||
iterations: 48,
|
||||
tangentAggressive: false
|
||||
});
|
||||
}
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: {
|
||||
field: 'constraints.apply',
|
||||
type,
|
||||
refs: specs.map(spec => spec.refs),
|
||||
data: specs.map(spec => spec.data || null)
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
this.clearSketchSelection?.();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function editSketchDimensionConstraint(constraintId) {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !constraintId) return false;
|
||||
clearSketchTransientInputState(this);
|
||||
const constraints = Array.isArray(feature.constraints) ? feature.constraints : [];
|
||||
const found = constraints.find(c => c?.id === constraintId && c?.type === 'dimension');
|
||||
if (!found) return false;
|
||||
const entities = Array.isArray(feature.entities) ? feature.entities : [];
|
||||
const measured = measureDimensionValue(entities, Array.isArray(found.refs) ? found.refs : []);
|
||||
const current = Number(found?.data?.value);
|
||||
const seed = Number.isFinite(current) && current > 0
|
||||
? current
|
||||
: (Number.isFinite(measured) && measured > 0 ? measured : 10);
|
||||
const input = window.prompt('Dimension value', String(Number(seed.toFixed(4))));
|
||||
clearSketchTransientInputState(this);
|
||||
if (input === null) return false;
|
||||
const value = Number(input);
|
||||
if (!Number.isFinite(value) || value <= 0) return false;
|
||||
|
||||
let changed = false;
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
|
||||
const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'dimension');
|
||||
if (!c) return;
|
||||
c.data = c.data || {};
|
||||
const prev = Number(c.data.value);
|
||||
if (Math.abs(prev - value) < 1e-9) return;
|
||||
c.data.value = value;
|
||||
changed = true;
|
||||
if (getConstraintMode(c) === 'driving') {
|
||||
enforceSketchConstraintsInPlace(sketch, { iterations: 48 });
|
||||
enforceSketchConstraintsInPlace(sketch, {
|
||||
useFallback: true,
|
||||
iterations: 48,
|
||||
tangentAggressive: false
|
||||
});
|
||||
}
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: { field: 'constraints.dimension.value', id: constraintId, value }
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function toggleSketchDimensionMode(constraintId) {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !constraintId) return false;
|
||||
let changed = false;
|
||||
api.features.update(feature.id, sketch => {
|
||||
sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
|
||||
sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : [];
|
||||
const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'dimension');
|
||||
if (!c) return;
|
||||
c.data = c.data || {};
|
||||
const prev = getConstraintMode(c);
|
||||
const next = prev === 'driving' ? 'driven' : 'driving';
|
||||
if (next === prev) return;
|
||||
if (next === 'driving') {
|
||||
const measured = measureDimensionValue(sketch.entities, Array.isArray(c.refs) ? c.refs : []);
|
||||
if (Number.isFinite(measured) && measured > 0) {
|
||||
c.data.value = measured;
|
||||
}
|
||||
}
|
||||
c.data.mode = next;
|
||||
changed = true;
|
||||
if (next === 'driving') {
|
||||
enforceSketchConstraintsInPlace(sketch, { iterations: 48 });
|
||||
enforceSketchConstraintsInPlace(sketch, {
|
||||
useFallback: true,
|
||||
iterations: 48,
|
||||
tangentAggressive: false
|
||||
});
|
||||
}
|
||||
}, {
|
||||
opType: 'feature.update',
|
||||
payload: { field: 'constraints.dimension.mode', id: constraintId }
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function findArcWithEndpoints(feature, p1Id, p2Id) {
|
||||
return sketchCreate.findArcWithEndpoints.call(this, feature, p1Id, p2Id);
|
||||
}
|
||||
|
||||
function convertArcToCircle(feature, arcId, p1Id, p2Id) {
|
||||
return sketchCreate.convertArcToCircle.call(this, feature, arcId, p1Id, p2Id);
|
||||
}
|
||||
|
||||
function findSketchConstraintInList(sketch, type, refs) {
|
||||
const list = Array.isArray(sketch?.constraints) ? sketch.constraints : [];
|
||||
const key = this.makeSketchConstraintKey(type, refs);
|
||||
for (const existing of list) {
|
||||
if (this.makeSketchConstraintKey(existing?.type, existing?.refs || []) === key) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toggleSketchConstraintInList(sketch, list, type, refs, dataIn = null, displayRefsIn = null) {
|
||||
const key = this.makeSketchConstraintKey(type, refs);
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const existing = list[i];
|
||||
if (this.makeSketchConstraintKey(existing?.type, existing?.refs || []) === key) {
|
||||
if (type === 'dimension') {
|
||||
existing.data = existing.data || {};
|
||||
const prev = Number(existing.data.value);
|
||||
const next = Number(dataIn?.value);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(prev - next) < 1e-9) {
|
||||
return false;
|
||||
}
|
||||
existing.data.value = next;
|
||||
applyConstraintDisplayRefs(existing, displayRefsIn);
|
||||
return true;
|
||||
}
|
||||
if (type === 'min_distance' || type === 'max_distance') {
|
||||
existing.data = existing.data || {};
|
||||
const prev = Number(existing.data.value);
|
||||
const next = Number(dataIn?.value);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return false;
|
||||
}
|
||||
if (Math.abs(prev - next) < 1e-9) {
|
||||
return false;
|
||||
}
|
||||
existing.data.value = next;
|
||||
applyConstraintDisplayRefs(existing, displayRefsIn);
|
||||
return true;
|
||||
}
|
||||
if (type === 'fixed' && dataIn?.anchors && typeof dataIn.anchors === 'object') {
|
||||
existing.data = existing.data || {};
|
||||
existing.data.anchors = existing.data.anchors || {};
|
||||
let changed = false;
|
||||
for (const id of this.normalizeConstraintRefs(type, refs)) {
|
||||
const anchor = dataIn.anchors[id];
|
||||
if (!anchor) continue;
|
||||
const x = Number(anchor.x);
|
||||
const y = Number(anchor.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
const prev = existing.data.anchors[id];
|
||||
if (!prev || Math.abs((prev.x || 0) - x) > 1e-9 || Math.abs((prev.y || 0) - y) > 1e-9) {
|
||||
existing.data.anchors[id] = { x, y };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
applyConstraintDisplayRefs(existing, displayRefsIn);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
list.splice(i, 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const data = {};
|
||||
if (type === 'fixed') {
|
||||
const provided = dataIn?.anchors && typeof dataIn.anchors === 'object' ? dataIn.anchors : null;
|
||||
data.anchors = {};
|
||||
if (provided) {
|
||||
for (const id of this.normalizeConstraintRefs(type, refs)) {
|
||||
const anchor = provided[id];
|
||||
if (!anchor) continue;
|
||||
const x = Number(anchor.x);
|
||||
const y = Number(anchor.y);
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
data.anchors[id] = { x, y };
|
||||
}
|
||||
}
|
||||
if (!Object.keys(data.anchors).length) {
|
||||
const entities = Array.isArray(sketch?.entities) ? sketch.entities : [];
|
||||
const pointById = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e]));
|
||||
for (const id of this.normalizeConstraintRefs(type, refs)) {
|
||||
const p = pointById.get(id);
|
||||
if (p) {
|
||||
data.anchors[id] = { x: p.x || 0, y: p.y || 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const rec = {
|
||||
id: this.newSketchEntityId('cst'),
|
||||
type,
|
||||
refs: this.normalizeConstraintRefs(type, refs),
|
||||
data,
|
||||
created_at: Date.now()
|
||||
};
|
||||
applyConstraintDisplayRefs(rec, displayRefsIn);
|
||||
if (type === 'dimension') {
|
||||
const value = Number(dataIn?.value);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return false;
|
||||
}
|
||||
rec.data = { ...rec.data, value };
|
||||
}
|
||||
if (type === 'min_distance' || type === 'max_distance') {
|
||||
const value = Number(dataIn?.value);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return false;
|
||||
}
|
||||
rec.data = { ...rec.data, value };
|
||||
}
|
||||
list.push(rec);
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeConstraintRefs(type, refs) {
|
||||
const out = Array.from(new Set((refs || []).filter(Boolean)));
|
||||
if (type === 'horizontal' || type === 'vertical' || type === 'fixed') {
|
||||
return out.slice(0, 1);
|
||||
}
|
||||
if (type === 'horizontal_points' || type === 'vertical_points') {
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
if (type === 'point_on_line' || type === 'point_on_arc') {
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
if (type === 'min_distance' || type === 'max_distance') {
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
if (type === 'arc_center_on_line') {
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
if (type === 'arc_center_on_arc') {
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
if (type === 'arc_center_fixed_origin') {
|
||||
return out.slice(0, 1);
|
||||
}
|
||||
if (type === 'midpoint') {
|
||||
return out.slice(0, 3);
|
||||
}
|
||||
if (type === 'arc_center_coincident') {
|
||||
return out.slice(0, 2);
|
||||
}
|
||||
if (type === 'dimension') {
|
||||
if (out.length === 1) {
|
||||
return out;
|
||||
}
|
||||
return out.slice(0, 2).sort();
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function makeSketchConstraintKey(type, refs) {
|
||||
return `${type}:${this.normalizeConstraintRefs(type, refs).join(',')}`;
|
||||
}
|
||||
|
||||
export {
|
||||
deleteSelectedSketchConstraints,
|
||||
deleteSelectedSketchEntities,
|
||||
toggleSelectedConstruction,
|
||||
applySketchConstraint,
|
||||
editSketchDimensionConstraint,
|
||||
toggleSketchDimensionMode,
|
||||
findArcWithEndpoints,
|
||||
convertArcToCircle,
|
||||
findSketchConstraintInList,
|
||||
toggleSketchConstraintInList,
|
||||
normalizeConstraintRefs,
|
||||
makeSketchConstraintKey
|
||||
};
|
||||
2125
src/void/sketch/constraints_fallback.js
Normal file
2125
src/void/sketch/constraints_fallback.js
Normal file
File diff suppressed because it is too large
Load diff
172
src/void/sketch/constraints_tangent.js
Normal file
172
src/void/sketch/constraints_tangent.js
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
function applyTangentConstraint(constraint, ctx) {
|
||||
const {
|
||||
points,
|
||||
lines,
|
||||
arcs,
|
||||
fixed,
|
||||
dragged = new Set(),
|
||||
draggedArcs = new Set(),
|
||||
tangentAggressive = false,
|
||||
deps
|
||||
} = ctx;
|
||||
const {
|
||||
getLineEndpoints,
|
||||
getLineEndpointId,
|
||||
isFixed,
|
||||
setPoint,
|
||||
getArcCircleData,
|
||||
moveArcCenterBy
|
||||
} = deps;
|
||||
|
||||
const refs = Array.isArray(constraint?.refs) ? constraint.refs : [];
|
||||
if (refs.length < 2) return false;
|
||||
const line = lines.get(lines.has(refs[0]) ? refs[0] : (lines.has(refs[1]) ? refs[1] : null));
|
||||
const arc = arcs.get(arcs.has(refs[0]) ? refs[0] : (arcs.has(refs[1]) ? refs[1] : null));
|
||||
if (!line && !arc) return false;
|
||||
if (!line && arc) {
|
||||
const a1 = arcs.get(refs[0]);
|
||||
const a2 = arcs.get(refs[1]);
|
||||
if (!a1 || !a2) return false;
|
||||
return applyArcArcTangent(constraint, a1, a2, ctx, deps);
|
||||
}
|
||||
if (!line || !arc) return false;
|
||||
const [a, b] = getLineEndpoints(line, points);
|
||||
if (!a || !b) return false;
|
||||
const circ = getArcCircleData(arc, points);
|
||||
if (!circ) return false;
|
||||
|
||||
const x1 = a.x || 0;
|
||||
const y1 = a.y || 0;
|
||||
const x2 = b.x || 0;
|
||||
const y2 = b.y || 0;
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (len < 1e-9) return false;
|
||||
const nx = -dy / len;
|
||||
const ny = dx / len;
|
||||
const dist = ((circ.cx - x1) * nx + (circ.cy - y1) * ny);
|
||||
const sign = dist >= 0 ? 1 : -1;
|
||||
const target = sign * circ.radius;
|
||||
const err = dist - target;
|
||||
if (Math.abs(err) < 1e-5) return false;
|
||||
const relax = 0.35;
|
||||
const maxStep = Math.max(0.25, len * 0.2);
|
||||
const corr = Math.max(-maxStep, Math.min(maxStep, err * relax));
|
||||
|
||||
const aId = getLineEndpointId(line, 'a');
|
||||
const bId = getLineEndpointId(line, 'b');
|
||||
const fa = isFixed(aId, fixed);
|
||||
const fb = isFixed(bId, fixed);
|
||||
if (fa && fb) return false;
|
||||
if (!fa && !fb) {
|
||||
const mx = corr * nx;
|
||||
const my = corr * ny;
|
||||
let changed = false;
|
||||
changed = setPoint(a, x1 + mx, y1 + my) || changed;
|
||||
changed = setPoint(b, x2 + mx, y2 + my) || changed;
|
||||
return changed;
|
||||
}
|
||||
if (!fa) {
|
||||
return setPoint(a, x1 + corr * nx, y1 + corr * ny);
|
||||
}
|
||||
return setPoint(b, x2 + corr * nx, y2 + corr * ny);
|
||||
}
|
||||
|
||||
function applyArcArcTangent(constraint, arc1, arc2, ctx, deps) {
|
||||
const {
|
||||
points,
|
||||
fixed,
|
||||
dragged = new Set(),
|
||||
draggedArcs = new Set(),
|
||||
tangentAggressive = false
|
||||
} = ctx;
|
||||
const {
|
||||
getLineEndpointId,
|
||||
isFixed,
|
||||
getArcCircleData,
|
||||
moveArcCenterBy
|
||||
} = deps;
|
||||
|
||||
const c1 = getArcCircleData(arc1, points);
|
||||
const c2 = getArcCircleData(arc2, points);
|
||||
if (!c1 || !c2) return false;
|
||||
|
||||
let dx = c2.cx - c1.cx;
|
||||
let dy = c2.cy - c1.cy;
|
||||
let dist = Math.hypot(dx, dy);
|
||||
if (!Number.isFinite(dist) || dist < 1e-9) {
|
||||
dx = 1;
|
||||
dy = 0;
|
||||
dist = 1;
|
||||
}
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
|
||||
const ext = c1.radius + c2.radius;
|
||||
const intl = Math.abs(c1.radius - c2.radius);
|
||||
const mode = resolveArcArcTangentMode(constraint, dist, ext, intl);
|
||||
const target = mode === 'internal' ? intl : ext;
|
||||
const err = dist - target;
|
||||
if (Math.abs(err) < 1e-6) return false;
|
||||
|
||||
const a2id = getLineEndpointId(arc2, 'a');
|
||||
const b2id = getLineEndpointId(arc2, 'b');
|
||||
const a2f = isFixed(a2id, fixed);
|
||||
const b2f = isFixed(b2id, fixed);
|
||||
const a1id = getLineEndpointId(arc1, 'a');
|
||||
const b1id = getLineEndpointId(arc1, 'b');
|
||||
const a1f = isFixed(a1id, fixed);
|
||||
const b1f = isFixed(b1id, fixed);
|
||||
|
||||
const arc1Dragged = draggedArcs?.has?.(arc1.id);
|
||||
const arc2Dragged = draggedArcs?.has?.(arc2.id);
|
||||
const a1Dragged = dragged?.has?.(a1id) || dragged?.has?.(b1id);
|
||||
const a2Dragged = dragged?.has?.(a2id) || dragged?.has?.(b2id);
|
||||
|
||||
if (arc1Dragged && !arc2Dragged && !(a2f && b2f)) {
|
||||
return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
if (arc2Dragged && !arc1Dragged && !(a1f && b1f)) {
|
||||
return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
if (a1Dragged && !a2Dragged && !(a2f && b2f)) {
|
||||
return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
if (a2Dragged && !a1Dragged && !(a1f && b1f)) {
|
||||
return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
if (!(a2f && b2f)) {
|
||||
return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
if (!(a1f && b1f)) {
|
||||
return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveArcArcTangentMode(constraint, dist, ext, intl) {
|
||||
const data = (constraint && typeof constraint === 'object') ? (constraint.data || (constraint.data = {})) : {};
|
||||
if (data.arc_arc_mode === 'external' || data.arc_arc_mode === 'internal') {
|
||||
return data.arc_arc_mode;
|
||||
}
|
||||
const mode = Math.abs(dist - ext) <= Math.abs(dist - intl) ? 'external' : 'internal';
|
||||
data.arc_arc_mode = mode;
|
||||
return mode;
|
||||
}
|
||||
|
||||
function moveArcCenterTowardTarget(anchor, moving, targetDist, ux, uy, moveArc, points, fixed, aggressive = false, moveArcCenterBy) {
|
||||
const tx = anchor.cx + ux * targetDist;
|
||||
const ty = anchor.cy + uy * targetDist;
|
||||
let dx = tx - moving.cx;
|
||||
let dy = ty - moving.cy;
|
||||
if (!aggressive) {
|
||||
dx *= 0.4;
|
||||
dy *= 0.4;
|
||||
}
|
||||
return moveArcCenterBy(moveArc, points, fixed, dx, dy);
|
||||
}
|
||||
|
||||
export { applyTangentConstraint };
|
||||
2132
src/void/sketch/create.js
Normal file
2132
src/void/sketch/create.js
Normal file
File diff suppressed because it is too large
Load diff
112
src/void/sketch/curve.js
Normal file
112
src/void/sketch/curve.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
const CURVE_TYPE = {
|
||||
ARC: 'arc',
|
||||
CIRCLE: 'circle'
|
||||
};
|
||||
|
||||
const CURVE_DEF = {
|
||||
ARC_THREE_POINT: 'three-point',
|
||||
ARC_CENTER_POINT: 'center-point',
|
||||
ARC_TANGENT: 'tangent',
|
||||
CIRCLE_CENTER_POINT: 'center-point',
|
||||
CIRCLE_THREE_POINT: 'three-point'
|
||||
};
|
||||
|
||||
function isArcEntity(entity) {
|
||||
return entity?.type === 'arc' || entity?.curveType === CURVE_TYPE.ARC || entity?.curveType === CURVE_TYPE.CIRCLE || entity?.circle === true;
|
||||
}
|
||||
|
||||
function getCurveType(entity) {
|
||||
if (!entity || typeof entity !== 'object') return null;
|
||||
if (entity?.curveType === CURVE_TYPE.CIRCLE) return CURVE_TYPE.CIRCLE;
|
||||
if (entity?.curveType === CURVE_TYPE.ARC) return CURVE_TYPE.ARC;
|
||||
if (entity?.circle === true) return CURVE_TYPE.CIRCLE;
|
||||
if (entity?.type === 'arc') return CURVE_TYPE.ARC;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isCircleCurve(entity) {
|
||||
return getCurveType(entity) === CURVE_TYPE.CIRCLE;
|
||||
}
|
||||
|
||||
function getCurveDefinition(entity) {
|
||||
if (!isArcEntity(entity)) return null;
|
||||
if (typeof entity?.curveDefinition === 'string' && entity.curveDefinition) {
|
||||
return entity.curveDefinition;
|
||||
}
|
||||
if (isCircleCurve(entity)) {
|
||||
return CURVE_DEF.CIRCLE_CENTER_POINT;
|
||||
}
|
||||
if (Number.isFinite(entity?.mx) && Number.isFinite(entity?.my)) {
|
||||
return CURVE_DEF.ARC_THREE_POINT;
|
||||
}
|
||||
return CURVE_DEF.ARC_CENTER_POINT;
|
||||
}
|
||||
|
||||
function applyCurveSchema(entity, curveType, curveDefinition) {
|
||||
if (!isArcEntity(entity)) return false;
|
||||
let changed = false;
|
||||
if (entity.curveType !== curveType) {
|
||||
entity.curveType = curveType;
|
||||
changed = true;
|
||||
}
|
||||
if (entity.curveDefinition !== curveDefinition) {
|
||||
entity.curveDefinition = curveDefinition;
|
||||
changed = true;
|
||||
}
|
||||
const legacyCircle = curveType === CURVE_TYPE.CIRCLE;
|
||||
if (entity.circle !== legacyCircle) {
|
||||
entity.circle = legacyCircle;
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function markArcThreePoint(entity) {
|
||||
return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_THREE_POINT);
|
||||
}
|
||||
|
||||
function markArcCenterPoint(entity) {
|
||||
return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_CENTER_POINT);
|
||||
}
|
||||
|
||||
function markArcTangent(entity) {
|
||||
return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_TANGENT);
|
||||
}
|
||||
|
||||
function markCircleCenterPoint(entity) {
|
||||
return applyCurveSchema(entity, CURVE_TYPE.CIRCLE, CURVE_DEF.CIRCLE_CENTER_POINT);
|
||||
}
|
||||
|
||||
function markCircleThreePoint(entity) {
|
||||
return applyCurveSchema(entity, CURVE_TYPE.CIRCLE, CURVE_DEF.CIRCLE_THREE_POINT);
|
||||
}
|
||||
|
||||
function isCenterPointCircle(entity) {
|
||||
return isCircleCurve(entity) && getCurveDefinition(entity) === CURVE_DEF.CIRCLE_CENTER_POINT;
|
||||
}
|
||||
|
||||
function isThreePointCircle(entity) {
|
||||
if (!isCircleCurve(entity)) return false;
|
||||
if (getCurveDefinition(entity) !== CURVE_DEF.CIRCLE_THREE_POINT) return false;
|
||||
const ids = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds.filter(Boolean) : [];
|
||||
return ids.length >= 3;
|
||||
}
|
||||
|
||||
export {
|
||||
CURVE_TYPE,
|
||||
CURVE_DEF,
|
||||
isArcEntity,
|
||||
getCurveType,
|
||||
isCircleCurve,
|
||||
getCurveDefinition,
|
||||
applyCurveSchema,
|
||||
markArcThreePoint,
|
||||
markArcCenterPoint,
|
||||
markArcTangent,
|
||||
markCircleCenterPoint,
|
||||
markCircleThreePoint,
|
||||
isCenterPointCircle,
|
||||
isThreePointCircle
|
||||
};
|
||||
1033
src/void/sketch/geometry.js
Normal file
1033
src/void/sketch/geometry.js
Normal file
File diff suppressed because it is too large
Load diff
484
src/void/sketch/index.js
Normal file
484
src/void/sketch/index.js
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { space } from '../../moto/space.js';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
SKETCH_POINT_MERGE_EPS
|
||||
} from './constants.js';
|
||||
import * as sketchGeom from './geometry.js';
|
||||
import * as sketchCreate from './create.js';
|
||||
import {
|
||||
getEditingSketchFeature,
|
||||
isSketchEditing,
|
||||
setSketchTool,
|
||||
getSketchTool,
|
||||
cancelSketchLine,
|
||||
cancelSketchArc,
|
||||
cancelSketchCircle,
|
||||
cancelSketchRect,
|
||||
clearSketchSelection,
|
||||
getSelectedSketchMirrorAxis,
|
||||
startSketchMirrorMode,
|
||||
stopSketchMirrorMode,
|
||||
getSelectedSketchPatternCenter,
|
||||
startSketchCircularPatternMode,
|
||||
stopSketchCircularPatternMode,
|
||||
getSelectedSketchGridAnchor,
|
||||
startSketchGridPatternMode,
|
||||
stopSketchGridPatternMode,
|
||||
editSketchCircularPatternConstraint,
|
||||
editSketchGridPatternConstraint,
|
||||
handleSketchKeyDown,
|
||||
selectSketchConstraint,
|
||||
setHoveredSketchConstraint,
|
||||
useHoveredDerivedEdge,
|
||||
useHoveredDerivedPoint
|
||||
} from './tools.js';
|
||||
import {
|
||||
deleteSelectedSketchConstraints,
|
||||
deleteSelectedSketchEntities,
|
||||
toggleSelectedConstruction,
|
||||
applySketchConstraint,
|
||||
editSketchDimensionConstraint,
|
||||
toggleSketchDimensionMode,
|
||||
findArcWithEndpoints,
|
||||
convertArcToCircle,
|
||||
findSketchConstraintInList,
|
||||
toggleSketchConstraintInList,
|
||||
normalizeConstraintRefs,
|
||||
makeSketchConstraintKey
|
||||
} from './constraints_actions.js';
|
||||
import {
|
||||
handleSketchPointerDown,
|
||||
handleSketchHover,
|
||||
handleSketchPointerMove,
|
||||
handleSketchMouseUp,
|
||||
handleSketchDrag,
|
||||
collectDragLockedArcCenters,
|
||||
applyDragLockedArcCenters,
|
||||
draggedArcsHaveTangent,
|
||||
isPointOnSelectedSketchLine
|
||||
} from './pointer.js';
|
||||
import {
|
||||
startSketchMarquee,
|
||||
updateSketchMarquee,
|
||||
finishSketchMarquee,
|
||||
clearSketchMarquee,
|
||||
updateSketchMarqueeVisual,
|
||||
viewportPointFromClient,
|
||||
selectSketchEntitiesInMarquee,
|
||||
projectSketchLocalToScreen,
|
||||
isPointInRect,
|
||||
segmentTouchesRect,
|
||||
segmentsIntersect,
|
||||
collectSelectedCoordinateRefs,
|
||||
collectCoordinateRefsFromIds
|
||||
} from './marquee.js';
|
||||
|
||||
function createSketchPoint(feature, local) {
|
||||
return sketchCreate.createSketchPoint.call(this, feature, local);
|
||||
}
|
||||
|
||||
function createSketchLine(feature, a, b, options = {}) {
|
||||
return sketchCreate.createSketchLine.call(this, feature, a, b, options);
|
||||
}
|
||||
|
||||
function createSketchArc(feature, start, end, onArc, options = {}) {
|
||||
return sketchCreate.createSketchArc.call(this, feature, start, end, onArc, options);
|
||||
}
|
||||
|
||||
function createSketchArcFromCenter(feature, center, start, endRaw, options = {}) {
|
||||
return sketchCreate.createSketchArcFromCenter.call(this, feature, center, start, endRaw, options);
|
||||
}
|
||||
|
||||
function createSketchCircle(feature, center, edge, options = {}) {
|
||||
return sketchCreate.createSketchCircle.call(this, feature, center, edge, options);
|
||||
}
|
||||
|
||||
function createSketchCircle3Point(feature, a, b, c, options = {}) {
|
||||
return sketchCreate.createSketchCircle3Point.call(this, feature, a, b, c, options);
|
||||
}
|
||||
|
||||
function makeSketchRectPreview(start, end, centerMode = false) {
|
||||
return sketchCreate.makeSketchRectPreview.call(this, start, end, centerMode);
|
||||
}
|
||||
|
||||
function getRectangleCorners(start, end, centerMode = false) {
|
||||
return sketchCreate.getRectangleCorners.call(this, start, end, centerMode);
|
||||
}
|
||||
|
||||
function createSketchRectangle(feature, start, end, options = {}) {
|
||||
return sketchCreate.createSketchRectangle.call(this, feature, start, end, options);
|
||||
}
|
||||
|
||||
function createDerivedSketchPoint(feature, local, source = {}) {
|
||||
return sketchCreate.createDerivedSketchPoint.call(this, feature, local, source);
|
||||
}
|
||||
|
||||
function createDerivedSketchLine(feature, candidate) {
|
||||
return sketchCreate.createDerivedSketchLine.call(this, feature, candidate);
|
||||
}
|
||||
|
||||
function deriveSelectionsAtomic(feature, selection) {
|
||||
return sketchCreate.deriveSelectionsAtomic.call(this, feature, selection);
|
||||
}
|
||||
|
||||
function refreshDerivedSketchGeometry(feature) {
|
||||
return sketchCreate.refreshDerivedSketchGeometry.call(this, feature);
|
||||
}
|
||||
|
||||
function createSketchPolygonFromSelectedCircle(mode = 'inscribed') {
|
||||
return sketchCreate.createSketchPolygonFromSelectedCircle.call(this, mode);
|
||||
}
|
||||
|
||||
function mirrorSelectedSketchGeometry(options = {}) {
|
||||
return sketchCreate.mirrorSelectedSketchGeometry.call(this, options);
|
||||
}
|
||||
|
||||
function circularPatternSelectedSketchGeometry(options = {}) {
|
||||
return sketchCreate.circularPatternSelectedSketchGeometry.call(this, options);
|
||||
}
|
||||
|
||||
function updateCircularPatternConstraintCopies(constraintId, count) {
|
||||
return sketchCreate.updateCircularPatternConstraintCopies.call(this, constraintId, count);
|
||||
}
|
||||
|
||||
function gridPatternSelectedSketchGeometry(options = {}) {
|
||||
return sketchCreate.gridPatternSelectedSketchGeometry.call(this, options);
|
||||
}
|
||||
|
||||
function updateGridPatternConstraintCopies(constraintId, axis = 'h', count = 3) {
|
||||
return sketchCreate.updateGridPatternConstraintCopies.call(this, constraintId, axis, count);
|
||||
}
|
||||
|
||||
function getSelectedSketchCircle(feature) {
|
||||
return sketchCreate.getSelectedSketchCircle.call(this, feature);
|
||||
}
|
||||
|
||||
function getCircleData(feature, circle) {
|
||||
return sketchCreate.getCircleData.call(this, feature, circle);
|
||||
}
|
||||
|
||||
function computeArcGeometry(start, end, onArc) {
|
||||
return sketchCreate.computeArcGeometry.call(this, start, end, onArc);
|
||||
}
|
||||
|
||||
function computeArcGeometryFromCenter(center, start, endRaw) {
|
||||
return sketchCreate.computeArcGeometryFromCenter.call(this, center, start, endRaw);
|
||||
}
|
||||
|
||||
function computeCircleFromThreePoints(a, b, c) {
|
||||
return sketchCreate.computeCircleFromThreePoints.call(this, a, b, c);
|
||||
}
|
||||
|
||||
function addCoincidentConstraintIfMissing(sketch, aId, bId) {
|
||||
return sketchCreate.addCoincidentConstraintIfMissing.call(this, sketch, aId, bId);
|
||||
}
|
||||
|
||||
function convertArcToCircleInSketch(sketch, p1Id, p2Id) {
|
||||
return sketchCreate.convertArcToCircleInSketch.call(this, sketch, p1Id, p2Id);
|
||||
}
|
||||
|
||||
function updateSketchInteractionVisuals() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) {
|
||||
return;
|
||||
}
|
||||
const dragHoverId = this.sketchDrag?.snapPointId || null;
|
||||
const external = this.hoveredDerivedCandidate || null;
|
||||
const canShowExternalPreview = !this.sketchDrag
|
||||
&& !this.sketchLineStart
|
||||
&& !this.sketchArcStart
|
||||
&& !this.sketchCircleCenter
|
||||
&& !this.sketchRectStart;
|
||||
const externalPointLocal = canShowExternalPreview ? (external?.hoverPoint?.local || null) : null;
|
||||
const showExternalPoint = !!externalPointLocal;
|
||||
const showExternalSegments = canShowExternalPreview && Array.isArray(external?.pathLocalSegments) && external.pathLocalSegments.length > 1;
|
||||
const showExternalLine = canShowExternalPreview && !showExternalSegments && !!external?.aLocal && !!external?.bLocal;
|
||||
const externalLine = showExternalLine
|
||||
? { a: external.aLocal, b: external.bLocal, forceHover: true, projected: true }
|
||||
: null;
|
||||
const externalStart = showExternalPoint
|
||||
? { ...externalPointLocal, projected: true }
|
||||
: null;
|
||||
const externalEnd = null;
|
||||
const externalPointWorld = showExternalPoint ? (external?.hoverPoint?.world || null) : null;
|
||||
const projectedFaceSegments = showExternalSegments
|
||||
? external.pathLocalSegments
|
||||
: (!showExternalPoint && !showExternalLine && this.hoveredSolidFaceKey
|
||||
? this.projectFaceBoundaryToSketch(feature, this.hoveredSolidFaceKey)
|
||||
: null);
|
||||
const sourceFaceWorldSegments = showExternalSegments
|
||||
? (external.pathWorldSegments || null)
|
||||
: (!showExternalPoint && !showExternalLine && this.hoveredSolidFaceKey
|
||||
? (() => {
|
||||
const loops = api.solids?.getFaceBoundaryLoops?.(this.hoveredSolidFaceKey) || [];
|
||||
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 = points[i];
|
||||
const b = points[i + 1];
|
||||
if (!a || !b) continue;
|
||||
out.push({
|
||||
a: { x: Number(a.x || 0), y: Number(a.y || 0), z: Number(a.z || 0) },
|
||||
b: { x: Number(b.x || 0), y: Number(b.y || 0), z: Number(b.z || 0) }
|
||||
});
|
||||
}
|
||||
}
|
||||
return out.length ? out : null;
|
||||
})()
|
||||
: null);
|
||||
api.sketchRuntime?.setEntityInteraction(feature.id, {
|
||||
hoveredId: this.sketchDrag ? dragHoverId : this.hoveredSketchEntityId,
|
||||
selectedIds: Array.from(this.selectedSketchEntities),
|
||||
mirrorMode: !!this.sketchMirrorMode,
|
||||
mirrorAxisId: this.sketchMirrorAxisId || null,
|
||||
circularPatternMode: !!this.sketchCircularPatternMode,
|
||||
circularPatternCenterRef: this.sketchCircularPatternCenterRef || null,
|
||||
gridPatternMode: !!this.sketchGridPatternMode,
|
||||
gridPatternCenterRef: this.sketchGridPatternCenterRef || null,
|
||||
hoveredConstraintId: this.hoveredSketchConstraintId || null,
|
||||
selectedConstraintIds: Array.from(this.selectedSketchConstraints || []),
|
||||
previewLine: this.sketchLinePreview || externalLine,
|
||||
previewExternalWorldLine: showExternalLine
|
||||
? {
|
||||
a: external?.a || null,
|
||||
b: external?.b || null,
|
||||
forceHover: true
|
||||
}
|
||||
: null,
|
||||
previewExternalWorldPoint: showExternalPoint
|
||||
? {
|
||||
x: externalPointWorld?.x || 0,
|
||||
y: externalPointWorld?.y || 0,
|
||||
z: externalPointWorld?.z || 0,
|
||||
forceHover: true
|
||||
}
|
||||
: null,
|
||||
previewExternalWorldSegments: sourceFaceWorldSegments || null,
|
||||
previewFaceSegments: projectedFaceSegments || null,
|
||||
previewStart: this.sketchLineStart || this.sketchArcStart || this.sketchCircleCenter || this.sketchRectStart || externalStart,
|
||||
previewEnd: this.sketchArcEnd || this.sketchCircleSecond || externalEnd || null,
|
||||
previewMid: null,
|
||||
previewArc: this.sketchArcPreview,
|
||||
previewRect: this.sketchRectPreview
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('void-state-change'));
|
||||
}
|
||||
|
||||
function newSketchEntityId(prefix = 'e') {
|
||||
const tail = Math.random().toString(36).slice(2, 7);
|
||||
return `${prefix}-${Date.now().toString(36)}-${tail}`;
|
||||
}
|
||||
|
||||
function pointerDistance(event, pointerDown) {
|
||||
return sketchGeom.pointerDistance.call(this, event, pointerDown);
|
||||
}
|
||||
|
||||
function hitTestSketchEntity(event, feature) {
|
||||
return sketchGeom.hitTestSketchEntity.call(this, event, feature);
|
||||
}
|
||||
|
||||
function getArcCenterLocalFromEntity(arc, pointById) {
|
||||
return sketchGeom.getArcCenterLocalFromEntity.call(this, arc, pointById);
|
||||
}
|
||||
|
||||
function getSketchEntityHitFromIntersections(intersections, feature) {
|
||||
return sketchGeom.getSketchEntityHitFromIntersections.call(this, intersections, feature);
|
||||
}
|
||||
|
||||
function resolveSketchHit(event, intersections, feature) {
|
||||
return sketchGeom.resolveSketchHit.call(this, event, intersections, feature);
|
||||
}
|
||||
|
||||
function resolveDerivedEdgeCandidate(event, intersections, feature) {
|
||||
return sketchGeom.resolveDerivedEdgeCandidate.call(this, event, intersections, feature);
|
||||
}
|
||||
|
||||
function projectFaceBoundaryToSketch(feature, faceKey) {
|
||||
return sketchGeom.projectFaceBoundaryToSketch.call(this, feature, faceKey);
|
||||
}
|
||||
|
||||
function isSketchEventInViewport(event) {
|
||||
return sketchGeom.isSketchEventInViewport.call(this, event);
|
||||
}
|
||||
|
||||
function getSketchHitLocalPoint(feature, hit) {
|
||||
return sketchGeom.getSketchHitLocalPoint.call(this, feature, hit);
|
||||
}
|
||||
|
||||
function getSketchDragSnapTarget(event, feature, movedPointIds) {
|
||||
return sketchGeom.getSketchDragSnapTarget.call(this, event, feature, movedPointIds);
|
||||
}
|
||||
|
||||
function findPointByCoord(feature, local, eps = SKETCH_POINT_MERGE_EPS) {
|
||||
return sketchGeom.findPointByCoord.call(this, feature, local, eps);
|
||||
}
|
||||
|
||||
function ensureSketchPoint(sketch, local) {
|
||||
return sketchGeom.ensureSketchPoint.call(this, sketch, local);
|
||||
}
|
||||
|
||||
function getLineEndpoints(line, pointById) {
|
||||
return sketchGeom.getLineEndpoints.call(this, line, pointById);
|
||||
}
|
||||
|
||||
function getArcEndpoints(arc, pointById) {
|
||||
return sketchGeom.getArcEndpoints.call(this, arc, pointById);
|
||||
}
|
||||
|
||||
function applyCircleDragKinematics(feature, dx = 0, dy = 0, local = null) {
|
||||
return sketchGeom.applyCircleDragKinematics.call(this, feature, dx, dy, local);
|
||||
}
|
||||
|
||||
function projectPointOnArcConstraintsForArcs(feature, arcIds) {
|
||||
return sketchGeom.projectPointOnArcConstraintsForArcs.call(this, feature, arcIds);
|
||||
}
|
||||
|
||||
function rebaseSketchDragState(feature, local) {
|
||||
return sketchGeom.rebaseSketchDragState.call(this, feature, local);
|
||||
}
|
||||
|
||||
function sampleArcPolyline(arc, a, b, segments = 24) {
|
||||
return sketchGeom.sampleArcPolyline.call(this, arc, a, b, segments);
|
||||
}
|
||||
|
||||
function distanceToSegmentPx(px, py, ax, ay, bx, by) {
|
||||
return sketchGeom.distanceToSegmentPx.call(this, px, py, ax, ay, bx, by);
|
||||
}
|
||||
|
||||
function getEventViewportXY(event) {
|
||||
return sketchGeom.getEventViewportXY.call(this, event);
|
||||
}
|
||||
|
||||
function getSketchBasis(feature) {
|
||||
return sketchGeom.getSketchBasis.call(this, feature);
|
||||
}
|
||||
|
||||
function sketchLocalToWorld(local, basis) {
|
||||
return sketchGeom.sketchLocalToWorld.call(this, local, basis);
|
||||
}
|
||||
|
||||
function worldToSketchLocal(world, basis) {
|
||||
return sketchGeom.worldToSketchLocal.call(this, world, basis);
|
||||
}
|
||||
|
||||
function projectEventToSketchLocal(event, feature) {
|
||||
return sketchGeom.projectEventToSketchLocal.call(this, event, feature);
|
||||
}
|
||||
|
||||
export {
|
||||
getEditingSketchFeature,
|
||||
isSketchEditing,
|
||||
setSketchTool,
|
||||
getSketchTool,
|
||||
cancelSketchLine,
|
||||
cancelSketchArc,
|
||||
cancelSketchCircle,
|
||||
cancelSketchRect,
|
||||
clearSketchSelection,
|
||||
getSelectedSketchMirrorAxis,
|
||||
startSketchMirrorMode,
|
||||
stopSketchMirrorMode,
|
||||
getSelectedSketchPatternCenter,
|
||||
startSketchCircularPatternMode,
|
||||
stopSketchCircularPatternMode,
|
||||
getSelectedSketchGridAnchor,
|
||||
startSketchGridPatternMode,
|
||||
stopSketchGridPatternMode,
|
||||
editSketchCircularPatternConstraint,
|
||||
editSketchGridPatternConstraint,
|
||||
selectSketchConstraint,
|
||||
setHoveredSketchConstraint,
|
||||
useHoveredDerivedEdge,
|
||||
useHoveredDerivedPoint,
|
||||
handleSketchKeyDown,
|
||||
applySketchConstraint,
|
||||
editSketchDimensionConstraint,
|
||||
toggleSketchDimensionMode,
|
||||
findSketchConstraintInList,
|
||||
toggleSketchConstraintInList,
|
||||
normalizeConstraintRefs,
|
||||
makeSketchConstraintKey,
|
||||
handleSketchPointerDown,
|
||||
handleSketchHover,
|
||||
handleSketchPointerMove,
|
||||
handleSketchMouseUp,
|
||||
handleSketchDrag,
|
||||
toggleSelectedConstruction,
|
||||
updateSketchInteractionVisuals,
|
||||
newSketchEntityId,
|
||||
pointerDistance,
|
||||
hitTestSketchEntity,
|
||||
getSketchEntityHitFromIntersections,
|
||||
resolveSketchHit,
|
||||
resolveDerivedEdgeCandidate,
|
||||
projectFaceBoundaryToSketch,
|
||||
isSketchEventInViewport,
|
||||
getSketchHitLocalPoint,
|
||||
getSketchDragSnapTarget,
|
||||
distanceToSegmentPx,
|
||||
getEventViewportXY,
|
||||
getSketchBasis,
|
||||
sketchLocalToWorld,
|
||||
worldToSketchLocal,
|
||||
projectEventToSketchLocal,
|
||||
viewportPointFromClient,
|
||||
startSketchMarquee,
|
||||
updateSketchMarquee,
|
||||
finishSketchMarquee,
|
||||
clearSketchMarquee,
|
||||
updateSketchMarqueeVisual,
|
||||
selectSketchEntitiesInMarquee,
|
||||
projectSketchLocalToScreen,
|
||||
isPointInRect,
|
||||
segmentTouchesRect,
|
||||
segmentsIntersect,
|
||||
collectSelectedCoordinateRefs,
|
||||
collectCoordinateRefsFromIds,
|
||||
isPointOnSelectedSketchLine,
|
||||
createSketchArc,
|
||||
createSketchArcFromCenter,
|
||||
createSketchCircle,
|
||||
createSketchCircle3Point,
|
||||
createSketchRectangle,
|
||||
makeSketchRectPreview,
|
||||
getRectangleCorners,
|
||||
findArcWithEndpoints,
|
||||
convertArcToCircle,
|
||||
convertArcToCircleInSketch,
|
||||
computeArcGeometry,
|
||||
computeArcGeometryFromCenter,
|
||||
computeCircleFromThreePoints,
|
||||
getArcEndpoints,
|
||||
applyCircleDragKinematics,
|
||||
projectPointOnArcConstraintsForArcs,
|
||||
collectDragLockedArcCenters,
|
||||
applyDragLockedArcCenters,
|
||||
draggedArcsHaveTangent,
|
||||
rebaseSketchDragState,
|
||||
getArcCenterLocalFromEntity,
|
||||
sampleArcPolyline,
|
||||
createSketchPoint,
|
||||
createSketchLine,
|
||||
createDerivedSketchPoint,
|
||||
createDerivedSketchLine,
|
||||
deriveSelectionsAtomic,
|
||||
refreshDerivedSketchGeometry,
|
||||
createSketchPolygonFromSelectedCircle,
|
||||
mirrorSelectedSketchGeometry,
|
||||
circularPatternSelectedSketchGeometry,
|
||||
updateCircularPatternConstraintCopies,
|
||||
gridPatternSelectedSketchGeometry,
|
||||
updateGridPatternConstraintCopies,
|
||||
deleteSelectedSketchEntities,
|
||||
deleteSelectedSketchConstraints,
|
||||
findPointByCoord,
|
||||
ensureSketchPoint,
|
||||
getLineEndpoints,
|
||||
getSelectedSketchCircle,
|
||||
getCircleData
|
||||
};
|
||||
275
src/void/sketch/marquee.js
Normal file
275
src/void/sketch/marquee.js
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { space } from '../../moto/space.js';
|
||||
import { api } from '../api.js';
|
||||
|
||||
function startSketchMarquee(feature, pointerDown, event) {
|
||||
const start = this.viewportPointFromClient(pointerDown?.clientX, pointerDown?.clientY);
|
||||
const end = this.getEventViewportXY(event);
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
this.sketchMarquee = {
|
||||
featureId: feature?.id || null,
|
||||
startX: start.x,
|
||||
startY: start.y,
|
||||
endX: end.x,
|
||||
endY: end.y,
|
||||
mode: end.x >= start.x ? 'window' : 'cross'
|
||||
};
|
||||
this.updateSketchMarqueeVisual();
|
||||
}
|
||||
|
||||
function updateSketchMarquee(event) {
|
||||
if (!this.sketchMarquee) {
|
||||
return;
|
||||
}
|
||||
const end = this.getEventViewportXY(event);
|
||||
if (!end) {
|
||||
return;
|
||||
}
|
||||
this.sketchMarquee.endX = end.x;
|
||||
this.sketchMarquee.endY = end.y;
|
||||
this.sketchMarquee.mode = end.x >= this.sketchMarquee.startX ? 'window' : 'cross';
|
||||
this.updateSketchMarqueeVisual();
|
||||
}
|
||||
|
||||
function finishSketchMarquee(feature) {
|
||||
if (!this.sketchMarquee) {
|
||||
return;
|
||||
}
|
||||
const marquee = this.sketchMarquee;
|
||||
this.clearSketchMarquee();
|
||||
const selectIds = this.selectSketchEntitiesInMarquee(feature, marquee);
|
||||
this.selectedSketchEntities = new Set(selectIds);
|
||||
this.selectedSketchArcCenters?.clear?.();
|
||||
this.hoveredSketchEntityId = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
}
|
||||
|
||||
function clearSketchMarquee() {
|
||||
this.sketchMarquee = null;
|
||||
if (this.sketchMarqueeEl?.parentElement) {
|
||||
this.sketchMarqueeEl.parentElement.removeChild(this.sketchMarqueeEl);
|
||||
}
|
||||
this.sketchMarqueeEl = null;
|
||||
}
|
||||
|
||||
function updateSketchMarqueeVisual() {
|
||||
const marquee = this.sketchMarquee;
|
||||
if (!marquee) {
|
||||
this.clearSketchMarquee();
|
||||
return;
|
||||
}
|
||||
const { container } = space.internals();
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!this.sketchMarqueeEl) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sketch-marquee sketch-marquee-window';
|
||||
container.appendChild(el);
|
||||
this.sketchMarqueeEl = el;
|
||||
}
|
||||
const left = Math.min(marquee.startX, marquee.endX);
|
||||
const top = Math.min(marquee.startY, marquee.endY);
|
||||
const width = Math.abs(marquee.endX - marquee.startX);
|
||||
const height = Math.abs(marquee.endY - marquee.startY);
|
||||
this.sketchMarqueeEl.className = `sketch-marquee ${marquee.mode === 'cross' ? 'sketch-marquee-cross' : 'sketch-marquee-window'}`;
|
||||
this.sketchMarqueeEl.style.left = `${left}px`;
|
||||
this.sketchMarqueeEl.style.top = `${top}px`;
|
||||
this.sketchMarqueeEl.style.width = `${width}px`;
|
||||
this.sketchMarqueeEl.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
function viewportPointFromClient(clientX, clientY) {
|
||||
const { container } = space.internals();
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX || 0) - rect.left,
|
||||
y: (clientY || 0) - rect.top
|
||||
};
|
||||
}
|
||||
|
||||
function selectSketchEntitiesInMarquee(feature, marquee) {
|
||||
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||
const basis = this.getSketchBasis(feature);
|
||||
if (!basis) {
|
||||
return [];
|
||||
}
|
||||
const minX = Math.min(marquee.startX, marquee.endX);
|
||||
const maxX = Math.max(marquee.startX, marquee.endX);
|
||||
const minY = Math.min(marquee.startY, marquee.endY);
|
||||
const maxY = Math.max(marquee.startY, marquee.endY);
|
||||
const rect = { minX, maxX, minY, maxY };
|
||||
const isWindow = marquee.mode !== 'cross';
|
||||
|
||||
const out = [];
|
||||
const pointById = new Map();
|
||||
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') {
|
||||
const p = this.projectSketchLocalToScreen({ x: entity.x || 0, y: entity.y || 0 }, basis);
|
||||
if (!p) continue;
|
||||
if (this.isPointInRect(p.x, p.y, rect)) {
|
||||
out.push(entity.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'line') {
|
||||
const [a, b] = this.getLineEndpoints(entity, pointById);
|
||||
if (!a || !b) continue;
|
||||
const pa = this.projectSketchLocalToScreen({ x: a.x || 0, y: a.y || 0 }, basis);
|
||||
const pb = this.projectSketchLocalToScreen({ x: b.x || 0, y: b.y || 0 }, basis);
|
||||
if (!pa || !pb) continue;
|
||||
const hit = isWindow
|
||||
? (this.isPointInRect(pa.x, pa.y, rect) && this.isPointInRect(pb.x, pb.y, rect))
|
||||
: this.segmentTouchesRect(pa, pb, rect);
|
||||
if (hit) {
|
||||
out.push(entity.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'arc') {
|
||||
const [a, b] = this.getArcEndpoints(entity, pointById);
|
||||
if (!a || !b) continue;
|
||||
const sample = this.sampleArcPolyline(entity, a, b, 28);
|
||||
if (!sample.length) continue;
|
||||
const screen = sample
|
||||
.map(local => this.projectSketchLocalToScreen(local, basis))
|
||||
.filter(Boolean);
|
||||
if (screen.length < 2) continue;
|
||||
let hit = false;
|
||||
if (isWindow) {
|
||||
hit = screen.every(p => this.isPointInRect(p.x, p.y, rect));
|
||||
} else {
|
||||
for (let i = 0; i < screen.length - 1 && !hit; i++) {
|
||||
if (this.segmentTouchesRect(screen[i], screen[i + 1], rect)) {
|
||||
hit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hit) {
|
||||
out.push(entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function projectSketchLocalToScreen(local, basis) {
|
||||
const world = this.sketchLocalToWorld(local, basis);
|
||||
const proj = api.overlay.project3Dto2D(world);
|
||||
if (!proj?.visible) {
|
||||
return null;
|
||||
}
|
||||
return { x: proj.x, y: proj.y };
|
||||
}
|
||||
|
||||
function isPointInRect(x, y, rect) {
|
||||
return x >= rect.minX && x <= rect.maxX && y >= rect.minY && y <= rect.maxY;
|
||||
}
|
||||
|
||||
function segmentTouchesRect(a, b, rect) {
|
||||
if (this.isPointInRect(a.x, a.y, rect) || this.isPointInRect(b.x, b.y, rect)) {
|
||||
return true;
|
||||
}
|
||||
const edges = [
|
||||
[{ x: rect.minX, y: rect.minY }, { x: rect.maxX, y: rect.minY }],
|
||||
[{ x: rect.maxX, y: rect.minY }, { x: rect.maxX, y: rect.maxY }],
|
||||
[{ x: rect.maxX, y: rect.maxY }, { x: rect.minX, y: rect.maxY }],
|
||||
[{ x: rect.minX, y: rect.maxY }, { x: rect.minX, y: rect.minY }]
|
||||
];
|
||||
for (const [c, d] of edges) {
|
||||
if (this.segmentsIntersect(a, b, c, d)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function segmentsIntersect(a, b, c, d) {
|
||||
const orient = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
||||
const onSeg = (p, q, r) =>
|
||||
Math.min(p.x, r.x) <= q.x && q.x <= Math.max(p.x, r.x) &&
|
||||
Math.min(p.y, r.y) <= q.y && q.y <= Math.max(p.y, r.y);
|
||||
|
||||
const o1 = orient(a, b, c);
|
||||
const o2 = orient(a, b, d);
|
||||
const o3 = orient(c, d, a);
|
||||
const o4 = orient(c, d, b);
|
||||
|
||||
if ((o1 > 0) !== (o2 > 0) && (o3 > 0) !== (o4 > 0)) {
|
||||
return true;
|
||||
}
|
||||
if (Math.abs(o1) < 1e-9 && onSeg(a, c, b)) return true;
|
||||
if (Math.abs(o2) < 1e-9 && onSeg(a, d, b)) return true;
|
||||
if (Math.abs(o3) < 1e-9 && onSeg(c, a, d)) return true;
|
||||
if (Math.abs(o4) < 1e-9 && onSeg(c, b, d)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectSelectedCoordinateRefs(feature) {
|
||||
return this.collectCoordinateRefsFromIds(feature, this.selectedSketchEntities);
|
||||
}
|
||||
|
||||
function collectCoordinateRefsFromIds(feature, selectedIds) {
|
||||
const refs = new Set();
|
||||
const entities = Array.isArray(feature.entities) ? feature.entities : [];
|
||||
const pointById = new Map();
|
||||
for (const entity of entities) {
|
||||
if (entity?.type === 'point' && entity.id) {
|
||||
pointById.set(entity.id, entity);
|
||||
}
|
||||
}
|
||||
for (const entity of entities) {
|
||||
if (!selectedIds?.has(entity.id)) {
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'point') {
|
||||
refs.add(entity);
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'line') {
|
||||
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 && pointById.has(aId)) refs.add(pointById.get(aId));
|
||||
if (bId && pointById.has(bId)) refs.add(pointById.get(bId));
|
||||
}
|
||||
if (entity.type === 'arc') {
|
||||
const aId = typeof entity?.a === 'string' ? entity.a : null;
|
||||
const bId = typeof entity?.b === 'string' ? entity.b : null;
|
||||
if (aId && pointById.has(aId)) refs.add(pointById.get(aId));
|
||||
if (bId && pointById.has(bId)) refs.add(pointById.get(bId));
|
||||
const threePointIds = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds : [];
|
||||
for (const pid of threePointIds) {
|
||||
if (pid && pointById.has(pid)) refs.add(pointById.get(pid));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
export {
|
||||
startSketchMarquee,
|
||||
updateSketchMarquee,
|
||||
finishSketchMarquee,
|
||||
clearSketchMarquee,
|
||||
updateSketchMarqueeVisual,
|
||||
viewportPointFromClient,
|
||||
selectSketchEntitiesInMarquee,
|
||||
projectSketchLocalToScreen,
|
||||
isPointInRect,
|
||||
segmentTouchesRect,
|
||||
segmentsIntersect,
|
||||
collectSelectedCoordinateRefs,
|
||||
collectCoordinateRefsFromIds
|
||||
};
|
||||
1226
src/void/sketch/pointer.js
Normal file
1226
src/void/sketch/pointer.js
Normal file
File diff suppressed because it is too large
Load diff
918
src/void/sketch/runtime.js
Normal file
918
src/void/sketch/runtime.js
Normal file
|
|
@ -0,0 +1,918 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { THREE, Line2, LineGeometry, LineMaterial, LineSegments2, LineSegmentsGeometry } from '../../ext/three.js';
|
||||
import { space } from '../../moto/space.js';
|
||||
import { Plane } from '../plane.js';
|
||||
import { VOID_PALETTE } from '../palette.js';
|
||||
import * as markerOps from './runtime_markers.js';
|
||||
import * as profileOps from './runtime_profiles.js';
|
||||
import * as arcOps from './runtime_arc.js';
|
||||
import * as uiOps from './runtime_ui.js';
|
||||
import { isCircleCurve, isThreePointCircle } from './curve.js';
|
||||
|
||||
const SKETCH_COLORS = VOID_PALETTE.sketch;
|
||||
const SKETCH_PLANE_SCALE = 0.86;
|
||||
const SKETCH_PLANE_MIN_SIZE = 24;
|
||||
const SKETCH_POINT_SCREEN_RADIUS_PX = 6;
|
||||
const SKETCH_POINT_BASE_RADIUS = 1.8;
|
||||
const SKETCH_VIRTUAL_ORIGIN_ID = '__sketch-origin__';
|
||||
const CONSTRAINT_GLYPH_SIZE_PX = 18;
|
||||
const CONSTRAINT_GLYPH_GAP_PX = 4;
|
||||
|
||||
function createSketchRuntimeApi(getApi) {
|
||||
return {
|
||||
root: null,
|
||||
sketches: new Map(), // id -> record
|
||||
hoveredId: null,
|
||||
editingId: null,
|
||||
selectedIds: new Set(),
|
||||
forcedVisibleIds: new Set(),
|
||||
mutatingIds: new Set(),
|
||||
hoveredProfileKey: null,
|
||||
selectedProfileKeys: new Set(),
|
||||
_glyphLayer: null,
|
||||
_glyphDrag: null,
|
||||
_glyphClick: null,
|
||||
_cameraSyncQueued: false,
|
||||
_viewCtrlBound: null,
|
||||
_viewCtrlChangeHandler: null,
|
||||
_renderPrefs: {
|
||||
arcSegmentLength: 2.5
|
||||
},
|
||||
|
||||
init(world) {
|
||||
if (this.root) return;
|
||||
this.root = new THREE.Group();
|
||||
this.root.name = 'sketch-runtime';
|
||||
world.add(this.root);
|
||||
this._tmpPointWorld = new THREE.Vector3();
|
||||
this.ensureConstraintGlyphLayer();
|
||||
const queueGlyphSync = () => {
|
||||
if (this._cameraSyncQueued) return;
|
||||
this._cameraSyncQueued = true;
|
||||
self.requestAnimationFrame(() => {
|
||||
this._cameraSyncQueued = false;
|
||||
if (!this.sketches?.size) return;
|
||||
this.updatePointScreenScales();
|
||||
this.updateConstraintGlyphs();
|
||||
});
|
||||
};
|
||||
this._viewCtrlChangeHandler = queueGlyphSync;
|
||||
this.bindViewControl();
|
||||
window.addEventListener('resize', () => {
|
||||
this.updatePointScreenScales();
|
||||
this.updateConstraintGlyphs();
|
||||
});
|
||||
window.addEventListener('mousemove', event => {
|
||||
if (this._glyphDrag) {
|
||||
this.updateConstraintDrag(event, false);
|
||||
}
|
||||
});
|
||||
window.addEventListener('mouseup', event => {
|
||||
if (this._glyphDrag) {
|
||||
this.updateConstraintDrag(event, true);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
sync() {
|
||||
this.bindViewControl();
|
||||
const api = getApi();
|
||||
const features = api.features.listBuilt().filter(f => f?.type === 'sketch');
|
||||
const present = new Set(features.map(f => f.id));
|
||||
|
||||
for (const [id, rec] of this.sketches.entries()) {
|
||||
if (!present.has(id)) {
|
||||
this.removeLabel(rec);
|
||||
this.root?.remove(rec.group);
|
||||
rec.plane?.dispose?.();
|
||||
this.sketches.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
let rec = this.sketches.get(feature.id);
|
||||
if (!rec) {
|
||||
rec = this.createSketchRecord(feature);
|
||||
this.sketches.set(feature.id, rec);
|
||||
this.root?.add(rec.group);
|
||||
} else {
|
||||
rec.feature = feature;
|
||||
}
|
||||
this.updateSketchRecord(rec);
|
||||
}
|
||||
this.updatePointScreenScales();
|
||||
this.updateConstraintGlyphs();
|
||||
},
|
||||
|
||||
syncFeature(featureId) {
|
||||
if (!featureId) return this.sync();
|
||||
this.bindViewControl();
|
||||
const api = getApi();
|
||||
const feature = api.features.findById(featureId);
|
||||
if (!feature || feature.type !== 'sketch') {
|
||||
return this.sync();
|
||||
}
|
||||
let rec = this.sketches.get(feature.id);
|
||||
if (!rec) {
|
||||
rec = this.createSketchRecord(feature);
|
||||
this.sketches.set(feature.id, rec);
|
||||
this.root?.add(rec.group);
|
||||
} else {
|
||||
rec.feature = feature;
|
||||
}
|
||||
this.updateSketchRecord(rec);
|
||||
this.updatePointScreenScales();
|
||||
this.updateConstraintGlyphs();
|
||||
},
|
||||
|
||||
bindViewControl() {
|
||||
const next = space.view?.ctrl || null;
|
||||
if (next === this._viewCtrlBound) {
|
||||
return;
|
||||
}
|
||||
if (this._viewCtrlBound?.removeEventListener && this._viewCtrlChangeHandler) {
|
||||
this._viewCtrlBound.removeEventListener('change', this._viewCtrlChangeHandler);
|
||||
}
|
||||
this._viewCtrlBound = next;
|
||||
if (this._viewCtrlBound?.addEventListener && this._viewCtrlChangeHandler) {
|
||||
this._viewCtrlBound.addEventListener('change', this._viewCtrlChangeHandler);
|
||||
}
|
||||
},
|
||||
|
||||
getRecord(featureId) {
|
||||
return this.sketches.get(featureId) || null;
|
||||
},
|
||||
|
||||
getEditingRecord() {
|
||||
return this.getRecord(this.editingId);
|
||||
},
|
||||
|
||||
createSketchRecord(feature) {
|
||||
const group = new THREE.Group();
|
||||
group.name = `sketch-${feature.id}`;
|
||||
const plane = new Plane({
|
||||
id: `sketch-plane-${feature.id}`,
|
||||
name: feature.name || 'Sketch Plane',
|
||||
size: 160,
|
||||
showHandles: false,
|
||||
color: SKETCH_COLORS.planeDefault.fill,
|
||||
outlineColor: SKETCH_COLORS.planeDefault.outline,
|
||||
opacity: SKETCH_COLORS.planeDefault.fillOpacity,
|
||||
outlineOpacity: SKETCH_COLORS.planeDefault.outlineOpacity
|
||||
});
|
||||
const planeGroup = plane.getGroup();
|
||||
planeGroup.visible = false;
|
||||
|
||||
const entitiesGroup = new THREE.Group();
|
||||
entitiesGroup.name = `sketch-entities-${feature.id}`;
|
||||
const dimensionGroup = new THREE.Group();
|
||||
dimensionGroup.name = `sketch-dimensions-${feature.id}`;
|
||||
const previewLine = this.createFatLine([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0)
|
||||
], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover);
|
||||
previewLine.visible = false;
|
||||
previewLine.renderOrder = 9;
|
||||
entitiesGroup.add(previewLine);
|
||||
const previewArc = this.createFatLine([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0)
|
||||
], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover);
|
||||
previewArc.visible = false;
|
||||
previewArc.renderOrder = 9;
|
||||
entitiesGroup.add(previewArc);
|
||||
const previewStart = this.createSketchPointMarker(0, 0, { virtualOrigin: true });
|
||||
previewStart.visible = false;
|
||||
previewStart.renderOrder = 11;
|
||||
entitiesGroup.add(previewStart);
|
||||
const previewEnd = this.createSketchPointMarker(0, 0, { virtualOrigin: true });
|
||||
previewEnd.visible = false;
|
||||
previewEnd.renderOrder = 11;
|
||||
entitiesGroup.add(previewEnd);
|
||||
const previewArcCenter = this.createArcCenterMarker(0, 0);
|
||||
previewArcCenter.visible = false;
|
||||
previewArcCenter.renderOrder = 11;
|
||||
entitiesGroup.add(previewArcCenter);
|
||||
const previewRect = this.createFatLine([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0)
|
||||
], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover);
|
||||
previewRect.visible = false;
|
||||
previewRect.renderOrder = 9;
|
||||
entitiesGroup.add(previewRect);
|
||||
const previewFaceSegments = this.createFatSegments([], SKETCH_COLORS.linesProjectedFace, SKETCH_COLORS.lineWidths.hover);
|
||||
previewFaceSegments.visible = false;
|
||||
previewFaceSegments.renderOrder = 55;
|
||||
entitiesGroup.add(previewFaceSegments);
|
||||
const previewExternalWorldLine = this.createFatLine([
|
||||
new THREE.Vector3(0, 0, 0),
|
||||
new THREE.Vector3(0, 0, 0)
|
||||
], SKETCH_COLORS.linesDerivedActual, SKETCH_COLORS.lineWidths.hover);
|
||||
previewExternalWorldLine.visible = false;
|
||||
previewExternalWorldLine.renderOrder = 60;
|
||||
group.add(previewExternalWorldLine);
|
||||
const previewExternalWorldSegments = this.createFatSegments([], SKETCH_COLORS.linesDerivedActual, SKETCH_COLORS.lineWidths.hover);
|
||||
previewExternalWorldSegments.visible = false;
|
||||
previewExternalWorldSegments.renderOrder = 60;
|
||||
group.add(previewExternalWorldSegments);
|
||||
const previewExternalWorldPoint = this.createArcCenterMarker(0, 0);
|
||||
previewExternalWorldPoint.visible = false;
|
||||
previewExternalWorldPoint.renderOrder = 60;
|
||||
group.add(previewExternalWorldPoint);
|
||||
|
||||
group.add(planeGroup);
|
||||
group.add(entitiesGroup);
|
||||
group.add(dimensionGroup);
|
||||
|
||||
return {
|
||||
feature,
|
||||
group,
|
||||
plane,
|
||||
entitiesGroup,
|
||||
dimensionGroup,
|
||||
previewLine,
|
||||
previewArc,
|
||||
previewStart,
|
||||
previewEnd,
|
||||
previewArcCenter,
|
||||
previewRect,
|
||||
previewFaceSegments,
|
||||
previewExternalWorldLine,
|
||||
previewExternalWorldSegments,
|
||||
previewExternalWorldPoint,
|
||||
entityViews: new Map(),
|
||||
interaction: {
|
||||
hoveredId: null,
|
||||
selectedIds: new Set(),
|
||||
mirrorMode: false,
|
||||
mirrorAxisId: null,
|
||||
circularPatternMode: false,
|
||||
circularPatternCenterRef: null,
|
||||
gridPatternMode: false,
|
||||
gridPatternCenterRef: null,
|
||||
hoveredProfileId: null,
|
||||
selectedProfileIds: new Set(),
|
||||
hoveredConstraintId: null,
|
||||
selectedConstraintIds: new Set(),
|
||||
previewLine: null,
|
||||
previewArc: null,
|
||||
previewRect: null,
|
||||
previewFaceSegments: null,
|
||||
previewExternalWorldLine: null,
|
||||
previewExternalWorldSegments: null,
|
||||
previewExternalWorldPoint: null,
|
||||
previewStart: null,
|
||||
previewEnd: null,
|
||||
previewMid: null
|
||||
},
|
||||
labelId: `sketch-label-${feature.id}`
|
||||
};
|
||||
},
|
||||
|
||||
createFatLine(points = [], color = SKETCH_COLORS.linesGray, width = SKETCH_COLORS.lineWidths.default) {
|
||||
const geo = new LineGeometry();
|
||||
const pos = [];
|
||||
for (const p of points) {
|
||||
pos.push(p.x || 0, p.y || 0, p.z || 0);
|
||||
}
|
||||
geo.setPositions(pos);
|
||||
const mat = new LineMaterial({
|
||||
color,
|
||||
linewidth: width,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
alphaToCoverage: false
|
||||
});
|
||||
const { renderer } = space.internals();
|
||||
const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1;
|
||||
const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1;
|
||||
mat.resolution.set(w, h);
|
||||
const line = new Line2(geo, mat);
|
||||
line.computeLineDistances?.();
|
||||
line.userData = line.userData || {};
|
||||
line.userData.isFatLine = true;
|
||||
return line;
|
||||
},
|
||||
|
||||
createFatSegments(positions = [], color = SKETCH_COLORS.linesGray, width = SKETCH_COLORS.lineWidths.default) {
|
||||
const geo = new LineSegmentsGeometry();
|
||||
geo.setPositions(positions);
|
||||
const mat = new LineMaterial({
|
||||
color,
|
||||
linewidth: width,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false,
|
||||
alphaToCoverage: false
|
||||
});
|
||||
const { renderer } = space.internals();
|
||||
const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1;
|
||||
const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1;
|
||||
mat.resolution.set(w, h);
|
||||
const line = new LineSegments2(geo, mat);
|
||||
line.computeLineDistances?.();
|
||||
line.userData = line.userData || {};
|
||||
line.userData.isFatLine = true;
|
||||
return line;
|
||||
},
|
||||
|
||||
updateSketchRecord(rec) {
|
||||
const feature = rec.feature;
|
||||
if (feature?.plane) {
|
||||
rec.plane.setFrame(this.toDisplayPlaneFrame(feature.plane));
|
||||
const pg = rec.plane.getGroup();
|
||||
rec.entitiesGroup.position.copy(pg.position);
|
||||
rec.entitiesGroup.quaternion.copy(pg.quaternion);
|
||||
rec.entitiesGroup.scale.copy(pg.scale);
|
||||
rec.dimensionGroup.position.copy(pg.position);
|
||||
rec.dimensionGroup.quaternion.copy(pg.quaternion);
|
||||
rec.dimensionGroup.scale.copy(pg.scale);
|
||||
}
|
||||
this.rebuildEntities(rec);
|
||||
this.applySketchState(rec);
|
||||
},
|
||||
|
||||
toDisplayPlaneFrame(frame) {
|
||||
if (!frame || typeof frame !== 'object') {
|
||||
return frame;
|
||||
}
|
||||
const out = JSON.parse(JSON.stringify(frame));
|
||||
const width = Number(out?.size?.width);
|
||||
const height = Number(out?.size?.height);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
out.size.width = Math.max(SKETCH_PLANE_MIN_SIZE, width * SKETCH_PLANE_SCALE);
|
||||
out.size.height = Math.max(SKETCH_PLANE_MIN_SIZE, height * SKETCH_PLANE_SCALE);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
ensureConstraintGlyphLayer() {
|
||||
if (this._glyphLayer?.isConnected) {
|
||||
return this._glyphLayer;
|
||||
}
|
||||
const { container } = space.internals();
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'sketch-constraint-layer';
|
||||
container.appendChild(layer);
|
||||
this._glyphLayer = layer;
|
||||
return layer;
|
||||
},
|
||||
|
||||
clearConstraintGlyphs() {
|
||||
if (this._glyphLayer) {
|
||||
this._glyphLayer.innerHTML = '';
|
||||
}
|
||||
},
|
||||
|
||||
constraintGlyphLabel(type) {
|
||||
return uiOps.constraintGlyphLabel(type);
|
||||
},
|
||||
|
||||
createSketchPointMarker(x = 0, y = 0, opts = {}) {
|
||||
return markerOps.createSketchPointMarker(x, y, opts, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
createArcCenterMarker(x = 0, y = 0) {
|
||||
return markerOps.createArcCenterMarker(x, y, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
rebuildEntities(rec) {
|
||||
while (rec.entitiesGroup.children.length) {
|
||||
const child = rec.entitiesGroup.children[0];
|
||||
if (child === rec.previewLine || child === rec.previewArc || child === rec.previewRect || child === rec.previewFaceSegments || child === rec.previewStart || child === rec.previewEnd || child === rec.previewArcCenter) {
|
||||
rec.entitiesGroup.remove(child);
|
||||
continue;
|
||||
}
|
||||
child.traverse?.(obj => {
|
||||
obj.geometry?.dispose?.();
|
||||
if (Array.isArray(obj.material)) {
|
||||
for (const mat of obj.material) mat?.dispose?.();
|
||||
} else {
|
||||
obj.material?.dispose?.();
|
||||
}
|
||||
});
|
||||
rec.entitiesGroup.remove(child);
|
||||
}
|
||||
rec.entityViews.clear();
|
||||
|
||||
// Sketch-local origin point, always available for snapping/line anchoring.
|
||||
const originPoint = this.createSketchPointMarker(0, 0, { virtualOrigin: true });
|
||||
originPoint.userData.sketchEntityId = SKETCH_VIRTUAL_ORIGIN_ID;
|
||||
originPoint.userData.sketchEntityType = 'point';
|
||||
this.tagPointMarker(originPoint, SKETCH_VIRTUAL_ORIGIN_ID);
|
||||
rec.entitiesGroup.add(originPoint);
|
||||
rec.entityViews.set(SKETCH_VIRTUAL_ORIGIN_ID, {
|
||||
entity: { id: SKETCH_VIRTUAL_ORIGIN_ID, type: 'point', x: 0, y: 0, virtual: true },
|
||||
object: originPoint,
|
||||
type: 'point',
|
||||
virtual: true
|
||||
});
|
||||
|
||||
const entities = Array.isArray(rec.feature?.entities) ? rec.feature.entities : [];
|
||||
const pointById = new Map();
|
||||
for (const entity of entities) {
|
||||
if (entity?.type === 'point' && entity.id) {
|
||||
pointById.set(entity.id, entity);
|
||||
}
|
||||
}
|
||||
// Circle endpoint points are implementation details; hide their markers.
|
||||
const hiddenPointIds = new Set();
|
||||
for (const entity of entities) {
|
||||
if (entity?.type !== 'arc' || !isCircleCurve(entity)) continue;
|
||||
if (typeof entity.a === 'string') hiddenPointIds.add(entity.a);
|
||||
if (typeof entity.b === 'string') hiddenPointIds.add(entity.b);
|
||||
}
|
||||
if (!this.mutatingIds.has(rec.feature?.id)) {
|
||||
this.addClosedProfileFills(rec, entities, pointById);
|
||||
}
|
||||
for (const entity of entities) {
|
||||
if (!entity?.id) {
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'line' && entity.a && entity.b) {
|
||||
const [a, b] = this.getLineEndpoints(entity, pointById);
|
||||
if (!a || !b) continue;
|
||||
const material = entity.construction
|
||||
? new THREE.LineDashedMaterial({
|
||||
color: SKETCH_COLORS.linesGray,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
dashSize: 3,
|
||||
gapSize: 2,
|
||||
depthWrite: false
|
||||
})
|
||||
: new THREE.LineBasicMaterial({
|
||||
color: SKETCH_COLORS.linesGray,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false
|
||||
});
|
||||
const line = entity.construction
|
||||
? new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(a.x || 0, a.y || 0, 0),
|
||||
new THREE.Vector3(b.x || 0, b.y || 0, 0)
|
||||
]),
|
||||
material
|
||||
)
|
||||
: this.createFatLine([
|
||||
new THREE.Vector3(a.x || 0, a.y || 0, 0),
|
||||
new THREE.Vector3(b.x || 0, b.y || 0, 0)
|
||||
], SKETCH_COLORS.linesGray, SKETCH_COLORS.lineWidths.default);
|
||||
if (entity.construction && line.computeLineDistances) {
|
||||
line.computeLineDistances();
|
||||
}
|
||||
line.renderOrder = 7;
|
||||
line.userData.sketchEntityId = entity.id;
|
||||
line.userData.sketchEntityType = 'line';
|
||||
rec.entitiesGroup.add(line);
|
||||
rec.entityViews.set(entity.id, { entity, object: line, type: 'line' });
|
||||
continue;
|
||||
}
|
||||
if (entity.type === 'arc' && entity.a && entity.b) {
|
||||
const [a, b] = this.getArcEndpoints(entity, pointById);
|
||||
if (!a || !b) continue;
|
||||
const points = this.getArcRenderPoints(entity, a, b, this.getArcSegmentsFor(entity, a, b, 'entity'));
|
||||
if (points.length < 2) continue;
|
||||
const material = entity.construction
|
||||
? new THREE.LineDashedMaterial({
|
||||
color: SKETCH_COLORS.linesGray,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
dashSize: 3,
|
||||
gapSize: 2,
|
||||
depthWrite: false
|
||||
})
|
||||
: new THREE.LineBasicMaterial({
|
||||
color: SKETCH_COLORS.linesGray,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthWrite: false
|
||||
});
|
||||
const arcPoints = points.map(p => new THREE.Vector3(p.x, p.y, 0));
|
||||
const arc = entity.construction
|
||||
? new THREE.Line(new THREE.BufferGeometry().setFromPoints(arcPoints), material)
|
||||
: this.createFatLine(arcPoints, SKETCH_COLORS.linesGray, SKETCH_COLORS.lineWidths.default);
|
||||
if (entity.construction && arc.computeLineDistances) {
|
||||
arc.computeLineDistances();
|
||||
}
|
||||
arc.renderOrder = 7;
|
||||
arc.userData.sketchEntityId = entity.id;
|
||||
arc.userData.sketchEntityType = 'arc';
|
||||
rec.entitiesGroup.add(arc);
|
||||
rec.entityViews.set(entity.id, { entity, object: arc, type: 'arc' });
|
||||
|
||||
const center = this.getArcCenterLocal(entity, a, b);
|
||||
if (center && !isThreePointCircle(entity)) {
|
||||
const centerKey = `arc-center:${entity.id}`;
|
||||
const centerMarker = this.createArcCenterMarker(center.x, center.y);
|
||||
centerMarker.userData.sketchEntityId = centerKey;
|
||||
centerMarker.userData.sketchEntityType = 'arc-center';
|
||||
centerMarker.userData.sketchEntityRefId = entity.id;
|
||||
centerMarker.traverse(obj => {
|
||||
obj.userData = obj.userData || {};
|
||||
obj.userData.sketchEntityId = centerKey;
|
||||
obj.userData.sketchEntityType = 'arc-center';
|
||||
obj.userData.sketchEntityRefId = entity.id;
|
||||
});
|
||||
rec.entitiesGroup.add(centerMarker);
|
||||
rec.entityViews.set(centerKey, { entity, object: centerMarker, type: 'arc-center' });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entity.type === 'point') {
|
||||
if (hiddenPointIds.has(entity.id)) {
|
||||
continue;
|
||||
}
|
||||
const point = this.createSketchPointMarker(entity.x || 0, entity.y || 0);
|
||||
point.userData.sketchEntityId = entity.id;
|
||||
point.userData.sketchEntityType = 'point';
|
||||
this.tagPointMarker(point, entity.id);
|
||||
rec.entitiesGroup.add(point);
|
||||
rec.entityViews.set(entity.id, { entity, object: point, type: 'point' });
|
||||
}
|
||||
}
|
||||
if (rec.previewLine && rec.previewLine.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewLine);
|
||||
} else if (rec.previewLine) {
|
||||
rec.entitiesGroup.remove(rec.previewLine);
|
||||
rec.entitiesGroup.add(rec.previewLine);
|
||||
}
|
||||
if (rec.previewStart && rec.previewStart.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewStart);
|
||||
} else if (rec.previewStart) {
|
||||
rec.entitiesGroup.remove(rec.previewStart);
|
||||
rec.entitiesGroup.add(rec.previewStart);
|
||||
}
|
||||
if (rec.previewArc && rec.previewArc.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewArc);
|
||||
} else if (rec.previewArc) {
|
||||
rec.entitiesGroup.remove(rec.previewArc);
|
||||
rec.entitiesGroup.add(rec.previewArc);
|
||||
}
|
||||
if (rec.previewEnd && rec.previewEnd.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewEnd);
|
||||
} else if (rec.previewEnd) {
|
||||
rec.entitiesGroup.remove(rec.previewEnd);
|
||||
rec.entitiesGroup.add(rec.previewEnd);
|
||||
}
|
||||
if (rec.previewArcCenter && rec.previewArcCenter.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewArcCenter);
|
||||
} else if (rec.previewArcCenter) {
|
||||
rec.entitiesGroup.remove(rec.previewArcCenter);
|
||||
rec.entitiesGroup.add(rec.previewArcCenter);
|
||||
}
|
||||
if (rec.previewFaceSegments && rec.previewFaceSegments.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewFaceSegments);
|
||||
} else if (rec.previewFaceSegments) {
|
||||
rec.entitiesGroup.remove(rec.previewFaceSegments);
|
||||
rec.entitiesGroup.add(rec.previewFaceSegments);
|
||||
}
|
||||
if (rec.previewRect && rec.previewRect.parent !== rec.entitiesGroup) {
|
||||
rec.entitiesGroup.add(rec.previewRect);
|
||||
} else if (rec.previewRect) {
|
||||
rec.entitiesGroup.remove(rec.previewRect);
|
||||
rec.entitiesGroup.add(rec.previewRect);
|
||||
}
|
||||
},
|
||||
|
||||
addClosedProfileFills(rec, entities, pointById) {
|
||||
return profileOps.addClosedProfileFills.call(this, rec, entities, pointById);
|
||||
},
|
||||
|
||||
simplifyLoopsWithClipper(loops) {
|
||||
return profileOps.simplifyLoopsWithClipper.call(this, loops);
|
||||
},
|
||||
|
||||
findClosedCurveLoops(feature, entities, pointById) {
|
||||
return profileOps.findClosedCurveLoops.call(this, feature, entities, pointById);
|
||||
},
|
||||
|
||||
segmentIntersectionParams(a, b, c, d, eps = 1e-9) {
|
||||
return profileOps.segmentIntersectionParams.call(this, a, b, c, d, eps);
|
||||
},
|
||||
|
||||
findClosedLineLoops(feature, entities, pointById) {
|
||||
return profileOps.findClosedLineLoops.call(this, feature, entities, pointById);
|
||||
},
|
||||
|
||||
applySketchState(rec) {
|
||||
return uiOps.applySketchState.call(this, rec, getApi, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPlaneStyle(plane, mode) {
|
||||
return uiOps.applyPlaneStyle.call(this, plane, mode, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyEntityStyle(rec, mode) {
|
||||
return uiOps.applyEntityStyle.call(this, rec, mode, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
getConstraintHoverHighlight(rec) {
|
||||
return uiOps.getConstraintHoverHighlight.call(this, rec);
|
||||
},
|
||||
|
||||
applyPreviewLine(rec, mode, editing) {
|
||||
return uiOps.applyPreviewLine.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewStart(rec, mode, editing) {
|
||||
return uiOps.applyPreviewStart.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewEnd(rec, mode, editing) {
|
||||
return uiOps.applyPreviewEnd.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewArc(rec, mode, editing) {
|
||||
return uiOps.applyPreviewArc.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewRect(rec, mode, editing) {
|
||||
return uiOps.applyPreviewRect.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewFaceSegments(rec, mode, editing) {
|
||||
return uiOps.applyPreviewFaceSegments.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyPreviewExternalWorld(rec, mode, editing) {
|
||||
return uiOps.applyPreviewExternalWorld.call(this, rec, mode, editing, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
applyLabelState(rec, mode, showPlane) {
|
||||
return uiOps.applyLabelState.call(this, rec, mode, showPlane, getApi, SKETCH_COLORS);
|
||||
},
|
||||
|
||||
removeLabel(rec) {
|
||||
return uiOps.removeLabel.call(this, rec, getApi);
|
||||
},
|
||||
|
||||
getPlaneLabelPosition(plane) {
|
||||
return uiOps.getPlaneLabelPosition.call(this, plane);
|
||||
},
|
||||
|
||||
setHovered(featureId) {
|
||||
this.hoveredId = featureId || null;
|
||||
this.refreshStates();
|
||||
},
|
||||
|
||||
setEditing(featureId) {
|
||||
this.editingId = featureId || null;
|
||||
this.refreshStates();
|
||||
},
|
||||
|
||||
setSelected(featureIds) {
|
||||
this.selectedIds = new Set(featureIds || []);
|
||||
this.refreshStates();
|
||||
},
|
||||
|
||||
setForcedVisible(featureIds) {
|
||||
this.forcedVisibleIds = new Set(featureIds || []);
|
||||
this.refreshStates();
|
||||
},
|
||||
|
||||
setMutating(featureId, active = false) {
|
||||
if (!featureId) return;
|
||||
if (active) {
|
||||
this.mutatingIds.add(featureId);
|
||||
} else {
|
||||
this.mutatingIds.delete(featureId);
|
||||
}
|
||||
this.sync();
|
||||
},
|
||||
|
||||
setEntityInteraction(featureId, interaction = {}) {
|
||||
const rec = this.getRecord(featureId);
|
||||
if (!rec) return;
|
||||
rec.interaction.hoveredId = interaction.hoveredId || null;
|
||||
rec.interaction.selectedIds = new Set(interaction.selectedIds || []);
|
||||
rec.interaction.mirrorMode = !!interaction.mirrorMode;
|
||||
rec.interaction.mirrorAxisId = interaction.mirrorAxisId || null;
|
||||
rec.interaction.circularPatternMode = !!interaction.circularPatternMode;
|
||||
rec.interaction.circularPatternCenterRef = interaction.circularPatternCenterRef || null;
|
||||
rec.interaction.gridPatternMode = !!interaction.gridPatternMode;
|
||||
rec.interaction.gridPatternCenterRef = interaction.gridPatternCenterRef || null;
|
||||
if (Object.prototype.hasOwnProperty.call(interaction, 'hoveredProfileId')) {
|
||||
rec.interaction.hoveredProfileId = interaction.hoveredProfileId || null;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(interaction, 'selectedProfileIds')) {
|
||||
rec.interaction.selectedProfileIds = new Set(interaction.selectedProfileIds || []);
|
||||
}
|
||||
rec.interaction.hoveredConstraintId = interaction.hoveredConstraintId || null;
|
||||
rec.interaction.selectedConstraintIds = new Set(interaction.selectedConstraintIds || []);
|
||||
rec.interaction.previewLine = interaction.previewLine || null;
|
||||
rec.interaction.previewArc = interaction.previewArc || null;
|
||||
rec.interaction.previewRect = interaction.previewRect || null;
|
||||
rec.interaction.previewFaceSegments = interaction.previewFaceSegments || null;
|
||||
rec.interaction.previewExternalWorldLine = interaction.previewExternalWorldLine || null;
|
||||
rec.interaction.previewExternalWorldSegments = interaction.previewExternalWorldSegments || null;
|
||||
rec.interaction.previewExternalWorldPoint = interaction.previewExternalWorldPoint || null;
|
||||
rec.interaction.previewStart = interaction.previewStart || null;
|
||||
rec.interaction.previewEnd = interaction.previewEnd || null;
|
||||
rec.interaction.previewMid = interaction.previewMid || null;
|
||||
this.applySketchState(rec);
|
||||
if (!this.mutatingIds?.has?.(featureId)) {
|
||||
this.updateConstraintGlyphs();
|
||||
}
|
||||
},
|
||||
|
||||
clearEntityInteraction(featureId) {
|
||||
const rec = this.getRecord(featureId);
|
||||
if (!rec) return;
|
||||
rec.interaction.hoveredId = null;
|
||||
rec.interaction.selectedIds = new Set();
|
||||
rec.interaction.mirrorMode = false;
|
||||
rec.interaction.mirrorAxisId = null;
|
||||
rec.interaction.circularPatternMode = false;
|
||||
rec.interaction.circularPatternCenterRef = null;
|
||||
rec.interaction.gridPatternMode = false;
|
||||
rec.interaction.gridPatternCenterRef = null;
|
||||
rec.interaction.hoveredProfileId = null;
|
||||
rec.interaction.selectedProfileIds = new Set();
|
||||
rec.interaction.hoveredConstraintId = null;
|
||||
rec.interaction.selectedConstraintIds = new Set();
|
||||
rec.interaction.previewLine = null;
|
||||
rec.interaction.previewArc = null;
|
||||
rec.interaction.previewRect = null;
|
||||
rec.interaction.previewFaceSegments = null;
|
||||
rec.interaction.previewExternalWorldLine = null;
|
||||
rec.interaction.previewExternalWorldSegments = null;
|
||||
rec.interaction.previewExternalWorldPoint = null;
|
||||
rec.interaction.previewStart = null;
|
||||
rec.interaction.previewEnd = null;
|
||||
rec.interaction.previewMid = null;
|
||||
this.applySketchState(rec);
|
||||
this.updateConstraintGlyphs();
|
||||
},
|
||||
|
||||
getLineEndpoints(line, pointById) {
|
||||
return arcOps.getLineEndpoints(line, pointById);
|
||||
},
|
||||
|
||||
getArcEndpoints(arc, pointById) {
|
||||
return arcOps.getArcEndpoints(arc, pointById);
|
||||
},
|
||||
|
||||
getArcRenderPoints(arc, a, b, segments = 32) {
|
||||
return arcOps.getArcRenderPoints(arc, a, b, segments);
|
||||
},
|
||||
|
||||
getArcLength(arc, a, b) {
|
||||
if (!arc) return 0;
|
||||
const cx = Number(arc?.cx);
|
||||
const cy = Number(arc?.cy);
|
||||
let radius = Number(arc?.radius);
|
||||
if (!Number.isFinite(radius) || radius <= 0) {
|
||||
if (a && Number.isFinite(cx) && Number.isFinite(cy)) {
|
||||
radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy);
|
||||
} else {
|
||||
radius = 0;
|
||||
}
|
||||
}
|
||||
if (isCircleCurve(arc)) {
|
||||
return radius > 0 ? (Math.PI * 2 * radius) : 0;
|
||||
}
|
||||
if (!Number.isFinite(radius) || radius <= 0) {
|
||||
if (a && b) {
|
||||
return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
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 geom = this.computeArcFromThreePoints(
|
||||
{ x: a.x || 0, y: a.y || 0 },
|
||||
{ x: b.x || 0, y: b.y || 0 },
|
||||
{ x: arc.mx, y: arc.my }
|
||||
);
|
||||
if (geom) {
|
||||
startAngle = geom.startAngle;
|
||||
endAngle = geom.endAngle;
|
||||
ccw = geom.ccw;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(startAngle) || !Number.isFinite(endAngle)) {
|
||||
if (a && b) {
|
||||
return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return Math.abs(sweep) * radius;
|
||||
},
|
||||
|
||||
getArcSegmentsFor(arc, a, b, mode = 'entity') {
|
||||
const unit = Math.max(0.05, Number(this._renderPrefs?.arcSegmentLength || 2.5) || 2.5);
|
||||
const arcLength = Math.max(0, Number(this.getArcLength(arc, a, b) || 0));
|
||||
const base = Math.ceil(arcLength / unit);
|
||||
if (mode === 'profile') return Math.max(24, Math.min(768, base));
|
||||
if (mode === 'preview') return Math.max(16, Math.min(768, base));
|
||||
return Math.max(12, Math.min(768, base));
|
||||
},
|
||||
|
||||
setRenderPreferences(next = {}) {
|
||||
if (!next || typeof next !== 'object') return;
|
||||
if (next.arcSegmentLength !== undefined) {
|
||||
const value = Math.max(0.05, Number(next.arcSegmentLength) || 2.5);
|
||||
this._renderPrefs.arcSegmentLength = value;
|
||||
}
|
||||
this.sync();
|
||||
},
|
||||
|
||||
getArcCenterLocal(arc, a, b) {
|
||||
return arcOps.getArcCenterLocal(arc, a, b);
|
||||
},
|
||||
|
||||
computeArcFromThreePoints(start, end, onArc) {
|
||||
return arcOps.computeArcFromThreePoints(start, end, onArc);
|
||||
},
|
||||
|
||||
updatePointScreenScales() {
|
||||
return uiOps.updatePointScreenScales.call(this, {
|
||||
pointScreenRadiusPx: SKETCH_POINT_SCREEN_RADIUS_PX,
|
||||
pointBaseRadius: SKETCH_POINT_BASE_RADIUS
|
||||
});
|
||||
},
|
||||
|
||||
getConstraintAnchorLocal(feature, constraint) {
|
||||
return uiOps.getConstraintAnchorLocal.call(this, feature, constraint);
|
||||
},
|
||||
|
||||
projectConstraintAnchor(rec, local) {
|
||||
return uiOps.projectConstraintAnchor.call(this, rec, local, getApi);
|
||||
},
|
||||
|
||||
tagPointMarker(marker, id) {
|
||||
return uiOps.tagPointMarker(marker, id);
|
||||
},
|
||||
|
||||
applyConstraintOffset(constraint, screenPos, slotIndex = 0, slotCount = 1) {
|
||||
return uiOps.applyConstraintOffset(constraint, screenPos, slotIndex, slotCount, {
|
||||
glyphSizePx: CONSTRAINT_GLYPH_SIZE_PX,
|
||||
glyphGapPx: CONSTRAINT_GLYPH_GAP_PX
|
||||
});
|
||||
},
|
||||
|
||||
updateConstraintGlyphs() {
|
||||
return uiOps.updateConstraintGlyphs.call(this, getApi, {
|
||||
glyphSizePx: CONSTRAINT_GLYPH_SIZE_PX,
|
||||
glyphGapPx: CONSTRAINT_GLYPH_GAP_PX,
|
||||
colors: SKETCH_COLORS
|
||||
});
|
||||
},
|
||||
|
||||
updateConstraintDrag(event, done = false) {
|
||||
return uiOps.updateConstraintDrag.call(this, event, done, getApi);
|
||||
},
|
||||
|
||||
refreshStates() {
|
||||
const profileByFeature = new Map();
|
||||
for (const key of this.selectedProfileKeys) {
|
||||
const [featureId, profileId] = String(key || '').split(':');
|
||||
if (!featureId || !profileId) continue;
|
||||
if (!profileByFeature.has(featureId)) profileByFeature.set(featureId, new Set());
|
||||
profileByFeature.get(featureId).add(profileId);
|
||||
}
|
||||
const hovered = this.hoveredProfileKey ? String(this.hoveredProfileKey).split(':') : null;
|
||||
for (const rec of this.sketches.values()) {
|
||||
const featureId = rec.feature?.id;
|
||||
rec.interaction.hoveredProfileId = hovered && hovered[0] === featureId ? hovered[1] : null;
|
||||
rec.interaction.selectedProfileIds = profileByFeature.get(featureId) || new Set();
|
||||
this.applySketchState(rec);
|
||||
}
|
||||
this.updateConstraintGlyphs();
|
||||
},
|
||||
|
||||
setHoveredProfile(profileKey) {
|
||||
this.hoveredProfileKey = profileKey || null;
|
||||
this.refreshStates();
|
||||
},
|
||||
|
||||
setSelectedProfiles(profileKeys) {
|
||||
this.selectedProfileKeys = new Set(profileKeys || []);
|
||||
this.refreshStates();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export { createSketchRuntimeApi };
|
||||
186
src/void/sketch/runtime_arc.js
Normal file
186
src/void/sketch/runtime_arc.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { isCircleCurve } from './curve.js';
|
||||
|
||||
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 getArcRenderPoints(arc, a, b, segments = 32) {
|
||||
if (isCircleCurve(arc)) {
|
||||
const cx = Number(arc?.cx);
|
||||
const cy = Number(arc?.cy);
|
||||
let radius = Number(arc?.radius);
|
||||
if (!Number.isFinite(radius) || radius <= 0) {
|
||||
if (a) {
|
||||
radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(radius) || radius <= 0) {
|
||||
return [];
|
||||
}
|
||||
const count = Math.max(32, 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 ang = start + t * Math.PI * 2;
|
||||
pts.push({
|
||||
x: cx + Math.cos(ang) * radius,
|
||||
y: cy + Math.sin(ang) * 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 geom = computeArcFromThreePoints(
|
||||
{ x: a.x || 0, y: a.y || 0 },
|
||||
{ x: b.x || 0, y: b.y || 0 },
|
||||
{ x: arc.mx, y: arc.my }
|
||||
);
|
||||
if (geom) {
|
||||
cx = geom.cx;
|
||||
cy = geom.cy;
|
||||
radius = geom.radius;
|
||||
startAngle = geom.startAngle;
|
||||
endAngle = geom.endAngle;
|
||||
ccw = geom.ccw;
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(startAngle) || !Number.isFinite(endAngle)) {
|
||||
return [];
|
||||
}
|
||||
if (!Number.isFinite(radius) || radius <= 0) {
|
||||
if (a) {
|
||||
radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(radius) || 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(8, segments);
|
||||
const pts = [];
|
||||
for (let i = 0; i <= count; i++) {
|
||||
const t = i / count;
|
||||
const ang = startAngle + sweep * t;
|
||||
pts.push({
|
||||
x: cx + Math.cos(ang) * radius,
|
||||
y: cy + Math.sin(ang) * 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 getArcCenterLocal(arc, a, b) {
|
||||
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) && a && b) {
|
||||
const geom = computeArcFromThreePoints(
|
||||
{ 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 };
|
||||
}
|
||||
}
|
||||
const cx = Number(arc?.cx);
|
||||
const cy = Number(arc?.cy);
|
||||
if (Number.isFinite(cx) && Number.isFinite(cy)) {
|
||||
return { x: cx, y: cy };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function computeArcFromThreePoints(start, end, onArc) {
|
||||
const x1 = start.x || 0;
|
||||
const y1 = start.y || 0;
|
||||
const x2 = end.x || 0;
|
||||
const y2 = end.y || 0;
|
||||
const x3 = onArc.x || 0;
|
||||
const y3 = onArc.y || 0;
|
||||
const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2));
|
||||
if (Math.abs(d) < 1e-8) {
|
||||
return null;
|
||||
}
|
||||
const x1sq = x1 * x1 + y1 * y1;
|
||||
const x2sq = x2 * x2 + y2 * y2;
|
||||
const x3sq = x3 * x3 + y3 * y3;
|
||||
const cx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d;
|
||||
const cy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d;
|
||||
const radius = Math.hypot(x1 - cx, y1 - cy);
|
||||
if (!Number.isFinite(radius) || radius < 1e-6) {
|
||||
return null;
|
||||
}
|
||||
const startAngle = Math.atan2(y1 - cy, x1 - cx);
|
||||
const endAngle = Math.atan2(y2 - cy, x2 - cx);
|
||||
const midAngle = Math.atan2(y3 - cy, x3 - cx);
|
||||
const normalize = a => {
|
||||
let out = a % (Math.PI * 2);
|
||||
if (out < 0) out += Math.PI * 2;
|
||||
return out;
|
||||
};
|
||||
const sa = normalize(startAngle);
|
||||
const ea = normalize(endAngle);
|
||||
const ma = normalize(midAngle);
|
||||
const ccwSpan = (ea - sa + Math.PI * 2) % (Math.PI * 2);
|
||||
const ccwMid = (ma - sa + Math.PI * 2) % (Math.PI * 2);
|
||||
const ccw = ccwMid <= ccwSpan;
|
||||
return { cx, cy, radius, startAngle, endAngle, ccw };
|
||||
}
|
||||
|
||||
export {
|
||||
getLineEndpoints,
|
||||
getArcEndpoints,
|
||||
getArcRenderPoints,
|
||||
getArcCenterLocal,
|
||||
computeArcFromThreePoints
|
||||
};
|
||||
247
src/void/sketch/runtime_markers.js
Normal file
247
src/void/sketch/runtime_markers.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { THREE } from '../../ext/three.js';
|
||||
|
||||
function attachColorShim(material, key, fallbackHex = 0xffffff) {
|
||||
if (!material?.uniforms?.[key]) return;
|
||||
const uni = material.uniforms[key];
|
||||
const fallback = new THREE.Color(fallbackHex);
|
||||
material.color = {
|
||||
setHex(hex) {
|
||||
if (uni.value?.setHex) uni.value.setHex(hex);
|
||||
else uni.value = new THREE.Color(hex);
|
||||
},
|
||||
getHex() {
|
||||
if (uni.value?.getHex) return uni.value.getHex();
|
||||
if (uni.value?.isColor) return uni.value.getHex();
|
||||
return fallback.getHex();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createShaderPointSymbol(opts = {}) {
|
||||
const size = Number(opts.sizePx || 14);
|
||||
const coreR = Number(opts.coreR || 0.17);
|
||||
const ringBlackR = Number(opts.ringBlackR || 0.23);
|
||||
const ringWhiteR = Number(opts.ringWhiteR || 0.29);
|
||||
const ringHighlightR = Number(opts.ringHighlightR || 0.36);
|
||||
const thickness = Number(opts.thickness || 0.028);
|
||||
const uniforms = {
|
||||
uSize: { value: size },
|
||||
uCoreColor: { value: new THREE.Color(opts.coreColor || 0x8f8f8f) },
|
||||
uRingBlackColor: { value: new THREE.Color(opts.ringBlackColor || 0x101010) },
|
||||
uRingWhiteColor: { value: new THREE.Color(opts.ringWhiteColor || 0xffffff) },
|
||||
uHighlightColor: { value: new THREE.Color(opts.highlightColor || 0xff9933) },
|
||||
uShowBaseRings: { value: Number(opts.showBaseRings === false ? 0 : 1) },
|
||||
uCoreR: { value: coreR },
|
||||
uRingBlackR: { value: ringBlackR },
|
||||
uRingWhiteR: { value: ringWhiteR },
|
||||
uRingHighlightR: { value: ringHighlightR },
|
||||
uThickness: { value: thickness },
|
||||
uHighlight: { value: Number(opts.highlight || 0) }
|
||||
};
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
vertexShader: `
|
||||
uniform float uSize;
|
||||
void main() {
|
||||
vec4 mv = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_Position = projectionMatrix * mv;
|
||||
gl_PointSize = uSize;
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
#include <common>
|
||||
uniform vec3 uCoreColor;
|
||||
uniform vec3 uRingBlackColor;
|
||||
uniform vec3 uRingWhiteColor;
|
||||
uniform vec3 uHighlightColor;
|
||||
uniform float uShowBaseRings;
|
||||
uniform float uCoreR;
|
||||
uniform float uRingBlackR;
|
||||
uniform float uRingWhiteR;
|
||||
uniform float uRingHighlightR;
|
||||
uniform float uThickness;
|
||||
uniform float uHighlight;
|
||||
uniform float uSize;
|
||||
|
||||
float band(float r, float c, float w) {
|
||||
float d = abs(r - c);
|
||||
float aa = max(fwidth(r) * 1.5, 1.0 / max(8.0, uSize));
|
||||
return 1.0 - smoothstep(w, w + aa, d);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 p = gl_PointCoord - vec2(0.5);
|
||||
float r = length(p);
|
||||
if (r > 0.5) discard;
|
||||
|
||||
vec3 col = uCoreColor;
|
||||
float a = 0.0;
|
||||
|
||||
float aa = max(fwidth(r) * 1.5, 1.0 / max(8.0, uSize));
|
||||
float core = 1.0 - smoothstep(uCoreR, uCoreR + aa, r);
|
||||
if (core > 0.001) {
|
||||
col = uCoreColor;
|
||||
a = max(a, core);
|
||||
}
|
||||
|
||||
if (uShowBaseRings > 0.5) {
|
||||
float blk = band(r, uRingBlackR, uThickness);
|
||||
if (blk > 0.001) {
|
||||
col = mix(col, uRingBlackColor, blk);
|
||||
a = max(a, blk);
|
||||
}
|
||||
|
||||
float wht = band(r, uRingWhiteR, uThickness);
|
||||
if (wht > 0.001) {
|
||||
col = mix(col, uRingWhiteColor, wht);
|
||||
a = max(a, wht);
|
||||
}
|
||||
}
|
||||
|
||||
if (uHighlight > 0.5) {
|
||||
float hi = band(r, uRingHighlightR, uThickness);
|
||||
if (hi > 0.001) {
|
||||
col = mix(col, uHighlightColor, hi);
|
||||
a = max(a, hi);
|
||||
}
|
||||
}
|
||||
|
||||
if (a < 0.01) discard;
|
||||
gl_FragColor = vec4(col, a);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`
|
||||
});
|
||||
attachColorShim(mat, 'uCoreColor', 0x8f8f8f);
|
||||
const geom = new THREE.BufferGeometry();
|
||||
geom.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0], 3));
|
||||
const pts = new THREE.Points(geom, mat);
|
||||
pts.frustumCulled = false;
|
||||
pts.renderOrder = 10;
|
||||
return { points: pts, material: mat, uniforms };
|
||||
}
|
||||
|
||||
function createSketchPointMarker(x = 0, y = 0, opts = {}, colors = {}) {
|
||||
const marker = new THREE.Group();
|
||||
marker.position.set(x, y, 0);
|
||||
marker.renderOrder = 8;
|
||||
marker.userData._shaderPoint = true;
|
||||
|
||||
const pickCore = new THREE.Mesh(
|
||||
new THREE.CircleGeometry(0.72, 16),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthWrite: false
|
||||
})
|
||||
);
|
||||
pickCore.material.colorWrite = false;
|
||||
pickCore.userData.sketchPointPick = true;
|
||||
marker.add(pickCore);
|
||||
|
||||
const sym = createShaderPointSymbol({
|
||||
sizePx: opts.virtualOrigin ? 15 : 13,
|
||||
coreColor: 0x8f8f8f,
|
||||
ringBlackColor: 0x101010,
|
||||
ringWhiteColor: 0xffffff,
|
||||
highlightColor: colors.linesHover || 0xff9933,
|
||||
coreR: 0.17,
|
||||
ringBlackR: 0.23,
|
||||
ringWhiteR: 0.29,
|
||||
ringHighlightR: 0.36,
|
||||
thickness: 0.028
|
||||
});
|
||||
marker.add(sym.points);
|
||||
const ringHighlight = {
|
||||
material: sym.material,
|
||||
color: {
|
||||
setHex(hex) {
|
||||
if (sym.uniforms.uHighlightColor.value?.setHex) sym.uniforms.uHighlightColor.value.setHex(hex);
|
||||
},
|
||||
getHex() {
|
||||
return sym.uniforms.uHighlightColor.value?.getHex?.() || 0xff9933;
|
||||
}
|
||||
},
|
||||
get visible() {
|
||||
return !!(sym.uniforms.uHighlight.value > 0.5);
|
||||
},
|
||||
set visible(v) {
|
||||
sym.uniforms.uHighlight.value = v ? 1 : 0;
|
||||
}
|
||||
};
|
||||
const ringWhite = {
|
||||
material: {
|
||||
color: {
|
||||
setHex(hex) {
|
||||
if (sym.uniforms.uRingWhiteColor.value?.setHex) sym.uniforms.uRingWhiteColor.value.setHex(hex);
|
||||
},
|
||||
getHex() {
|
||||
return sym.uniforms.uRingWhiteColor.value?.getHex?.() || 0xffffff;
|
||||
}
|
||||
},
|
||||
get depthTest() {
|
||||
return sym.material.depthTest;
|
||||
},
|
||||
set depthTest(v) {
|
||||
sym.material.depthTest = !!v;
|
||||
}
|
||||
}
|
||||
};
|
||||
const ringBlack = {
|
||||
material: {
|
||||
color: {
|
||||
setHex(hex) {
|
||||
if (sym.uniforms.uRingBlackColor.value?.setHex) sym.uniforms.uRingBlackColor.value.setHex(hex);
|
||||
},
|
||||
getHex() {
|
||||
return sym.uniforms.uRingBlackColor.value?.getHex?.() || 0x101010;
|
||||
}
|
||||
},
|
||||
get depthTest() {
|
||||
return sym.material.depthTest;
|
||||
},
|
||||
set depthTest(v) {
|
||||
sym.material.depthTest = !!v;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
marker.userData._markerParts = {
|
||||
core: sym.points,
|
||||
ringBlack,
|
||||
ringWhite,
|
||||
ringBase: {
|
||||
material: sym.material,
|
||||
get visible() {
|
||||
return !!(sym.uniforms.uShowBaseRings.value > 0.5);
|
||||
},
|
||||
set visible(v) {
|
||||
sym.uniforms.uShowBaseRings.value = v ? 1 : 0;
|
||||
}
|
||||
},
|
||||
ringHighlight,
|
||||
ringOuter: { material: sym.material },
|
||||
ringInner: { material: sym.material }
|
||||
};
|
||||
marker.userData._isVirtualOrigin = !!opts.virtualOrigin;
|
||||
|
||||
return marker;
|
||||
}
|
||||
|
||||
function createArcCenterMarker(x = 0, y = 0, colors = {}) {
|
||||
const marker = createSketchPointMarker(x, y, {}, colors);
|
||||
marker.userData._isArcCenter = true;
|
||||
return marker;
|
||||
}
|
||||
|
||||
export {
|
||||
createSketchPointMarker,
|
||||
createArcCenterMarker
|
||||
};
|
||||
411
src/void/sketch/runtime_profiles.js
Normal file
411
src/void/sketch/runtime_profiles.js
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { THREE } from '../../ext/three.js';
|
||||
import { ClipperLib } from '../../ext/clip2.esm.js';
|
||||
|
||||
const PROFILE_MERGE_EPS = 1e-3;
|
||||
const CLIPPER_SCALE = 100000;
|
||||
|
||||
function addClosedProfileFills(rec, entities, pointById) {
|
||||
const loops = this.findClosedCurveLoops(rec.feature, entities, pointById);
|
||||
const regions = buildProfileRegions(loops);
|
||||
for (let index = 0; index < regions.length; index++) {
|
||||
const region = regions[index];
|
||||
const outer = region?.outer;
|
||||
const holes = Array.isArray(region?.holes) ? region.holes : [];
|
||||
if (!Array.isArray(outer) || outer.length < 3) continue;
|
||||
const shape = loopToShapePath(ensureLoopWinding(outer, true), THREE.Shape);
|
||||
if (!shape) continue;
|
||||
for (const hole of holes) {
|
||||
const path = loopToShapePath(ensureLoopWinding(hole, false), THREE.Path);
|
||||
if (path) shape.holes.push(path);
|
||||
}
|
||||
const geom = new THREE.ShapeGeometry(shape);
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: 0x8f8f8f,
|
||||
transparent: true,
|
||||
opacity: 0.18,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
polygonOffsetUnits: -1,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
const fill = new THREE.Mesh(geom, mat);
|
||||
fill.position.z = 0;
|
||||
fill.renderOrder = 6;
|
||||
const profileId = `profile-${index}`;
|
||||
fill.userData.sketchEntityId = profileId;
|
||||
fill.userData.sketchEntityType = 'profile';
|
||||
fill.userData.sketchProfileId = profileId;
|
||||
fill.userData.sketchFeatureId = rec.feature?.id || null;
|
||||
const profileLoops = [
|
||||
ensureLoopWinding(outer, true),
|
||||
...holes.map(loop => ensureLoopWinding(loop, false))
|
||||
]
|
||||
.map(loop => (Array.isArray(loop) ? loop.map(p => ({ x: p.x || 0, y: p.y || 0 })) : null))
|
||||
.filter(loop => Array.isArray(loop) && loop.length >= 3);
|
||||
fill.userData.sketchProfileLoops = profileLoops;
|
||||
fill.userData.sketchProfileLoop = profileLoops[0] || null;
|
||||
rec.entitiesGroup.add(fill);
|
||||
rec.entityViews.set(profileId, {
|
||||
entity: {
|
||||
id: profileId,
|
||||
type: 'profile',
|
||||
loop: fill.userData.sketchProfileLoop,
|
||||
loops: profileLoops
|
||||
},
|
||||
object: fill,
|
||||
type: 'profile'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loopToShapePath(loop, Ctor = THREE.Path) {
|
||||
if (!Array.isArray(loop) || loop.length < 3) return null;
|
||||
const path = new Ctor();
|
||||
path.moveTo(loop[0].x || 0, loop[0].y || 0);
|
||||
for (let i = 1; i < loop.length; i++) {
|
||||
path.lineTo(loop[i].x || 0, loop[i].y || 0);
|
||||
}
|
||||
path.closePath();
|
||||
return path;
|
||||
}
|
||||
|
||||
function simplifyLoopsWithClipper(loops) {
|
||||
if (!Array.isArray(loops) || !loops.length || !ClipperLib?.Clipper) {
|
||||
return loops || [];
|
||||
}
|
||||
const out = [];
|
||||
const fill = ClipperLib.PolyFillType.pftEvenOdd;
|
||||
for (const loop of loops) {
|
||||
if (!Array.isArray(loop) || loop.length < 3) continue;
|
||||
const path = [];
|
||||
for (const p of loop) {
|
||||
path.push({
|
||||
X: Math.round((p.x || 0) * CLIPPER_SCALE),
|
||||
Y: Math.round((p.y || 0) * CLIPPER_SCALE)
|
||||
});
|
||||
}
|
||||
if (path.length < 3) continue;
|
||||
const simp = ClipperLib.Clipper.SimplifyPolygon(path, fill) || [];
|
||||
if (!simp.length) {
|
||||
out.push(loop);
|
||||
continue;
|
||||
}
|
||||
for (const poly of simp) {
|
||||
if (!Array.isArray(poly) || poly.length < 3) continue;
|
||||
out.push(poly.map(pt => ({
|
||||
x: (pt.X || 0) / CLIPPER_SCALE,
|
||||
y: (pt.Y || 0) / CLIPPER_SCALE
|
||||
})));
|
||||
}
|
||||
}
|
||||
return out.length ? out : loops;
|
||||
}
|
||||
|
||||
function findClosedCurveLoops(feature, entities, pointById) {
|
||||
const curves = entities.filter(e => (e?.type === 'line' || e?.type === 'arc') && !e.construction);
|
||||
if (!curves.length) return [];
|
||||
|
||||
const q = v => Math.round(v / PROFILE_MERGE_EPS) * PROFILE_MERGE_EPS;
|
||||
const nodes = new Map();
|
||||
const nodeCoord = new Map();
|
||||
const baseSegments = [];
|
||||
let nodeSeq = 0;
|
||||
|
||||
const getNodeId = (x, y) => {
|
||||
const key = `${q(x)},${q(y)}`;
|
||||
let node = nodes.get(key);
|
||||
if (!node) {
|
||||
node = { id: `n${++nodeSeq}`, x, y };
|
||||
nodes.set(key, node);
|
||||
nodeCoord.set(node.id, { x, y });
|
||||
}
|
||||
return node.id;
|
||||
};
|
||||
|
||||
for (const curve of curves) {
|
||||
let poly = null;
|
||||
if (curve.type === 'line') {
|
||||
const [a, b] = this.getLineEndpoints(curve, pointById);
|
||||
if (a && b) {
|
||||
poly = [{ x: a.x || 0, y: a.y || 0 }, { x: b.x || 0, y: b.y || 0 }];
|
||||
}
|
||||
} else if (curve.type === 'arc') {
|
||||
const [a, b] = this.getArcEndpoints(curve, pointById);
|
||||
if (a && b) {
|
||||
poly = this.getArcRenderPoints(curve, a, b, this.getArcSegmentsFor?.(curve, a, b, 'profile') || 64);
|
||||
}
|
||||
}
|
||||
if (!poly || poly.length < 2) continue;
|
||||
|
||||
for (let i = 0; i < poly.length - 1; i++) {
|
||||
const p1 = poly[i];
|
||||
const p2 = poly[i + 1];
|
||||
const x1 = p1.x || 0;
|
||||
const y1 = p1.y || 0;
|
||||
const x2 = p2.x || 0;
|
||||
const y2 = p2.y || 0;
|
||||
if (Math.hypot(x2 - x1, y2 - y1) < PROFILE_MERGE_EPS) continue;
|
||||
baseSegments.push({ id: baseSegments.length, a: { x: x1, y: y1 }, b: { x: x2, y: y2 }, ts: [0, 1] });
|
||||
}
|
||||
}
|
||||
if (!baseSegments.length) return [];
|
||||
|
||||
const segEps = 1e-9;
|
||||
for (let i = 0; i < baseSegments.length; i++) {
|
||||
const s1 = baseSegments[i];
|
||||
for (let j = i + 1; j < baseSegments.length; j++) {
|
||||
const s2 = baseSegments[j];
|
||||
const hit = this.segmentIntersectionParams(s1.a, s1.b, s2.a, s2.b, segEps);
|
||||
if (!hit || hit.collinear) continue;
|
||||
if (Number.isFinite(hit.t) && hit.t >= -segEps && hit.t <= 1 + segEps) {
|
||||
s1.ts.push(Math.max(0, Math.min(1, hit.t)));
|
||||
}
|
||||
if (Number.isFinite(hit.u) && hit.u >= -segEps && hit.u <= 1 + segEps) {
|
||||
s2.ts.push(Math.max(0, Math.min(1, hit.u)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edges = [];
|
||||
const edgeKeys = new Set();
|
||||
const uniqueSorted = list => {
|
||||
const out = Array.from(new Set(list.map(v => Number(v.toFixed(12)))));
|
||||
out.sort((a, b) => a - b);
|
||||
return out;
|
||||
};
|
||||
for (const seg of baseSegments) {
|
||||
const ts = uniqueSorted(seg.ts).filter(t => t >= 0 && t <= 1);
|
||||
if (ts.length < 2) continue;
|
||||
const sx = seg.a.x;
|
||||
const sy = seg.a.y;
|
||||
const dx = seg.b.x - seg.a.x;
|
||||
const dy = seg.b.y - seg.a.y;
|
||||
for (let i = 0; i < ts.length - 1; i++) {
|
||||
const t0 = ts[i];
|
||||
const t1 = ts[i + 1];
|
||||
if ((t1 - t0) < 1e-9) continue;
|
||||
const p0 = { x: sx + dx * t0, y: sy + dy * t0 };
|
||||
const p1 = { x: sx + dx * t1, y: sy + dy * t1 };
|
||||
if (Math.hypot(p1.x - p0.x, p1.y - p0.y) < PROFILE_MERGE_EPS) continue;
|
||||
const aId = getNodeId(p0.x, p0.y);
|
||||
const bId = getNodeId(p1.x, p1.y);
|
||||
if (!aId || !bId || aId === bId) continue;
|
||||
const key = aId < bId ? `${aId}|${bId}` : `${bId}|${aId}`;
|
||||
if (edgeKeys.has(key)) continue;
|
||||
edgeKeys.add(key);
|
||||
edges.push({ id: edges.length, a: aId, b: bId });
|
||||
}
|
||||
}
|
||||
if (!edges.length) return [];
|
||||
|
||||
const halfEdges = [];
|
||||
const outgoing = new Map();
|
||||
const addOutgoing = (nid, heId) => {
|
||||
if (!outgoing.has(nid)) outgoing.set(nid, []);
|
||||
outgoing.get(nid).push(heId);
|
||||
};
|
||||
for (const edge of edges) {
|
||||
const a = nodeCoord.get(edge.a);
|
||||
const b = nodeCoord.get(edge.b);
|
||||
if (!a || !b) continue;
|
||||
const heAB = { id: halfEdges.length, edgeId: edge.id, from: edge.a, to: edge.b, angle: Math.atan2(b.y - a.y, b.x - a.x), twin: -1 };
|
||||
halfEdges.push(heAB);
|
||||
const heBA = { id: halfEdges.length, edgeId: edge.id, from: edge.b, to: edge.a, angle: Math.atan2(a.y - b.y, a.x - b.x), twin: heAB.id };
|
||||
halfEdges.push(heBA);
|
||||
heAB.twin = heBA.id;
|
||||
addOutgoing(heAB.from, heAB.id);
|
||||
addOutgoing(heBA.from, heBA.id);
|
||||
}
|
||||
for (const [nid, list] of outgoing.entries()) {
|
||||
list.sort((ha, hb) => halfEdges[ha].angle - halfEdges[hb].angle);
|
||||
outgoing.set(nid, list);
|
||||
}
|
||||
|
||||
const visited = new Set();
|
||||
const loops = [];
|
||||
const minArea = 1e-5;
|
||||
for (const start of halfEdges) {
|
||||
if (visited.has(start.id)) continue;
|
||||
const cycleHes = [];
|
||||
let curr = start;
|
||||
let guard = 0;
|
||||
while (curr && !visited.has(curr.id) && guard++ < halfEdges.length * 4) {
|
||||
visited.add(curr.id);
|
||||
cycleHes.push(curr.id);
|
||||
const outAtTo = outgoing.get(curr.to) || [];
|
||||
if (!outAtTo.length) break;
|
||||
const twinIndex = outAtTo.indexOf(curr.twin);
|
||||
if (twinIndex < 0) break;
|
||||
const nextIndex = (twinIndex - 1 + outAtTo.length) % outAtTo.length;
|
||||
curr = halfEdges[outAtTo[nextIndex]];
|
||||
if (curr.id === start.id) {
|
||||
cycleHes.push(curr.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cycleHes.length || cycleHes[cycleHes.length - 1] !== start.id) continue;
|
||||
const nodeIds = [];
|
||||
for (let i = 0; i < cycleHes.length - 1; i++) nodeIds.push(halfEdges[cycleHes[i]].from);
|
||||
if (nodeIds.length < 3) continue;
|
||||
const pts = nodeIds.map(nid => nodeCoord.get(nid)).filter(Boolean);
|
||||
if (pts.length < 3) continue;
|
||||
let area2 = 0;
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i];
|
||||
const q2 = pts[(i + 1) % pts.length];
|
||||
area2 += p.x * q2.y - q2.x * p.y;
|
||||
}
|
||||
if ((area2 * 0.5) > minArea) {
|
||||
loops.push(pts.map(p => ({ x: p.x, y: p.y })));
|
||||
}
|
||||
}
|
||||
return loops;
|
||||
}
|
||||
|
||||
function segmentIntersectionParams(a, b, c, d, eps = 1e-9) {
|
||||
const r = { x: (b.x || 0) - (a.x || 0), y: (b.y || 0) - (a.y || 0) };
|
||||
const s = { x: (d.x || 0) - (c.x || 0), y: (d.y || 0) - (c.y || 0) };
|
||||
const cross = (u, v) => u.x * v.y - u.y * v.x;
|
||||
const qmp = { x: (c.x || 0) - (a.x || 0), y: (c.y || 0) - (a.y || 0) };
|
||||
const denom = cross(r, s);
|
||||
const qmpxr = cross(qmp, r);
|
||||
|
||||
if (Math.abs(denom) < eps) {
|
||||
if (Math.abs(qmpxr) < eps) return { collinear: true };
|
||||
return null;
|
||||
}
|
||||
const t = cross(qmp, s) / denom;
|
||||
const u = cross(qmp, r) / denom;
|
||||
if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null;
|
||||
return { t, u, collinear: false };
|
||||
}
|
||||
|
||||
function findClosedLineLoops(feature, entities, pointById) {
|
||||
return this.findClosedCurveLoops(feature, entities, pointById);
|
||||
}
|
||||
|
||||
function polygonAbsArea(loop) {
|
||||
if (!Array.isArray(loop) || loop.length < 3) return 0;
|
||||
let area2 = 0;
|
||||
for (let i = 0; i < loop.length; i++) {
|
||||
const a = loop[i];
|
||||
const b = loop[(i + 1) % loop.length];
|
||||
area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0);
|
||||
}
|
||||
return Math.abs(area2 * 0.5);
|
||||
}
|
||||
|
||||
function polygonSignedArea(loop) {
|
||||
if (!Array.isArray(loop) || loop.length < 3) return 0;
|
||||
let area2 = 0;
|
||||
for (let i = 0; i < loop.length; i++) {
|
||||
const a = loop[i];
|
||||
const b = loop[(i + 1) % loop.length];
|
||||
area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0);
|
||||
}
|
||||
return area2 * 0.5;
|
||||
}
|
||||
|
||||
function ensureLoopWinding(loop, ccw = true) {
|
||||
if (!Array.isArray(loop)) return loop;
|
||||
const signed = polygonSignedArea(loop);
|
||||
const isCCW = signed > 0;
|
||||
if ((ccw && isCCW) || (!ccw && !isCCW)) return loop;
|
||||
return loop.slice().reverse();
|
||||
}
|
||||
|
||||
function pointOnSegment(p, a, b, eps = 1e-8) {
|
||||
const px = p.x || 0;
|
||||
const py = p.y || 0;
|
||||
const ax = a.x || 0;
|
||||
const ay = a.y || 0;
|
||||
const bx = b.x || 0;
|
||||
const by = b.y || 0;
|
||||
const abx = bx - ax;
|
||||
const aby = by - ay;
|
||||
const apx = px - ax;
|
||||
const apy = py - ay;
|
||||
const cross = abx * apy - aby * apx;
|
||||
if (Math.abs(cross) > eps) return false;
|
||||
const dot = apx * abx + apy * aby;
|
||||
if (dot < -eps) return false;
|
||||
const len2 = abx * abx + aby * aby;
|
||||
if (dot - len2 > eps) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// returns 1 = inside, 0 = boundary, -1 = outside
|
||||
function pointInPolygonState(point, loop) {
|
||||
if (!Array.isArray(loop) || loop.length < 3) return -1;
|
||||
const p = { x: point.x || 0, y: point.y || 0 };
|
||||
let inside = false;
|
||||
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
|
||||
const a = loop[i];
|
||||
const b = loop[j];
|
||||
if (pointOnSegment(p, a, b)) return 0;
|
||||
const yi = a.y || 0;
|
||||
const yj = b.y || 0;
|
||||
const xi = a.x || 0;
|
||||
const xj = b.x || 0;
|
||||
const intersect = ((yi > p.y) !== (yj > p.y))
|
||||
&& (p.x < ((xj - xi) * (p.y - yi)) / ((yj - yi) || 1e-12) + xi);
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside ? 1 : -1;
|
||||
}
|
||||
|
||||
function loopContainsLoop(outer, inner) {
|
||||
if (!Array.isArray(outer) || !Array.isArray(inner) || outer.length < 3 || inner.length < 3) return false;
|
||||
let sawInside = false;
|
||||
for (const p of inner) {
|
||||
const state = pointInPolygonState(p, outer);
|
||||
if (state < 0) return false;
|
||||
if (state > 0) sawInside = true;
|
||||
}
|
||||
return sawInside;
|
||||
}
|
||||
|
||||
function buildProfileRegions(loops) {
|
||||
const valid = (loops || [])
|
||||
.filter(loop => Array.isArray(loop) && loop.length >= 3 && polygonAbsArea(loop) > 1e-10)
|
||||
.map((loop, index) => ({
|
||||
id: index,
|
||||
loop,
|
||||
area: polygonAbsArea(loop),
|
||||
parent: null,
|
||||
children: []
|
||||
}));
|
||||
if (!valid.length) return [];
|
||||
valid.sort((a, b) => a.area - b.area);
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
const child = valid[i];
|
||||
for (let j = i + 1; j < valid.length; j++) {
|
||||
const parent = valid[j];
|
||||
if (loopContainsLoop(parent.loop, child.loop)) {
|
||||
child.parent = parent;
|
||||
parent.children.push(child);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every loop yields one selectable region: itself minus immediate children.
|
||||
const regions = [];
|
||||
for (const node of valid) {
|
||||
regions.push({
|
||||
outer: node.loop,
|
||||
holes: node.children.map(c => c.loop)
|
||||
});
|
||||
}
|
||||
return regions;
|
||||
}
|
||||
|
||||
export {
|
||||
addClosedProfileFills,
|
||||
simplifyLoopsWithClipper,
|
||||
findClosedCurveLoops,
|
||||
segmentIntersectionParams,
|
||||
findClosedLineLoops
|
||||
};
|
||||
1705
src/void/sketch/runtime_ui.js
Normal file
1705
src/void/sketch/runtime_ui.js
Normal file
File diff suppressed because it is too large
Load diff
543
src/void/sketch/tools.js
Normal file
543
src/void/sketch/tools.js
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js';
|
||||
|
||||
function getEditingSketchFeature() {
|
||||
const sketchId = api.sketchRuntime?.editingId;
|
||||
if (!sketchId) return null;
|
||||
const feature = api.features.findById(sketchId);
|
||||
return feature?.type === 'sketch' ? feature : null;
|
||||
}
|
||||
|
||||
function isSketchEditing() {
|
||||
return !!this.getEditingSketchFeature();
|
||||
}
|
||||
|
||||
function setSketchTool(tool = 'select') {
|
||||
if (tool === 'arc') tool = 'arc-3pt';
|
||||
if (tool === 'circle') tool = 'circle-center';
|
||||
const allowed = new Set([
|
||||
'select',
|
||||
'point',
|
||||
'line',
|
||||
'arc',
|
||||
'arc-3pt',
|
||||
'arc-center',
|
||||
'arc-tangent',
|
||||
'circle',
|
||||
'circle-center',
|
||||
'circle-3pt',
|
||||
'rect',
|
||||
'rect-center'
|
||||
]);
|
||||
const next = allowed.has(tool) ? tool : 'select';
|
||||
if (this.sketchTool === next) {
|
||||
if (next === 'circle' || next === 'circle-center' || next === 'circle-3pt') {
|
||||
this.cancelSketchCircle();
|
||||
this.sketchPointerDown = null;
|
||||
this.sketchDrag = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.sketchTool = next;
|
||||
if (next !== 'select') {
|
||||
this.stopSketchMirrorMode?.();
|
||||
this.stopSketchCircularPatternMode?.();
|
||||
this.stopSketchGridPatternMode?.();
|
||||
}
|
||||
if (next !== 'line') {
|
||||
this.cancelSketchLine();
|
||||
}
|
||||
if (next !== 'arc' && next !== 'arc-3pt' && next !== 'arc-center' && next !== 'arc-tangent') {
|
||||
this.cancelSketchArc();
|
||||
}
|
||||
if (next !== 'circle' && next !== 'circle-center' && next !== 'circle-3pt') {
|
||||
this.cancelSketchCircle();
|
||||
}
|
||||
if (next !== 'rect' && next !== 'rect-center') {
|
||||
this.cancelSketchRect();
|
||||
}
|
||||
this.sketchRectCenterMode = next === 'rect-center';
|
||||
this.updateSketchInteractionVisuals();
|
||||
window.dispatchEvent(new CustomEvent('void-state-change'));
|
||||
}
|
||||
|
||||
function getSketchTool() {
|
||||
return this.sketchTool || 'select';
|
||||
}
|
||||
|
||||
function cancelSketchLine() {
|
||||
this.sketchLineStart = null;
|
||||
this.sketchLineStartRefId = null;
|
||||
this.sketchLineStartSeq = null;
|
||||
this.sketchLinePreview = null;
|
||||
}
|
||||
|
||||
function cancelSketchArc() {
|
||||
this.sketchArcStart = null;
|
||||
this.sketchArcStartRefId = null;
|
||||
this.sketchArcEnd = null;
|
||||
this.sketchArcEndRefId = null;
|
||||
this.sketchArcPreview = null;
|
||||
}
|
||||
|
||||
function cancelSketchCircle() {
|
||||
this.sketchCircleCenter = null;
|
||||
this.sketchCircleSecond = null;
|
||||
this.sketchCircleCenterRefId = null;
|
||||
this.sketchCircleSecondRefId = null;
|
||||
this.sketchCircleStartSeq = null;
|
||||
this.sketchArcPreview = null;
|
||||
}
|
||||
|
||||
function cancelSketchRect() {
|
||||
this.sketchRectStart = null;
|
||||
this.sketchRectStartRefId = null;
|
||||
this.sketchRectStartSeq = null;
|
||||
this.sketchRectPreview = null;
|
||||
}
|
||||
|
||||
function clearSketchSelection() {
|
||||
this.selectedSketchEntities.clear();
|
||||
this.selectedSketchArcCenters?.clear?.();
|
||||
this.selectedSketchConstraints.clear();
|
||||
this.selectedSketchProfiles?.clear?.();
|
||||
this.hoveredSketchProfileKey = null;
|
||||
api.sketchRuntime?.setSelectedProfiles?.([]);
|
||||
api.sketchRuntime?.setHoveredProfile?.(null);
|
||||
this.hoveredSketchEntityId = null;
|
||||
this.hoveredDerivedCandidate = null;
|
||||
this.selectedDerivedSelections?.clear?.();
|
||||
this.selectedSolidFaceKeys?.clear?.();
|
||||
api.solids?.clearFaceSelection?.();
|
||||
this.hoveredSketchConstraintId = null;
|
||||
this.sketchLinePreview = null;
|
||||
this.sketchArcPreview = null;
|
||||
this.sketchRectPreview = null;
|
||||
this.clearSketchMarquee();
|
||||
this.updateSketchInteractionVisuals();
|
||||
}
|
||||
|
||||
function getSelectedSketchMirrorAxis(feature) {
|
||||
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||
const selected = this.selectedSketchEntities instanceof Set ? this.selectedSketchEntities : new Set();
|
||||
const lines = entities.filter(entity => entity?.type === 'line' && selected.has(entity.id));
|
||||
return lines.length === 1 ? lines[0] : null;
|
||||
}
|
||||
|
||||
function startSketchMirrorMode() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) return false;
|
||||
const axis = this.getSelectedSketchMirrorAxis(feature);
|
||||
if (!axis?.id) return false;
|
||||
this.sketchMirrorMode = true;
|
||||
this.sketchMirrorAxisId = axis.id;
|
||||
this.stopSketchCircularPatternMode?.();
|
||||
this.setSketchTool('select');
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function stopSketchMirrorMode() {
|
||||
if (!this.sketchMirrorMode && !this.sketchMirrorAxisId) return false;
|
||||
this.sketchMirrorMode = false;
|
||||
this.sketchMirrorAxisId = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getSelectedSketchPatternCenter(feature) {
|
||||
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||
const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity]));
|
||||
const refs = [];
|
||||
for (const id of this.selectedSketchEntities || []) {
|
||||
if (id === SKETCH_VIRTUAL_ORIGIN_ID) {
|
||||
refs.push(SKETCH_VIRTUAL_ORIGIN_ID);
|
||||
continue;
|
||||
}
|
||||
const ent = byId.get(id);
|
||||
if (ent?.type === 'point') refs.push(id);
|
||||
}
|
||||
for (const arcId of this.selectedSketchArcCenters || []) {
|
||||
if (typeof arcId === 'string' && arcId) {
|
||||
refs.push(`arc-center:${arcId}`);
|
||||
}
|
||||
}
|
||||
const unique = [...new Set(refs)];
|
||||
return unique.length === 1 ? unique[0] : null;
|
||||
}
|
||||
|
||||
function startSketchCircularPatternMode() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) return false;
|
||||
const centerRef = this.getSelectedSketchPatternCenter(feature);
|
||||
if (!centerRef) return false;
|
||||
this.sketchCircularPatternMode = true;
|
||||
this.sketchCircularPatternCenterRef = centerRef;
|
||||
this.stopSketchMirrorMode?.();
|
||||
this.setSketchTool('select');
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function stopSketchCircularPatternMode() {
|
||||
if (!this.sketchCircularPatternMode && !this.sketchCircularPatternCenterRef) return false;
|
||||
this.sketchCircularPatternMode = false;
|
||||
this.sketchCircularPatternCenterRef = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getSelectedSketchGridAnchor(feature) {
|
||||
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||
const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity]));
|
||||
const pointIds = [];
|
||||
for (const id of this.selectedSketchEntities || []) {
|
||||
const ent = byId.get(id);
|
||||
if (ent?.type === 'point') pointIds.push(id);
|
||||
}
|
||||
const unique = [...new Set(pointIds)];
|
||||
return unique.length === 1 ? unique[0] : null;
|
||||
}
|
||||
|
||||
function startSketchGridPatternMode() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) return false;
|
||||
const centerPointId = this.getSelectedSketchGridAnchor(feature);
|
||||
if (!centerPointId) return false;
|
||||
this.sketchGridPatternMode = true;
|
||||
this.sketchGridPatternCenterRef = centerPointId;
|
||||
this.stopSketchMirrorMode?.();
|
||||
this.stopSketchCircularPatternMode?.();
|
||||
this.setSketchTool('select');
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function stopSketchGridPatternMode() {
|
||||
if (!this.sketchGridPatternMode && !this.sketchGridPatternCenterRef) return false;
|
||||
this.sketchGridPatternMode = false;
|
||||
this.sketchGridPatternCenterRef = null;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function editSketchCircularPatternConstraint(constraintId) {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !constraintId) return false;
|
||||
const constraints = Array.isArray(feature.constraints) ? feature.constraints : [];
|
||||
const found = constraints.find(c => c?.id === constraintId && c?.type === 'circular_pattern');
|
||||
if (!found) return false;
|
||||
const current = Math.max(2, Number(found?.data?.count || 0) || 3);
|
||||
const input = window.prompt('Pattern copies', String(current));
|
||||
if (input === null) return false;
|
||||
const value = Math.floor(Number(input));
|
||||
if (!Number.isFinite(value) || value < 2 || value > 256) return false;
|
||||
return !!this.updateCircularPatternConstraintCopies?.(constraintId, value);
|
||||
}
|
||||
|
||||
function editSketchGridPatternConstraint(constraintId, axis = 'h') {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature || !constraintId) return false;
|
||||
const constraints = Array.isArray(feature.constraints) ? feature.constraints : [];
|
||||
const found = constraints.find(c => c?.id === constraintId && c?.type === 'grid_pattern');
|
||||
if (!found) return false;
|
||||
const key = axis === 'v' ? 'countV' : 'countH';
|
||||
const current = Math.max(1, Number(found?.data?.[key] || 0) || 3);
|
||||
const input = window.prompt(`${axis === 'v' ? 'Vertical' : 'Horizontal'} copies`, String(current));
|
||||
if (input === null) return false;
|
||||
const value = Math.floor(Number(input));
|
||||
if (!Number.isFinite(value) || value < 1 || value > 256) return false;
|
||||
return !!this.updateGridPatternConstraintCopies?.(constraintId, axis, value);
|
||||
}
|
||||
|
||||
function handleSketchKeyDown(event) {
|
||||
if (!this.isSketchEditing()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeTag = document.activeElement?.tagName;
|
||||
const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
|
||||
if (editingInput) {
|
||||
return false;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event.code === 'Escape') {
|
||||
this.sketchPointerDown = null;
|
||||
this.sketchDrag = null;
|
||||
if (api?.sketchRuntime) {
|
||||
api.sketchRuntime._glyphDrag = null;
|
||||
}
|
||||
const hadMarquee = !!this.sketchMarquee;
|
||||
if (hadMarquee) {
|
||||
this.clearSketchMarquee();
|
||||
}
|
||||
const hadLine = !!this.sketchLineStart;
|
||||
const hadArc = !!this.sketchArcStart || !!this.sketchArcEnd;
|
||||
const hadRect = !!this.sketchRectStart;
|
||||
if (hadLine) {
|
||||
this.cancelSketchLine();
|
||||
}
|
||||
if (hadArc) {
|
||||
this.cancelSketchArc();
|
||||
}
|
||||
if (hadRect) {
|
||||
this.cancelSketchRect();
|
||||
}
|
||||
if (this.getSketchTool() !== 'select') {
|
||||
this.setSketchTool('select');
|
||||
return true;
|
||||
}
|
||||
if (this.stopSketchMirrorMode?.()) {
|
||||
return true;
|
||||
}
|
||||
if (this.stopSketchCircularPatternMode?.()) {
|
||||
return true;
|
||||
}
|
||||
if (this.stopSketchGridPatternMode?.()) {
|
||||
return true;
|
||||
}
|
||||
return hadLine || hadArc || hadRect || hadMarquee;
|
||||
}
|
||||
|
||||
const toggleTool = tool => {
|
||||
const curr = this.getSketchTool();
|
||||
this.setSketchTool(curr === tool ? 'select' : tool);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (event.code === 'KeyL' && !event.shiftKey) {
|
||||
return toggleTool('line');
|
||||
}
|
||||
if (event.code === 'KeyA' && !event.shiftKey) {
|
||||
return toggleTool('arc-3pt');
|
||||
}
|
||||
if (event.code === 'KeyC' && !event.shiftKey) {
|
||||
return toggleTool('circle-center');
|
||||
}
|
||||
if (event.code === 'KeyR' && !event.shiftKey) {
|
||||
return toggleTool('rect-center');
|
||||
}
|
||||
if (event.code === 'KeyG' && !event.shiftKey) {
|
||||
return toggleTool('rect');
|
||||
}
|
||||
if (event.code === 'KeyS' && event.shiftKey) {
|
||||
return toggleTool('point');
|
||||
}
|
||||
if (event.code === 'KeyQ' && !event.shiftKey) {
|
||||
return this.toggleSelectedConstruction();
|
||||
}
|
||||
if (event.code === 'KeyU' && !event.shiftKey) {
|
||||
return this.useHoveredDerivedEdge();
|
||||
}
|
||||
if (event.code === 'KeyH' && !event.shiftKey) {
|
||||
this.applySketchConstraint('horizontal');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyV' && !event.shiftKey) {
|
||||
this.applySketchConstraint('vertical');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyL' && event.shiftKey) {
|
||||
this.applySketchConstraint('perpendicular');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyE' && !event.shiftKey) {
|
||||
this.applySketchConstraint('equal');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyD' && !event.shiftKey) {
|
||||
this.applySketchConstraint('dimension');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyT' && !event.shiftKey) {
|
||||
this.applySketchConstraint('tangent');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyI' && !event.shiftKey) {
|
||||
this.applySketchConstraint('coincident');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyM' && event.shiftKey) {
|
||||
this.applySketchConstraint('midpoint');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyJ' && event.shiftKey) {
|
||||
this.applySketchConstraint('fixed');
|
||||
return true;
|
||||
}
|
||||
if (event.code === 'KeyM' && !event.shiftKey) {
|
||||
if (this.sketchMirrorMode) {
|
||||
return this.stopSketchMirrorMode?.();
|
||||
}
|
||||
return this.startSketchMirrorMode?.();
|
||||
}
|
||||
if (event.code === 'Delete' || event.code === 'Backspace') {
|
||||
if (this.selectedSketchConstraints?.size) {
|
||||
return this.deleteSelectedSketchConstraints();
|
||||
}
|
||||
return this.deleteSelectedSketchEntities();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectSketchConstraint(constraintId, event = {}) {
|
||||
if (!constraintId) {
|
||||
return false;
|
||||
}
|
||||
const multi = !!(event.ctrlKey || event.metaKey || event.shiftKey);
|
||||
if (!multi) {
|
||||
if (this.selectedSketchConstraints.size === 1 && this.selectedSketchConstraints.has(constraintId)) {
|
||||
return false;
|
||||
}
|
||||
this.selectedSketchConstraints.clear();
|
||||
this.selectedSketchConstraints.add(constraintId);
|
||||
} else {
|
||||
if (this.selectedSketchConstraints.has(constraintId)) {
|
||||
this.selectedSketchConstraints.delete(constraintId);
|
||||
} else {
|
||||
this.selectedSketchConstraints.add(constraintId);
|
||||
}
|
||||
}
|
||||
this.hoveredSketchConstraintId = constraintId;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function setHoveredSketchConstraint(constraintId) {
|
||||
const next = constraintId || null;
|
||||
if (this.hoveredSketchConstraintId === next) {
|
||||
return false;
|
||||
}
|
||||
this.hoveredSketchConstraintId = next;
|
||||
this.updateSketchInteractionVisuals();
|
||||
return true;
|
||||
}
|
||||
|
||||
function useHoveredDerivedEdge() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
if (!feature) return false;
|
||||
const selectionMap = this.selectedDerivedSelections instanceof Map
|
||||
? this.selectedDerivedSelections
|
||||
: new Map();
|
||||
const selectedEdges = [];
|
||||
const selectedPoints = [];
|
||||
for (const sel of selectionMap.values()) {
|
||||
if (sel?.type === 'edge') selectedEdges.push(sel);
|
||||
if (sel?.type === 'point') selectedPoints.push(sel);
|
||||
}
|
||||
const selectedFaces = Array.from(this.selectedSolidFaceKeys || []);
|
||||
const hovered = this.hoveredDerivedCandidate || null;
|
||||
if (!selectedEdges.length && !selectedPoints.length && !selectedFaces.length) {
|
||||
// `u` should prioritize the actively hovered derived edge candidate.
|
||||
if (hovered?.aLocal && hovered?.bLocal) {
|
||||
if (Array.isArray(hovered?.pathLocalSegments) && hovered.pathLocalSegments.length > 1) {
|
||||
const worldSegs = Array.isArray(hovered?.pathWorldSegments) ? hovered.pathWorldSegments : [];
|
||||
const segKeys = Array.isArray(hovered?.pathSegmentKeys) ? hovered.pathSegmentKeys : [];
|
||||
const segEntityIds = Array.isArray(hovered?.pathSegmentEntityIds) ? hovered.pathSegmentEntityIds : [];
|
||||
for (let i = 0; i < hovered.pathLocalSegments.length; i++) {
|
||||
const seg = hovered.pathLocalSegments[i];
|
||||
const wseg = worldSegs[i] || null;
|
||||
const segKey = String(segKeys[i] || '');
|
||||
const segEntityId = String(segEntityIds[i] || '');
|
||||
const segKeyParts = segKey ? segKey.split(':') : [];
|
||||
const segIndex = segKeyParts.length >= 4 ? Number(segKeyParts[segKeyParts.length - 1]) : NaN;
|
||||
if (!seg?.a || !seg?.b) continue;
|
||||
selectedEdges.push({
|
||||
type: 'edge',
|
||||
aLocal: seg.a,
|
||||
bLocal: seg.b,
|
||||
source: {
|
||||
...(hovered.source || {}),
|
||||
entity: segEntityId
|
||||
? { kind: 'boundary-segment', id: segEntityId }
|
||||
: (hovered?.source?.entity || null),
|
||||
boundary_segment_id: segEntityId || String(hovered?.source?.boundary_segment_id || ''),
|
||||
edge_key: segKey || String(hovered?.source?.edge_key || ''),
|
||||
edge_index: Number.isFinite(segIndex)
|
||||
? segIndex
|
||||
: Number(hovered?.source?.edge_index ?? i),
|
||||
a: wseg?.a || hovered?.source?.a || null,
|
||||
b: wseg?.b || hovered?.source?.b || null,
|
||||
local_a: seg.a,
|
||||
local_b: seg.b
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selectedEdges.push({
|
||||
type: 'edge',
|
||||
aLocal: hovered.aLocal,
|
||||
bLocal: hovered.bLocal,
|
||||
source: hovered.source || null
|
||||
});
|
||||
}
|
||||
} else if (this.hoveredSolidFaceKey) {
|
||||
// Face derive is fallback only when no discrete edge is hovered.
|
||||
selectedFaces.push(this.hoveredSolidFaceKey);
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
edges: selectedEdges,
|
||||
points: selectedPoints,
|
||||
faces: selectedFaces
|
||||
};
|
||||
const created = this.deriveSelectionsAtomic(feature, payload);
|
||||
if (!created) return false;
|
||||
// Prevent immediate face-boundary re-highlight after `u` when cursor is still nearby.
|
||||
this.hoveredSolidFaceKey = null;
|
||||
api.solids?.setHoveredFace?.(null);
|
||||
this.clearSketchSelection?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function useHoveredDerivedPoint() {
|
||||
const feature = this.getEditingSketchFeature();
|
||||
const candidate = this.hoveredDerivedCandidate || null;
|
||||
if (!feature || !candidate) {
|
||||
return false;
|
||||
}
|
||||
const local = candidate?.hoverPoint?.local || candidate?.midLocal || null;
|
||||
if (!local) return false;
|
||||
const created = this.createDerivedSketchPoint(feature, local, {
|
||||
...(candidate.source || {}),
|
||||
local_point: null,
|
||||
point_kind: candidate?.hoverPoint?.kind || 'mid'
|
||||
});
|
||||
if (!created) return false;
|
||||
this.clearSketchSelection?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
export {
|
||||
getEditingSketchFeature,
|
||||
isSketchEditing,
|
||||
setSketchTool,
|
||||
getSketchTool,
|
||||
cancelSketchLine,
|
||||
cancelSketchArc,
|
||||
cancelSketchCircle,
|
||||
cancelSketchRect,
|
||||
clearSketchSelection,
|
||||
getSelectedSketchMirrorAxis,
|
||||
startSketchMirrorMode,
|
||||
stopSketchMirrorMode,
|
||||
getSelectedSketchPatternCenter,
|
||||
startSketchCircularPatternMode,
|
||||
stopSketchCircularPatternMode,
|
||||
getSelectedSketchGridAnchor,
|
||||
startSketchGridPatternMode,
|
||||
stopSketchGridPatternMode,
|
||||
editSketchCircularPatternConstraint,
|
||||
editSketchGridPatternConstraint,
|
||||
handleSketchKeyDown,
|
||||
selectSketchConstraint,
|
||||
setHoveredSketchConstraint,
|
||||
useHoveredDerivedEdge,
|
||||
useHoveredDerivedPoint
|
||||
};
|
||||
Loading…
Reference in a new issue