merge experimental void app

This commit is contained in:
Stewart Allen 2026-02-14 19:00:48 -05:00
commit 02fb3cd30d
80 changed files with 30437 additions and 813 deletions

111
src/void/api.js Normal file
View file

@ -0,0 +1,111 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { overlay } from './overlay.js';
import { datum } from './datum.js';
import { Plane } from './plane.js';
import { interact } from './interact.js';
import { createOriginApi } from './api/origin.js';
import { createFeaturesApi } from './api/features.js';
import { createSketchApi } from './sketch/api.js';
import { createSketchRuntimeApi } from './sketch/runtime.js';
import { createDocumentApi } from './api/document.js';
import { createSolidsApi } from './api/solids.js';
import { createGeometryStoreApi } from './api/geometry_store.js';
const DOC_SCHEMA_VERSION = 1;
const ADMIN_CURRENT_DOC_KEY = 'current_doc_id';
const ADMIN_CURRENT_REV_KEY = 'current_rev';
const UNDOABLE_OP_TYPES = new Set([
'snapshot',
'datum.update',
'datum.root.update',
'origin.update',
'feature.add',
'feature.remove',
'feature.move',
'feature.rename',
'feature.update',
'feature.suppress',
'feature.atomic.edit',
'timeline.set'
]);
function shortId() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function revString(rev) {
const major = String(rev?.major ?? 0).padStart(6, '0');
const micro = String(rev?.micro ?? 0).padStart(6, '0');
return `${major}.${micro}`;
}
// Main API object
const api = {
db: null,
overlay,
datum,
Plane,
interact,
origin: null,
// Document management
document: null,
// Feature management
sketch: null,
sketchRuntime: null,
features: null,
solids: null,
geometryStore: null,
// Selection management
selection: {
items: new Set(),
clear() {
this.items.clear();
},
add(item) {
this.items.add(item);
},
remove(item) {
this.items.delete(item);
},
toggle(item) {
if (this.items.has(item)) {
this.remove(item);
} else {
this.add(item);
}
}
},
// Initialize API
init() {
console.log({ api_initialized: true });
}
};
api.origin = createOriginApi(() => api);
api.features = createFeaturesApi(() => api);
api.sketch = createSketchApi(() => api, shortId);
api.sketchRuntime = createSketchRuntimeApi(() => api);
api.document = createDocumentApi(() => api, {
DOC_SCHEMA_VERSION,
ADMIN_CURRENT_DOC_KEY,
ADMIN_CURRENT_REV_KEY,
UNDOABLE_OP_TYPES,
idFactory: shortId,
revString
});
api.solids = createSolidsApi(() => api);
api.geometryStore = createGeometryStoreApi(() => api);
export { api };

616
src/void/api/document.js Normal file
View file

@ -0,0 +1,616 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { space } from '../../moto/space.js';
function createDocumentApi(getApi, cfg) {
const {
DOC_SCHEMA_VERSION,
ADMIN_CURRENT_DOC_KEY,
ADMIN_CURRENT_REV_KEY,
UNDOABLE_OP_TYPES,
idFactory,
revString
} = cfg;
return {
current: null,
isHydrating: false,
_runtimeSaveTimer: null,
_runtimeSavePending: null,
_datumHandlers: new Map(),
_datumRootHandler: null,
_originHandler: null,
_runtimeFlushBound: false,
_redoStack: [],
_atomicEdit: null,
create() {
const api = getApi();
const doc = {
id: idFactory(),
schema_version: DOC_SCHEMA_VERSION,
name: 'Untitled',
created_at: Date.now(),
modified_at: Date.now(),
version: { major: 0, micro: 0 },
head_rev: null,
features: [],
tree: {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
},
timeline: {
index: null
},
generated: {
solids: []
},
geometry_store: api.geometryStore?.defaultState?.() || null,
scene: {
datum: api.datum.defaultState(),
origin: api.origin.defaultState()
}
};
this.current = doc;
this._redoStack = [];
return doc;
},
normalizeName(name) {
const clean = String(name || '').trim();
return clean || 'Untitled';
},
migrate(doc) {
const api = getApi();
if (!doc) return doc;
let changed = false;
if (doc.schema_version === undefined) {
doc.schema_version = DOC_SCHEMA_VERSION;
changed = true;
}
if (!doc.created_at && doc.created) {
doc.created_at = doc.created;
changed = true;
}
if (!doc.modified_at && doc.modified) {
doc.modified_at = doc.modified;
changed = true;
}
if (!doc.version) {
doc.version = { major: 0, micro: 0 };
changed = true;
}
if (doc.head_rev === undefined) {
doc.head_rev = null;
changed = true;
}
if (!Array.isArray(doc.features)) {
doc.features = [];
changed = true;
}
if (!doc.tree || !Array.isArray(doc.tree.folders)) {
doc.tree = {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
};
changed = true;
}
if (!doc.timeline || typeof doc.timeline !== 'object') {
doc.timeline = { index: null };
changed = true;
}
if (doc.timeline.index !== null && !Number.isFinite(doc.timeline.index)) {
doc.timeline.index = null;
changed = true;
}
if (!doc.generated || typeof doc.generated !== 'object') {
doc.generated = { solids: [] };
changed = true;
}
if (!Array.isArray(doc.generated.solids)) {
doc.generated.solids = [];
changed = true;
}
if (!doc.geometry_store) {
doc.geometry_store = api.geometryStore?.defaultState?.() || null;
changed = true;
}
if (api.geometryStore?.normalize) {
const normalized = api.geometryStore.normalize(doc.geometry_store);
if (JSON.stringify(normalized) !== JSON.stringify(doc.geometry_store)) {
doc.geometry_store = normalized;
changed = true;
}
}
if (!doc.scene) {
doc.scene = {};
changed = true;
}
if (!doc.scene.datum) {
doc.scene.datum = api.datum.defaultState();
changed = true;
}
if (!doc.scene.origin) {
doc.scene.origin = api.origin.defaultState();
changed = true;
}
return { doc, changed };
},
nextRevision(kind = 'micro') {
const current = this.current?.version || { major: 0, micro: 0 };
if (kind === 'major') {
return { major: current.major + 1, micro: 0 };
}
return { major: current.major, micro: current.micro + 1 };
},
revisionKey(docId, rev) {
return `${docId}:${revString(rev)}`;
},
toSnapshot() {
if (!this.current) return null;
this.current.scene = this.captureRuntimeState();
return JSON.parse(JSON.stringify(this.current));
},
captureRuntimeState() {
const api = getApi();
return {
datum: api.datum.toJSON(),
origin: api.origin.toJSON()
};
},
hydrateRuntimeState(doc) {
const api = getApi();
if (!doc) return;
api.geometryStore?.hydrate?.(doc);
const scene = doc.scene || {};
const datumState = scene.datum || api.datum.defaultState();
const originState = scene.origin || api.origin.defaultState();
this.isHydrating = true;
try {
api.origin.applyJSON(originState, false);
api.datum.applyJSON(datumState);
} finally {
this.isHydrating = false;
}
api.datum.updateLabels(api.overlay);
api.sketchRuntime?.sync();
space.update();
},
bindRuntimeObservers() {
const api = getApi();
if (!api.datum || !api.datum.getPlanes) return;
for (const [id, rec] of this._datumHandlers) {
rec.plane.offChange(rec.handler);
}
this._datumHandlers.clear();
if (this._datumRootHandler) {
api.datum.offChange(this._datumRootHandler);
this._datumRootHandler = null;
}
if (this._originHandler) {
api.origin.offChange(this._originHandler);
this._originHandler = null;
}
for (const plane of api.datum.getPlanes()) {
const handler = () => {
this.scheduleRuntimeSave('datum.update', { plane_id: plane.id });
};
this._datumHandlers.set(plane.id, { plane, handler });
plane.onChange(handler);
}
this._datumRootHandler = () => {
this.scheduleRuntimeSave('datum.root.update', { scope: 'datum' });
};
api.datum.onChange(this._datumRootHandler);
this._originHandler = () => {
this.scheduleRuntimeSave('origin.update', { scope: 'origin' });
};
api.origin.onChange(this._originHandler);
if (!this._runtimeFlushBound) {
this._runtimeFlushBound = true;
const flush = () => this.flushRuntimeSave();
window.addEventListener('pagehide', flush);
window.addEventListener('beforeunload', flush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flush();
}
});
}
},
scheduleRuntimeSave(opType = 'runtime.update', payload = null) {
if (this.isHydrating || !this.current) {
return;
}
this._runtimeSavePending = { opType, payload };
clearTimeout(this._runtimeSaveTimer);
this._runtimeSaveTimer = setTimeout(() => {
this.flushRuntimeSave();
}, 200);
},
flushRuntimeSave() {
if (this.isHydrating || !this.current || !this._runtimeSavePending) {
return Promise.resolve();
}
clearTimeout(this._runtimeSaveTimer);
const pending = this._runtimeSavePending;
this._runtimeSavePending = null;
return this.save({
kind: 'micro',
opType: pending.opType,
payload: pending.payload
});
},
load(id) {
const api = getApi();
return api.db.documents.get(id).then(doc => {
if (doc) {
const migrated = this.migrate(doc);
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
this.current.name = this.normalizeName(this.current.name);
this._redoStack = [];
this.hydrateRuntimeState(this.current);
if (migrated.changed) {
return api.db.documents.put(this.current.id, this.current).then(() => this.current);
}
return this.current;
}
return null;
});
},
save(options = {}) {
const api = getApi();
if (this.current) {
const now = Date.now();
const kind = options.kind || 'micro';
const opType = options.opType || (kind === 'major' ? 'snapshot' : 'delta');
const undoable = options.undoable !== undefined ? !!options.undoable :
(kind === 'major' || UNDOABLE_OP_TYPES.has(opType));
const next = this.nextRevision(kind);
const revId = this.revisionKey(this.current.id, next);
this.current.modified_at = now;
this.current.name = this.normalizeName(this.current.name);
if (!undoable) {
const currentRev = this.current.head_rev || null;
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, currentRev)
]);
}
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: this.current.head_rev || null,
schema_version: DOC_SCHEMA_VERSION,
op_type: opType,
payload: options.payload || null,
snapshot: this.toSnapshot(),
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = revision.snapshot.scene;
if (undoable && options.clearRedo !== false) {
this._redoStack = [];
}
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]);
}
return Promise.resolve();
},
createAndSelect() {
this.create();
this.hydrateRuntimeState(this.current);
return this.save({
kind: 'major',
opType: 'snapshot',
payload: { reason: 'seed' }
}).then(() => this.current);
},
list() {
const api = getApi();
return api.db.documents.iterate().then(entries => {
return entries
.map(({ value }) => value)
.filter(Boolean)
.map(doc => {
const migrated = this.migrate(doc);
migrated.doc.name = this.normalizeName(migrated.doc.name);
return migrated.doc;
})
.sort((a, b) => (b.modified_at || 0) - (a.modified_at || 0));
});
},
select(id) {
const api = getApi();
return this.load(id).then(doc => {
if (!doc) {
return null;
}
this._redoStack = [];
return Promise.all([
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
});
},
open(id) {
return this.select(id);
},
rename(name) {
if (!this.current) return Promise.resolve(null);
const nextName = this.normalizeName(name);
if (nextName === this.current.name) {
return Promise.resolve(this.current);
}
const previous = this.current.name;
this.current.name = nextName;
return this.save({
kind: 'micro',
opType: 'doc.rename',
undoable: false,
payload: {
previous,
next: nextName
}
}).then(() => this.current);
},
getRevision(revId) {
const api = getApi();
if (!revId) return Promise.resolve(null);
return api.db.versions.get(revId);
},
applyRevision(revision) {
const api = getApi();
if (!revision || !revision.snapshot) {
return Promise.resolve(null);
}
const previousAtomicEdit = this._atomicEdit ? { ...this._atomicEdit } : null;
const previousDocId = this.current?.id || null;
const preserved = this.current ? {
name: this.current.name,
tree: JSON.parse(JSON.stringify(this.current.tree || { folders: [] }))
} : null;
const migrated = this.migrate(JSON.parse(JSON.stringify(revision.snapshot)));
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
if (previousAtomicEdit && previousDocId && previousDocId === this.current?.id) {
const featureId = previousAtomicEdit.feature_id || null;
const hasFeature = featureId && Array.isArray(this.current?.features)
? this.current.features.some(feature => feature?.id === featureId)
: false;
this._atomicEdit = hasFeature ? previousAtomicEdit : null;
} else {
this._atomicEdit = null;
}
if (preserved) {
this.current.name = this.normalizeName(preserved.name);
this.current.tree = preserved.tree;
}
this.current.version = revision.rev || this.current.version;
this.current.head_rev = revision.rev_id || this.revisionKey(this.current.id, this.current.version);
this.current.modified_at = revision.created_at || this.current.modified_at || Date.now();
this.hydrateRuntimeState(this.current);
api.solids?.scheduleRebuild?.('document.hydrate');
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
},
canRedo() {
return this._redoStack.length > 0;
},
undo() {
const head = this.current?.head_rev;
if (!head) return Promise.resolve(false);
return this.getRevision(head).then(revision => {
const parent = revision?.parent_rev;
if (!parent) return false;
return this.getRevision(parent).then(parentRevision => {
if (!parentRevision) return false;
this._redoStack.push(head);
return this.applyRevision(parentRevision).then(() => true);
});
});
},
redo() {
if (!this._redoStack.length) return Promise.resolve(false);
const nextRev = this._redoStack.pop();
return this.getRevision(nextRev).then(revision => {
if (!revision) return false;
if (revision.parent_rev && revision.parent_rev !== this.current?.head_rev) {
this._redoStack = [];
return false;
}
return this.applyRevision(revision).then(() => true);
});
},
delete(id) {
const api = getApi();
if (!id) return Promise.resolve(false);
const isCurrent = this.current?.id === id;
const lower = `${id}:`;
const upper = `${id}:\uffff`;
return api.db.versions.iterate({ lower, upper }).then(entries => {
const deletes = entries.map(({ key }) => api.db.versions.remove(key));
deletes.push(api.db.documents.remove(id));
return Promise.all(deletes);
}).then(() => {
if (!isCurrent) {
return true;
}
this._redoStack = [];
return this.list().then(docs => {
const next = docs.find(doc => doc.id !== id);
if (next) {
return this.select(next.id).then(() => true);
}
return this.createAndSelect().then(() => true);
});
});
},
restoreOrCreate() {
const api = getApi();
return api.db.admin.get(ADMIN_CURRENT_DOC_KEY).then(docId => {
if (!docId) {
return this.createAndSelect();
}
return this.select(docId).then(doc => {
if (doc) {
return this.current;
}
return this.list().then(docs => {
if (docs.length) {
return this.select(docs[0].id).then(() => this.current);
}
return this.createAndSelect();
});
});
});
},
isAtomicEditActive() {
return !!this._atomicEdit;
},
getAtomicEditFeatureId() {
return this._atomicEdit?.feature_id || null;
},
beginAtomicEdit(meta = {}) {
this._atomicEdit = {
feature_id: meta.feature_id || null,
feature_type: meta.feature_type || null,
start_rev: this.current?.head_rev || null
};
},
endAtomicEdit(options = {}) {
const api = getApi();
const session = this._atomicEdit;
this._atomicEdit = null;
if (!session) {
return Promise.resolve(false);
}
if (options.commit !== true) {
return Promise.resolve(false);
}
const startRev = session.start_rev || null;
const currentHead = this.current?.head_rev || null;
if (startRev === currentHead) {
return Promise.resolve(false);
}
if (!this.current) return Promise.resolve(false);
const now = Date.now();
const next = this.nextRevision('micro');
const revId = this.revisionKey(this.current.id, next);
const snapshot = this.toSnapshot();
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: startRev,
schema_version: DOC_SCHEMA_VERSION,
op_type: options.opType || 'feature.atomic.edit',
payload: options.payload || {
feature_id: session.feature_id,
feature_type: session.feature_type
},
snapshot,
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = snapshot.scene;
this.current.modified_at = now;
this._redoStack = [];
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]).then(() => true);
},
getTimelineCount() {
const features = Array.isArray(this.current?.features) ? this.current.features : [];
if (!features.length) return 0;
const rawIndex = this.current?.timeline?.index;
if (rawIndex === null || rawIndex === undefined) {
return features.length;
}
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(rawIndex)));
return index + 1;
},
setTimelineCount(count) {
const api = getApi();
if (!this.current) return Promise.resolve(false);
const features = Array.isArray(this.current.features) ? this.current.features : [];
const maxCount = features.length;
const nextCount = Math.max(0, Math.min(maxCount, Math.floor(Number(count) || 0)));
const prevCount = this.getTimelineCount();
if (nextCount === prevCount) {
return Promise.resolve(false);
}
this.current.timeline = this.current.timeline || { index: null };
this.current.timeline.index = nextCount >= maxCount ? null : (nextCount - 1);
return this.save({
kind: 'micro',
opType: 'timeline.set',
payload: { previous: prevCount, next: nextCount }
}).then(() => {
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('timeline.set');
return true;
});
}
};
}
export { createDocumentApi };

238
src/void/api/features.js Normal file
View file

@ -0,0 +1,238 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createFeaturesApi(getApi) {
function getEffectiveTimelineCount(doc, editFeatureId = null) {
const features = Array.isArray(doc?.features) ? doc.features : [];
if (!features.length) return 0;
const raw = doc?.timeline?.index;
let count;
if (raw === null || raw === undefined) {
count = features.length;
} else {
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(raw)));
count = index + 1;
}
if (editFeatureId) {
const editIndex = features.findIndex(feature => feature?.id === editFeatureId);
if (editIndex >= 0) {
count = Math.min(count, editIndex + 1);
}
}
return count;
}
return {
list() {
const api = getApi();
const doc = api.document.current;
return doc ? doc.features : [];
},
listBuilt() {
const api = getApi();
const doc = api.document.current;
if (!doc) return [];
const features = Array.isArray(doc.features) ? doc.features : [];
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
return features.filter((feature, index) => index < timelineCount && feature?.suppressed !== true);
},
isIndexBuilt(index) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return false;
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
return index >= 0 && index < timelineCount;
},
isBuilt(featureId) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return false;
const index = doc.features.findIndex(f => f?.id === featureId);
if (index < 0) return false;
return this.isIndexBuilt(index);
},
add(feature) {
const api = getApi();
const doc = api.document.current;
if (doc) {
if (feature?.visible === undefined) {
feature.visible = true;
}
if (feature?.suppressed === undefined) {
feature.suppressed = false;
}
doc.features = Array.isArray(doc.features) ? doc.features : [];
const rawTimeline = doc.timeline?.index;
const hasTimelineMarker = rawTimeline !== null && rawTimeline !== undefined && Number.isFinite(rawTimeline);
const timelineIndex = hasTimelineMarker
? Math.max(-1, Math.min(doc.features.length - 1, Math.floor(rawTimeline)))
: (doc.features.length - 1);
const insertIndex = hasTimelineMarker ? (timelineIndex + 1) : doc.features.length;
doc.features.splice(insertIndex, 0, feature);
api.document.save({
kind: 'micro',
opType: 'feature.add',
payload: {
type: feature?.type || 'unknown',
id: feature?.id || null
}
});
if (hasTimelineMarker) {
// Keep marker at the newly inserted feature so downstream remains disabled.
doc.timeline.index = insertIndex;
}
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.add');
}
},
remove(feature) {
const api = getApi();
const doc = api.document.current;
if (doc) {
const index = doc.features.indexOf(feature);
if (index >= 0) {
doc.features.splice(index, 1);
if (doc.timeline && doc.timeline.index !== null && doc.timeline.index !== undefined) {
const timelineIndex = Math.floor(doc.timeline.index);
if (timelineIndex >= doc.features.length) {
doc.timeline.index = doc.features.length ? doc.features.length - 1 : null;
}
}
api.document.save({
kind: 'micro',
opType: 'feature.remove',
payload: {
type: feature?.type || 'unknown',
id: feature?.id || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.remove');
}
}
},
findById(id) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return null;
return doc.features.find(f => f?.id === id) || null;
},
update(featureId, mutator, options = {}) {
const api = getApi();
const doc = api.document.current;
if (!doc || !featureId) return null;
const feature = this.findById(featureId);
if (!feature) return null;
if (typeof mutator === 'function') {
mutator(feature);
} else if (mutator && typeof mutator === 'object') {
Object.assign(feature, mutator);
}
api.document.save({
kind: 'micro',
opType: options.opType || 'feature.update',
payload: {
id: feature.id,
type: feature.type || 'unknown',
changes: options.payload || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.update');
return feature;
},
mutateTransient(featureId, mutator) {
const api = getApi();
const feature = this.findById(featureId);
if (!feature) return null;
if (typeof mutator === 'function') {
mutator(feature);
} else if (mutator && typeof mutator === 'object') {
Object.assign(feature, mutator);
}
api.sketchRuntime?.sync();
return feature;
},
commit(featureId, options = {}) {
const api = getApi();
const feature = this.findById(featureId);
if (!feature) return null;
api.document.save({
kind: 'micro',
opType: options.opType || 'feature.update',
payload: {
id: feature.id,
type: feature.type || 'unknown',
changes: options.payload || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.commit');
return feature;
},
rename(featureId, name) {
const nextName = String(name || '').trim();
if (!nextName) return null;
return this.update(featureId, feature => {
feature.name = nextName;
}, {
opType: 'feature.rename',
payload: { name: nextName }
});
},
setVisible(featureId, visible) {
return this.update(featureId, feature => {
feature.visible = !!visible;
}, {
opType: 'feature.update',
payload: { field: 'visible', value: !!visible }
});
},
setSuppressed(featureId, suppressed) {
return this.update(featureId, feature => {
feature.suppressed = !!suppressed;
}, {
opType: 'feature.suppress',
payload: { field: 'suppressed', value: !!suppressed }
});
},
move(featureId, toIndex) {
const api = getApi();
const doc = api.document.current;
if (!doc || !featureId || !Array.isArray(doc.features)) return false;
const fromIndex = doc.features.findIndex(f => f?.id === featureId);
if (fromIndex < 0) return false;
const clamped = Math.max(0, Math.min(doc.features.length - 1, Math.floor(Number(toIndex))));
if (clamped === fromIndex) return false;
const [feature] = doc.features.splice(fromIndex, 1);
doc.features.splice(clamped, 0, feature);
api.document.save({
kind: 'micro',
opType: 'feature.move',
payload: { id: featureId, from: fromIndex, to: clamped }
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.move');
return true;
}
};
}
export { createFeaturesApi };

View file

@ -0,0 +1,98 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createGeometryStoreApi(getApi) {
return {
schemaVersion: 1,
state: null,
defaultState() {
return {
schema_version: this.schemaVersion,
surfaces: [],
boundaries: [],
segments: [],
points: [],
regions: [],
topology: {
surface_to_segments: {},
segment_to_surfaces: {}
},
meta: {
feature_count: 0,
generated_at: 0
}
};
},
normalize(raw) {
const base = this.defaultState();
const out = raw && typeof raw === 'object'
? { ...base, ...raw }
: base;
out.schema_version = Number(out.schema_version) || this.schemaVersion;
out.surfaces = Array.isArray(out.surfaces) ? out.surfaces : [];
out.boundaries = Array.isArray(out.boundaries) ? out.boundaries : [];
out.segments = Array.isArray(out.segments) ? out.segments : [];
out.points = Array.isArray(out.points) ? out.points : [];
out.regions = Array.isArray(out.regions) ? out.regions : [];
out.topology = out.topology && typeof out.topology === 'object'
? out.topology
: base.topology;
out.meta = out.meta && typeof out.meta === 'object'
? out.meta
: base.meta;
out.meta.feature_count = Number(out.meta.feature_count) || 0;
out.meta.generated_at = Number(out.meta.generated_at) || 0;
return out;
},
hydrate(docLike) {
const raw = docLike?.geometry_store || null;
this.state = this.normalize(raw);
return this.state;
},
attachToDocument(doc) {
if (!doc || typeof doc !== 'object') return null;
const state = this.normalize(doc.geometry_store);
doc.geometry_store = state;
this.state = state;
return state;
},
snapshot() {
const state = this.state || this.defaultState();
return JSON.parse(JSON.stringify(state));
},
seedFromDocument(doc) {
const api = getApi();
const state = this.attachToDocument(doc) || this.defaultState();
const features = Array.isArray(api.document?.current?.features)
? api.document.current.features
: [];
state.meta = state.meta || {};
state.meta.feature_count = features.length;
state.meta.generated_at = Date.now();
return state;
},
applySolidSnapshot(doc, snapshot = {}) {
const state = this.attachToDocument(doc) || this.defaultState();
state.surfaces = Array.isArray(snapshot.surfaces) ? snapshot.surfaces : [];
state.boundaries = Array.isArray(snapshot.boundaries) ? snapshot.boundaries : [];
state.segments = Array.isArray(snapshot.segments) ? snapshot.segments : [];
state.points = Array.isArray(snapshot.points) ? snapshot.points : [];
state.regions = Array.isArray(snapshot.regions) ? snapshot.regions : [];
state.topology = snapshot.topology && typeof snapshot.topology === 'object'
? snapshot.topology
: state.topology;
state.meta = state.meta || {};
state.meta.feature_count = Number(snapshot?.meta?.feature_count) || state.meta.feature_count || 0;
state.meta.generated_at = Date.now();
return state;
}
};
}
export { createGeometryStoreApi };

79
src/void/api/origin.js Normal file
View file

@ -0,0 +1,79 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createOriginApi(getApi) {
return {
state: { x: 0, y: 0, z: 0, show: true },
changeHandlers: new Set(),
defaultState() {
return { x: 0, y: 0, z: 0, show: true };
},
toJSON() {
const { x, y, z, show } = this.state;
return { x, y, z, show };
},
applyJSON(data = {}, notify = true) {
const next = {
x: data.x ?? 0,
y: data.y ?? 0,
z: data.z ?? 0,
show: data.show !== undefined ? !!data.show : true
};
this.state = next;
this.syncOverlayPoint();
if (notify) {
this.notifyChange();
}
return this.state;
},
isVisible() {
return !!this.state.show;
},
setVisible(visible) {
const next = !!visible;
if (this.state.show === next) {
return this.state;
}
this.applyJSON({ ...this.state, show: next }, true);
return this.state;
},
toggleVisible() {
return this.setVisible(!this.isVisible());
},
onChange(handler) {
if (typeof handler === 'function') {
this.changeHandlers.add(handler);
}
return this;
},
offChange(handler) {
this.changeHandlers.delete(handler);
return this;
},
notifyChange() {
for (const handler of this.changeHandlers) {
handler(this.state);
}
},
syncOverlayPoint() {
const api = getApi();
const item = api.overlay?.elements?.get('origin-point');
if (item?.el) {
item.opts = item.opts || {};
item.opts.hidden = !this.state.show;
item.el.style.display = this.state.show ? '' : 'none';
}
}
};
}
export { createOriginApi };

2651
src/void/api/solids.js Normal file

File diff suppressed because it is too large Load diff

382
src/void/datum.js Normal file
View file

@ -0,0 +1,382 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
import { Plane } from './plane.js';
const { Group } = THREE;
/**
* Datum - composed of three orthogonal plane primitives
* Provides the default coordinate system reference for modeling
*/
const datum = {
group: null, // THREE.Group containing all planes
planes: {}, // { xy, xz, yz } - Plane instances
size: 200, // Plane dimensions
visible: true,
overlay: null, // Overlay reference for label updates
labelHandlers: new Map(),
changeHandlers: new Set(),
/**
* Initialize datum with three orthogonal planes
*/
init(options = {}) {
this.size = options.size || 200;
this.group = new Group();
this.group.name = 'datum';
// Create three plane primitives with labels (like Onshape)
this.planes.xy = new Plane({
id: 'datum-xy',
name: 'XY Plane',
label: 'Top',
size: this.size
});
this.planes.xz = new Plane({
id: 'datum-xz',
name: 'XZ Plane',
label: 'Front',
size: this.size
});
this.planes.yz = new Plane({
id: 'datum-yz',
name: 'YZ Plane',
label: 'Right',
size: this.size
});
// Position XZ plane (vertical, front-back)
this.planes.xz.setRotation(Math.PI / 2, 0, 0);
// Position YZ plane (vertical, left-right)
this.planes.yz.setRotation(0, Math.PI / 2, 0);
// Add all plane groups to datum group
this.group.add(this.planes.xy.getGroup());
this.group.add(this.planes.xz.getGroup());
this.group.add(this.planes.yz.getGroup());
// Keep labels in sync with plane transform/size updates
for (const [key, plane] of Object.entries(this.planes)) {
const handler = () => {
if (this.overlay) {
this.updateLabel(this.overlay, key, plane);
}
};
this.labelHandlers.set(key, handler);
plane.onChange(handler);
}
// Set initial visibility
this.setVisible(options.visible !== undefined ? options.visible : true);
console.log({ datum_initialized: true, size: this.size, planes: 3 });
return this.group;
},
/**
* Get all plane primitives
*/
getPlanes() {
return Object.values(this.planes);
},
/**
* Update all plane labels in overlay (should be called by main init)
*/
updateLabels(overlay) {
if (!overlay) return;
this.overlay = overlay;
// Add or update labels for each plane
for (const [key, plane] of Object.entries(this.planes)) {
this.updateLabel(overlay, key, plane);
}
},
/**
* Update a single plane label in the overlay
*/
updateLabel(overlay, key, plane) {
const label = plane.getLabel();
if (!label) return;
const labelId = `datum-label-${key}`;
const corner = plane.getTopLeftCorner();
const hidden = !this.visible || !plane.getGroup()?.visible;
if (overlay.elements.has(labelId)) {
overlay.update(labelId, { pos3d: corner, text: label, hidden });
} else {
overlay.add(labelId, 'text', {
pos3d: corner,
text: label,
color: '#b0b0b0',
fontSize: 13,
anchor: 'start',
hidden,
className: 'datum-label'
});
}
},
/**
* Set size of all planes
*/
setSize(size) {
this.size = size;
for (const plane of Object.values(this.planes)) {
plane.setSize(size);
}
this.notifyChange();
},
/**
* Set visibility of all planes
*/
setVisible(visible) {
this.visible = visible;
if (this.group) {
this.group.visible = visible;
}
this.notifyChange();
},
/**
* Show specific plane
*/
show(planeName) {
if (this.planes[planeName]) {
this.planes[planeName].setVisible(true);
} else {
console.warn(`datum: unknown plane ${planeName}`);
}
},
/**
* Hide specific plane
*/
hide(planeName) {
if (this.planes[planeName]) {
this.planes[planeName].setVisible(false);
} else {
console.warn(`datum: unknown plane ${planeName}`);
}
},
/**
* Set opacity of all planes
*/
setOpacity(opacity) {
for (const plane of Object.values(this.planes)) {
plane.setOpacity(opacity);
}
},
/**
* Set color of specific plane
*/
setColor(planeName, color) {
if (this.planes[planeName]) {
this.planes[planeName].setColor(color);
} else {
console.warn(`datum: unknown plane ${planeName}`);
}
},
/**
* Set outline color of specific plane
*/
setOutlineColor(planeName, color) {
if (this.planes[planeName]) {
this.planes[planeName].setOutlineColor(color);
} else {
console.warn(`datum: unknown plane ${planeName}`);
}
},
/**
* Get plane primitive by name
*/
getPlane(planeName) {
return this.planes[planeName] || null;
},
/**
* Serialize datum to JSON
*/
toJSON() {
return {
type: 'datum',
size: this.size,
visible: this.visible,
planes: {
xy: this.planes.xy.toJSON(),
xz: this.planes.xz.toJSON(),
yz: this.planes.yz.toJSON()
}
};
},
/**
* Canonical default datum state for new documents.
*/
defaultState(size = 200) {
return {
type: 'datum',
size,
visible: true,
planes: {
xy: {
id: 'datum-xy',
name: 'XY Plane',
label: 'Top',
type: 'plane',
size: { width: size, height: size },
visible: true,
frame: {
origin: { x: 0, y: 0, z: 0 },
normal: { x: 0, y: 0, z: 1 },
x_axis: { x: 1, y: 0, z: 0 },
size: { width: size, height: size }
}
},
xz: {
id: 'datum-xz',
name: 'XZ Plane',
label: 'Front',
type: 'plane',
size: { width: size, height: size },
visible: true,
frame: {
origin: { x: 0, y: 0, z: 0 },
normal: { x: 0, y: -1, z: 0 },
x_axis: { x: 1, y: 0, z: 0 },
size: { width: size, height: size }
}
},
yz: {
id: 'datum-yz',
name: 'YZ Plane',
label: 'Right',
type: 'plane',
size: { width: size, height: size },
visible: true,
frame: {
origin: { x: 0, y: 0, z: 0 },
normal: { x: 1, y: 0, z: 0 },
x_axis: { x: 0, y: 1, z: 0 },
size: { width: size, height: size }
}
}
}
};
},
/**
* Apply serialized datum state onto existing runtime planes.
*/
applyJSON(data) {
if (!data || !this.planes) return;
if (typeof data.size === 'number') {
this.size = data.size;
}
if (typeof data.visible === 'boolean') {
this.setVisible(data.visible);
}
const planeData = data.planes || {};
for (const [key, plane] of Object.entries(this.planes)) {
const src = planeData[key];
if (!src) continue;
if (src.name !== undefined) plane.name = src.name;
if (src.label !== undefined) plane.setLabel(src.label);
if (src.color !== undefined) plane.setColor(src.color);
if (src.outlineColor !== undefined) plane.setOutlineColor(src.outlineColor);
if (src.opacity !== undefined) plane.setOpacity(src.opacity);
if (src.outlineOpacity !== undefined) plane.setOutlineOpacity(src.outlineOpacity);
if (src.showHandles !== undefined) plane.showHandles = src.showHandles;
if (src.visible !== undefined) plane.setVisible(src.visible);
if (src.frame) {
plane.setFrame(src.frame);
} else {
// Legacy fallback for older document data.
const width = src?.size?.width !== undefined ? src.size.width : (src.size !== undefined ? src.size : plane.size);
const height = src?.size?.height !== undefined ? src.size.height : (src.height !== undefined ? src.height : undefined);
plane.setSize(width, height);
if (src.position) {
plane.setPosition(
src.position.x || 0,
src.position.y || 0,
src.position.z || 0
);
}
if (src.rotation) {
plane.setRotation(
src.rotation.x || 0,
src.rotation.y || 0,
src.rotation.z || 0
);
}
}
}
this.notifyChange();
},
/**
* Register callback for datum-level changes.
*/
onChange(handler) {
if (typeof handler === 'function') {
this.changeHandlers.add(handler);
}
return this;
},
/**
* Remove registered datum-level callback.
*/
offChange(handler) {
this.changeHandlers.delete(handler);
return this;
},
/**
* Notify listeners datum changed.
*/
notifyChange() {
for (const handler of this.changeHandlers) {
handler(this);
}
},
/**
* Dispose of all resources
*/
dispose() {
if (this.group) {
for (const [key, plane] of Object.entries(this.planes)) {
const handler = this.labelHandlers.get(key);
if (handler) {
plane.offChange(handler);
}
plane.dispose();
this.group.remove(plane.getGroup());
}
this.planes = {};
this.labelHandlers.clear();
this.overlay = null;
this.group = null;
}
}
};
export { datum };

369
src/void/interact.js Normal file
View file

@ -0,0 +1,369 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
import { space } from '../moto/space.js';
import { datum } from './datum.js';
import { api } from './api.js';
import { properties } from './properties.js';
import * as targetOps from './interact/targets.js';
import * as pointOps from './interact/points.js';
import * as selectionOps from './interact/selection.js';
import * as planeOps from './interact/planes.js';
import * as sketchOps from './sketch/index.js';
/**
* Interaction manager for void:form primitives
* Handles selection, hover, and dragging behaviors
*/
const interact = {
selectedPlanes: new Set(),
hoveredPlane: null,
selectedPoints: new Set(),
hoveredPoint: null,
draggedHandle: null,
draggedPlane: null,
dragHandleName: null,
dragAnchorPos: null,
dragStartSizes: new Map(),
dragStartCenters: new Map(),
planes: [],
upSelectCalled: false,
wasHandleDrag: false,
hoverIntersection: null,
handleScreenRadiusPx: 7,
handleBaseRadius: 4,
pointHitRadiusPx: 10,
pointIds: ['origin-point'],
_tmpWorldPos: new THREE.Vector3(),
sketchTool: 'select',
selectedSketchEntities: new Set(),
selectedSketchProfiles: new Set(),
selectedSolidFaceKeys: new Set(),
selectedSolidEdgeKeys: new Set(),
selectedSketchArcCenters: new Set(),
selectedSketchConstraints: new Set(),
hoveredSketchEntityId: null,
hoveredDerivedCandidate: null,
selectedDerivedSelections: new Map(),
hoveredSketchProfileKey: null,
hoveredSolidFaceKey: null,
hoveredSolidEdgeKey: null,
hoveredSketchConstraintId: null,
sketchPointerDown: null,
sketchDrag: null,
sketchLineStart: null,
sketchLineStartRefId: null,
sketchLineStartSeq: null,
sketchArcStart: null,
sketchArcStartRefId: null,
sketchArcEnd: null,
sketchArcEndRefId: null,
sketchArcPreview: null,
sketchCircleCenter: null,
sketchCircleSecond: null,
sketchCircleCenterRefId: null,
sketchCircleSecondRefId: null,
sketchCircleStartSeq: null,
sketchRectStart: null,
sketchRectStartRefId: null,
sketchRectStartSeq: null,
sketchRectPreview: null,
sketchRectCenterMode: false,
sketchMirrorMode: false,
sketchMirrorAxisId: null,
sketchCircularPatternMode: false,
sketchCircularPatternCenterRef: null,
sketchGridPatternMode: false,
sketchGridPatternCenterRef: null,
sketchPointerSeq: 0,
sketchMarquee: null,
sketchMarqueeEl: null,
_lastSketchDownStamp: null,
_lastSketchUpStamp: null,
_skipNextWindowSketchDown: false,
_skipNextWindowSketchUp: false,
_focusRaycaster: new THREE.Raycaster(),
_focusNDC: new THREE.Vector2(),
focusTweenMs: 120,
isSketchRetargetMode() {
if (!(this.isSketchEditing && this.isSketchEditing())) return false;
const featureId = properties.currentFeatureId || null;
if (!featureId) return false;
const feature = api.features?.findById?.(featureId);
if (!feature || feature.type !== 'sketch') return false;
const source = feature?.target?.source || null;
return !(source && source.type);
},
optionFocusOnDown(event) {
if (!event || event.button !== 2 || !event.altKey) {
return false;
}
const { camera, renderer } = space.internals();
const canvas = renderer?.domElement || null;
if (!camera || !canvas) {
return false;
}
const rect = canvas.getBoundingClientRect();
if (!rect.width || !rect.height) {
return false;
}
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
this._focusNDC.set(x, y);
this._focusRaycaster.setFromCamera(this._focusNDC, camera);
const hits = this._focusRaycaster.intersectObjects(space.objects(), true);
if (!hits?.length) {
return false;
}
const hit = hits.find(rec => rec?.object?.visible !== false) || hits[0];
const point = hit?.point;
if (!point) {
return false;
}
space.view.panTo(point.x, point.y, point.z, undefined, undefined, this.focusTweenMs);
return true;
},
init() {
this.planes = datum.getPlanes();
window.addEventListener('keydown', event => {
if (space.isFocused()) {
return false;
}
if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.code === 'KeyF') {
space.view.fit(null, { tween: true });
event.preventDefault();
return true;
}
let handled = false;
switch (event.code) {
case 'Space':
{
const currentFeatureId = properties.currentFeatureId || null;
const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null;
const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId;
if (editingChamfer) {
handled = true;
break;
}
this.deselectAll();
}
handled = true;
break;
case 'KeyN':
handled = this.viewNormalToHover();
break;
case 'KeyP':
handled = this.toggleDatumPlanesVisibility();
break;
default:
handled = this.handleSketchKeyDown(event);
break;
}
if (handled) {
event.preventDefault();
}
});
window.addEventListener('mousemove', event => {
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
this.handleSketchPointerMove?.(event);
}
});
space.mouse.down((event) => {
this.optionFocusOnDown(event);
});
space.mouse.downSelect((int, event, ints) => {
if (event && event.button !== 0) {
return;
}
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
if (!int && int !== null) {
return this.getInteractiveObjects();
}
this._lastSketchDownStamp = event?.timeStamp ?? null;
this._skipNextWindowSketchDown = true;
this.handleSketchPointerDown(event, ints);
return;
}
if (!int && int !== null) {
return this.getInteractiveObjects();
}
let targetInt = int;
if (ints && ints.length > 0) {
const handleInt = ints.find(hit => {
const handleType = hit?.object?.userData?.handleType;
const plane = hit?.object?.userData?.plane;
return handleType === 'plane-resize' && plane && this.selectedPlanes.has(plane);
});
if (handleInt) {
targetInt = handleInt;
}
}
const obj = targetInt?.object;
const handleType = obj?.userData?.handleType;
if (handleType === 'plane-resize') {
this.startHandleDrag(obj, targetInt, event);
}
});
space.mouse.upSelect((int, event, ints) => {
if (event && event.button !== 0) {
return;
}
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
// Query phase from space.js: return selectable objects only.
if (!event && int === undefined) {
return this.getInteractiveObjects();
}
this.upSelectCalled = true;
this._skipNextWindowSketchUp = true;
this.handleSketchMouseUp(event, ints);
this._lastSketchUpStamp = event?.timeStamp ?? null;
this.sketchPointerDown = null;
this.wasHandleDrag = false;
return;
}
if (!int && int !== null) {
this.wasHandleDrag = false;
return this.getInteractiveObjects();
}
this.upSelectCalled = true;
if (!this.wasHandleDrag) {
this.handleMouseUp(int, event, ints);
}
this.wasHandleDrag = false;
});
space.mouse.up((event, ints) => {
if (event && event.button !== 0) {
return;
}
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
// sketch completion is handled by mouseUpSelect (preferred)
// or window mouseup fallback when mouseUpSelect is skipped.
return;
}
if (!this.upSelectCalled && !this.draggedHandle && !this.wasHandleDrag && ints && ints.length > 0) {
this.handleMouseUp(ints[0], event, ints);
}
this.upSelectCalled = false;
});
window.addEventListener('mouseup', event => {
if (event && event.button !== 0) {
return;
}
if (!this.isSketchEditing() || this.isSketchRetargetMode()) {
return;
}
if (this._skipNextWindowSketchUp) {
this._skipNextWindowSketchUp = false;
this.upSelectCalled = false;
return;
}
if (event?.timeStamp && this._lastSketchUpStamp === event.timeStamp) {
this.upSelectCalled = false;
return;
}
// When space.js doesn't emit up/upSelect (empty selection array),
// complete the sketch interaction here.
if (!this.upSelectCalled && this.sketchPointerDown) {
this.handleSketchMouseUp(event);
this._lastSketchUpStamp = event?.timeStamp ?? null;
this.sketchPointerDown = null;
}
this.upSelectCalled = false;
});
window.addEventListener('mousedown', event => {
if (event && event.button !== 0) {
return;
}
if (!this.isSketchEditing() || this.isSketchRetargetMode()) {
return;
}
if (this._skipNextWindowSketchDown) {
this._skipNextWindowSketchDown = false;
return;
}
if (this._lastSketchDownStamp !== null && event.timeStamp === this._lastSketchDownStamp) {
return;
}
const { container } = space.internals();
if (!container || !container.contains(event.target)) {
return;
}
this.handleSketchPointerDown(event);
});
space.mouse.onHover((int, event, ints) => {
if (!int && int !== null) {
return this.getInteractiveObjects();
}
this.handleHover(int, event, ints);
if (this.isSketchEditing()) {
this.handleSketchHover(event, ints);
}
}, () => {
this.handleHover();
});
space.mouse.onDrag((delta, offset, isDone, intersections) => {
if (delta === undefined) {
if (this.draggedHandle) {
return [];
}
if (this.isSketchEditing()) {
return [];
}
return null;
}
if (this.isSketchEditing()) {
this.handleSketchDrag(delta, offset, isDone, intersections);
if (isDone) {
this.sketchPointerDown = null;
}
return;
}
if (isDone && this.draggedHandle) {
this.wasHandleDrag = true;
this.draggedHandle = null;
this.draggedPlane = null;
this.dragHandleName = null;
this.dragAnchorPos = null;
this.dragStartSizes.clear();
this.dragStartCenters.clear();
return;
}
if (isDone && !this.draggedHandle && offset) {
const offsetMag = Math.sqrt(offset.x * offset.x + offset.y * offset.y);
if (offsetMag < 5) {
if (intersections && intersections.length > 0) {
this.handleMouseUp(intersections[0], { ctrlKey: false, metaKey: false }, intersections);
}
}
return;
}
this.handleDrag(delta, offset, isDone, intersections);
});
this.setupHandleScaleHooks();
this.updateHandleScreenScales();
}
};
Object.assign(interact, pointOps);
Object.assign(interact, selectionOps);
Object.assign(interact, planeOps);
Object.assign(interact, targetOps);
Object.assign(interact, sketchOps);
export { interact };

1338
src/void/interact/planes.js Normal file

File diff suppressed because it is too large Load diff

137
src/void/interact/points.js Normal file
View file

@ -0,0 +1,137 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { space } from '../../moto/space.js';
import { overlay } from '../overlay.js';
import { api } from '../api.js';
function getPointHitFromEvent(event) {
if (!event || !overlay?.elements) {
return null;
}
const { container } = space.internals();
if (!container) {
return null;
}
const rect = container.getBoundingClientRect();
const ex = event.clientX - rect.left;
const ey = event.clientY - rect.top;
let best = null;
for (const id of this.pointIds) {
const item = overlay.elements.get(id);
if (!item || item.type !== 'point' || !item.pos3d || item.opts?.hidden) {
continue;
}
const proj = overlay.project3Dto2D(item.pos3d);
if (!proj || !proj.visible) {
continue;
}
const dx = ex - proj.x;
const dy = ey - proj.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > this.pointHitRadiusPx) {
continue;
}
if (!best || dist < best.dist) {
best = { id, item, dist };
}
}
return best;
}
function setHoveredPoint(id) {
if (this.hoveredPoint === id) {
return;
}
const prev = this.hoveredPoint;
this.hoveredPoint = id;
if (prev) {
this.applyPointAppearance(prev);
}
if (id) {
this.applyPointAppearance(id);
}
}
function clearSelectedPoints() {
if (!this.selectedPoints.size) {
return;
}
const ids = Array.from(this.selectedPoints);
this.selectedPoints.clear();
for (const id of ids) {
this.applyPointAppearance(id);
}
}
function selectPoint(id, event) {
const multiSelect = event && (event.ctrlKey || event.metaKey);
if (!multiSelect) {
// Points are selected like planes: single-select clears prior selection.
for (const plane of this.selectedPlanes) {
plane.setSelected(false);
}
this.selectedPlanes.clear();
this.selectedSolidFaceKeys?.clear?.();
this.selectedSolidEdgeKeys?.clear?.();
this.hoveredSolidFaceKey = null;
this.hoveredSolidEdgeKey = null;
this.selectedSketchProfiles?.clear?.();
this.hoveredSketchProfileKey = null;
this.clearSketchSelection?.();
this.cancelSketchLine?.();
this.setSketchTool?.('select');
api.solids?.clearFaceSelection?.();
api.solids?.clearEdgeSelection?.();
this.clearSelectedPoints();
this.selectedPoints.add(id);
} else {
if (this.selectedPoints.has(id)) {
this.selectedPoints.delete(id);
} else {
this.selectedPoints.add(id);
}
}
this.applyPointAppearance(id);
this.updateHandleScreenScales();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
function applyPointAppearance(id) {
const item = overlay?.elements?.get(id);
if (!item?.el) {
return;
}
if (item.opts?.hidden) {
item.el.style.display = 'none';
return;
}
const isSelected = this.selectedPoints.has(id);
const isHovered = this.hoveredPoint === id;
const defaultFill = 'rgba(140, 140, 140, 0.45)';
const defaultStroke = '#5a9fd4';
const hoverStroke = '#ff9933';
const fill = isSelected ? 'rgba(160, 160, 160, 0.6)' : defaultFill;
const stroke = (isSelected || isHovered) ? hoverStroke : defaultStroke;
const strokeWidth = isSelected ? 2.5 : (isHovered ? 2.2 : 2);
item.el.setAttribute('fill', fill);
item.el.setAttribute('stroke', stroke);
item.el.setAttribute('stroke-width', String(strokeWidth));
}
export {
getPointHitFromEvent,
setHoveredPoint,
clearSelectedPoints,
selectPoint,
applyPointAppearance
};

View file

@ -0,0 +1,134 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { space } from '../../moto/space.js';
import { api } from '../api.js';
import { properties } from '../properties.js';
function selectPlane(plane, event) {
const multiSelect = event && (event.ctrlKey || event.metaKey);
const currentFeatureId = properties.currentFeatureId || null;
const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null;
const editingSketch = currentFeature?.type === 'sketch' && currentFeature?.id === currentFeatureId;
if (multiSelect) {
// Toggle selection with Ctrl/Cmd
if (this.selectedPlanes.has(plane)) {
// Already selected - deselect it
plane.setSelected(false);
this.selectedPlanes.delete(plane);
} else {
// Not selected - add to selection
plane.setSelected(true);
plane.setHovered(false);
this.selectedPlanes.add(plane);
}
} else {
// Single select - deselect all others
for (const selectedPlane of this.selectedPlanes) {
if (selectedPlane !== plane) {
selectedPlane.setSelected(false);
}
}
this.selectedPlanes.clear();
this.clearSelectedPoints();
this.selectedSketchProfiles?.clear?.();
this.selectedSolidFaceKeys?.clear?.();
this.selectedSolidEdgeKeys?.clear?.();
this.hoveredSolidFaceKey = null;
this.hoveredSolidEdgeKey = null;
api.solids?.clearFaceSelection?.();
api.solids?.clearEdgeSelection?.();
api.sketchRuntime?.setSelectedProfiles?.([]);
api.sketchRuntime?.setHoveredProfile?.(null);
// Select the new plane
plane.setSelected(true);
plane.setHovered(false);
this.selectedPlanes.add(plane);
}
this.updateHandleScreenScales();
if (editingSketch && plane?.getFrame) {
const updated = api.features.update(currentFeature.id, feature => {
const offset = Number(feature?.target?.offset || 0);
feature.target = feature.target || {};
feature.target.kind = 'plane';
feature.target.id = plane.id || null;
feature.target.name = plane.name || plane.label || 'Plane';
feature.target.label = plane.label || null;
feature.target.source = { type: 'plane', id: plane.id || null };
feature.target.offset = offset;
const frame = plane.getFrame();
feature.plane = api.solids?.applyOffsetToFrame?.(frame, offset) || frame;
}, {
opType: 'feature.update',
payload: { field: 'target.plane', id: plane?.id || null }
});
if (updated) {
properties.onChanged?.();
}
}
window.dispatchEvent(new CustomEvent('void-state-change'));
}
function isEventInsideViewport(event) {
if (!event) return true;
const { container } = space.internals();
if (!container) return true;
const target = event.target;
if (!target) return true;
return container.contains(target);
}
function deselectAll() {
for (const plane of this.selectedPlanes) {
plane.setSelected(false);
}
this.selectedPlanes.clear();
if (this.hoveredPlane) {
this.hoveredPlane.setHovered(false);
this.hoveredPlane = null;
}
this.setHoveredPoint(null);
this.clearSelectedPoints();
this.selectedSketchProfiles?.clear?.();
this.hoveredSketchProfileKey = null;
this.selectedSolidFaceKeys?.clear?.();
this.selectedSolidEdgeKeys?.clear?.();
this.hoveredSolidFaceKey = null;
this.hoveredSolidEdgeKey = null;
api.solids?.clearFaceSelection?.();
api.solids?.clearEdgeSelection?.();
api.sketchRuntime?.setSelectedProfiles?.([]);
api.sketchRuntime?.setHoveredProfile?.(null);
this.clearSketchSelection?.();
this.cancelSketchLine?.();
this.stopSketchMirrorMode?.();
this.stopSketchCircularPatternMode?.();
this.stopSketchGridPatternMode?.();
this.setSketchTool?.('select');
this.sketchPointerDown = null;
this.sketchDrag = null;
if (api?.sketchRuntime) {
api.sketchRuntime._glyphDrag = null;
}
this.updateHandleScreenScales();
window.dispatchEvent(new CustomEvent('void-clear-selection'));
window.dispatchEvent(new CustomEvent('void-state-change'));
}
function getSelected() {
return this.selectedPlanes;
}
function isSelected(plane) {
return this.selectedPlanes.has(plane);
}
export {
selectPlane,
isEventInsideViewport,
deselectAll,
getSelected,
isSelected
};

View file

@ -0,0 +1,185 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../../ext/three.js';
const DEFAULTS = Object.freeze({
sketchFaceEpsilon: 0.25,
edgeGateDistance: 2.5
});
const SELECTION_INTENTS = Object.freeze({
profile: 'profile',
solidFace: 'solid-face',
solidEdge: 'solid-edge',
point: 'point',
segment: 'segment',
boundary: 'boundary',
surface: 'surface',
region: 'region'
});
const SELECTION_MODES = Object.freeze({
sketch: 'sketch',
sketchRetarget: 'sketch-retarget',
extrudeProfiles: 'extrude-profiles',
solid: 'solid'
});
function resolvePrimarySurfaceHit(intersections, options = {}) {
if (!Array.isArray(intersections)) return null;
const {
api = null,
retargetMode = false,
editingExtrudeProfiles = false,
sketchFaceEpsilon = DEFAULTS.sketchFaceEpsilon,
edgeGateDistance = DEFAULTS.edgeGateDistance
} = options;
let nearestProfile = null;
let nearestSolidFace = null;
for (const hit of intersections) {
const obj = hit?.object;
if (!obj) continue;
const profileId = obj.userData?.sketchProfileId || null;
const featureId = obj.userData?.sketchFeatureId || null;
if (!retargetMode && !nearestProfile && profileId && featureId) {
nearestProfile = {
type: 'profile',
distance: Number(hit?.distance) || 0,
hit: { featureId, profileId, object: obj, intersection: hit },
entity: {
kind: 'region',
id: `profile:${featureId}:${profileId}`
}
};
}
if (!nearestSolidFace) {
const solidFaceHit = api?.solids?.getFaceHitFromIntersections?.([hit]);
if (solidFaceHit) {
const faceEntity = api?.solids?.resolveCanonicalFaceEntity?.(solidFaceHit.key) || null;
nearestSolidFace = {
type: 'solid-face',
distance: Number(hit?.distance) || 0,
hit: solidFaceHit,
entity: faceEntity || null
};
}
}
}
let nearestSolidEdge = null;
if (nearestSolidFace?.hit?.key && nearestSolidFace?.hit?.intersection?.point) {
// Primary path: resolve boundary from the currently hovered face itself.
// This keeps edge picks aligned with face boundaries and avoids cross-face
// edge steals (e.g. cylinder seam lines).
const edge = api?.solids?.getFaceEdgeHit?.(
nearestSolidFace.hit.key,
nearestSolidFace.hit.intersection.point,
edgeGateDistance
);
if (edge) {
nearestSolidEdge = {
type: 'solid-edge',
// Slightly prefer edge over owning face when near boundary.
distance: Math.max(0, (nearestSolidFace.distance || 0) - 1e-4),
hit: {
...edge,
intersection: nearestSolidFace.hit.intersection
},
entity: api?.solids?.resolveCanonicalEdgeEntity?.(edge.key) || null
};
}
}
if (!nearestSolidEdge && nearestSolidFace?.hit?.solidId) {
// Fallback: use rendered edge intersections on the same solid.
const sameSolidEdgeInts = intersections.filter(hit => {
const obj = hit?.object;
return obj?.userData?.solidEdge === true
&& String(obj?.userData?.solidId || '') === String(nearestSolidFace.hit.solidId || '');
});
if (sameSolidEdgeInts.length) {
const edgeHit = api?.solids?.getEdgeHitFromIntersections?.(sameSolidEdgeInts) || null;
if (edgeHit?.aWorld && edgeHit?.bWorld) {
const hitEdge = {
key: `${edgeHit.solidId}:${edgeHit.index}`,
solidId: edgeHit.solidId,
index: edgeHit.index,
aWorld: edgeHit.aWorld,
bWorld: edgeHit.bWorld,
midWorld: edgeHit.midWorld
};
nearestSolidEdge = {
type: 'solid-edge',
distance: Number(edgeHit?.intersection?.distance) || Math.max(0, (nearestSolidFace.distance || 0) - 1e-4),
hit: {
...hitEdge,
intersection: edgeHit.intersection || nearestSolidFace.hit.intersection
},
entity: api?.solids?.resolveCanonicalEdgeEntity?.(hitEdge.key) || null
};
}
}
}
if (editingExtrudeProfiles) {
return nearestProfile || null;
}
if (nearestProfile && nearestSolidEdge && nearestSolidFace) {
return [nearestProfile, nearestSolidEdge, nearestSolidFace]
.sort((a, b) => a.distance - b.distance)[0];
}
if (nearestProfile && nearestSolidEdge) {
const delta = nearestProfile.distance - nearestSolidEdge.distance;
if (delta <= sketchFaceEpsilon) return nearestProfile;
return nearestSolidEdge;
}
if (nearestProfile && nearestSolidFace) {
const delta = nearestProfile.distance - nearestSolidFace.distance;
if (delta <= sketchFaceEpsilon) {
return nearestProfile;
}
return nearestSolidFace;
}
if (nearestSolidEdge && nearestSolidFace) {
return nearestSolidEdge.distance <= nearestSolidFace.distance + sketchFaceEpsilon
? nearestSolidEdge
: nearestSolidFace;
}
if (nearestProfile) return nearestProfile;
if (nearestSolidEdge) return nearestSolidEdge;
if (nearestSolidFace) return nearestSolidFace;
return null;
}
function resolveSelectionCandidate(intersections, options = {}) {
const mode = options.mode || SELECTION_MODES.solid;
const intents = new Set(Array.isArray(options.intents) ? options.intents : []);
// Phase 1 parity routing:
// keep current behavior, but route through explicit mode/intent context.
if (mode === SELECTION_MODES.extrudeProfiles) {
return resolvePrimarySurfaceHit(intersections, {
...options,
editingExtrudeProfiles: true
});
}
if (intents.size === 0
|| intents.has(SELECTION_INTENTS.profile)
|| intents.has(SELECTION_INTENTS.solidFace)
|| intents.has(SELECTION_INTENTS.solidEdge)) {
return resolvePrimarySurfaceHit(intersections, options);
}
return null;
}
export {
SELECTION_INTENTS,
SELECTION_MODES,
resolvePrimarySurfaceHit,
resolveSelectionCandidate
};

View file

@ -0,0 +1,149 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../../ext/three.js';
import { api } from '../api.js';
function getPrimarySketchTarget() {
return this.resolveSketchTarget(this.hoverIntersection) || this.resolveSketchTargetFromSelection();
}
function resolveSketchTargetFromSelection() {
const selectedFaceKeys = api.solids?.getSelectedFaceKeys?.() || Array.from(this.selectedSolidFaceKeys || []);
const selectedFaceCount = selectedFaceKeys.length;
if (selectedFaceCount > 0) {
if (selectedFaceCount === 1) {
const key = selectedFaceKeys[0];
const target = api.solids?.getSketchTargetForFaceKey?.(key);
if (target?.frame) {
return target;
}
}
// If any solid face is selected but it's not a valid sketch target (for now: non-planar),
// do not silently fall back to a datum plane.
return null;
}
if (this.selectedPlanes.size !== 1) {
return null;
}
const plane = this.selectedPlanes.values().next().value;
return this.sketchTargetFromPlane(plane);
}
function resolveSketchTarget(intersection) {
const object = intersection?.object;
if (!object) return null;
const resolver = object.userData?.sketchTargetResolver;
if (typeof resolver === 'function') {
const resolved = resolver({ intersection, object });
if (resolved?.frame) {
return resolved;
}
}
const plane = object.userData?.plane;
if (plane) {
return this.sketchTargetFromPlane(plane);
}
if (intersection.face && object.geometry) {
return this.sketchTargetFromFace(intersection, object);
}
return null;
}
function sketchTargetFromPlane(plane) {
if (!plane || typeof plane.getFrame !== 'function') {
return null;
}
return {
kind: 'plane',
id: plane.id,
name: plane.name || plane.label || 'Plane',
label: plane.label || null,
frame: plane.getFrame(),
source: {
type: 'plane',
id: plane.id
}
};
}
function sketchTargetFromFace(intersection, object) {
if (!intersection?.face || !object) {
return null;
}
object.updateMatrixWorld(true);
const normal = intersection.face.normal.clone().transformDirection(object.matrixWorld).normalize();
const center = this.getFaceCenterWorld(intersection, object) || intersection.point?.clone();
if (!center) return null;
const xAxis = this.getFaceXAxisWorld(intersection, object, normal);
if (!xAxis) return null;
const faceIndex = intersection.faceIndex ?? -1;
return {
kind: 'face',
id: `${object.uuid}:f${faceIndex}`,
name: 'Face',
frame: this.makeFrame(center, normal, xAxis),
source: {
type: 'face',
object_id: object.uuid,
face_index: faceIndex
}
};
}
function makeFrame(origin, normal, xAxis) {
return {
origin: { x: origin.x, y: origin.y, z: origin.z },
normal: { x: normal.x, y: normal.y, z: normal.z },
x_axis: { x: xAxis.x, y: xAxis.y, z: xAxis.z }
};
}
function getFaceXAxisWorld(intersection, object, normal) {
const geom = object.geometry;
const face = intersection.face;
const pos = geom?.attributes?.position;
if (!face || !pos) {
return this.projectAxisOnPlane(new THREE.Vector3(1, 0, 0), normal);
}
const a = new THREE.Vector3().fromBufferAttribute(pos, face.a).applyMatrix4(object.matrixWorld);
const b = new THREE.Vector3().fromBufferAttribute(pos, face.b).applyMatrix4(object.matrixWorld);
const edge = b.sub(a);
if (edge.lengthSq() < 1e-12) {
return this.projectAxisOnPlane(new THREE.Vector3(1, 0, 0), normal);
}
return this.projectAxisOnPlane(edge, normal);
}
function projectAxisOnPlane(axis, normal) {
const out = axis.clone().addScaledVector(normal, -axis.dot(normal));
if (out.lengthSq() < 1e-12) {
const fallback = Math.abs(normal.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
fallback.addScaledVector(normal, -fallback.dot(normal));
if (fallback.lengthSq() < 1e-12) {
return null;
}
return fallback.normalize();
}
return out.normalize();
}
export {
getPrimarySketchTarget,
resolveSketchTargetFromSelection,
resolveSketchTarget,
sketchTargetFromPlane,
sketchTargetFromFace,
makeFrame,
getFaceXAxisWorld,
projectAxisOnPlane
};

386
src/void/overlay.js Normal file
View file

@ -0,0 +1,386 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../moto/webui.js';
import { space } from '../moto/space.js';
// SVG namespace
const SVG_NS = 'http://www.w3.org/2000/svg';
// 2D overlay system for tracking 3D points
const overlay = {
container: null, // Reference to #sketch-overlay div
svg: null, // SVG element
elements: new Map(), // Map<id, {el, pos3d, type, opts}>
enabled: true,
camera: null,
renderer: null,
/**
* Initialize overlay system
*/
init() {
this.container = $('sketch-overlay');
if (!this.container) {
console.error('overlay: sketch-overlay element not found');
return;
}
// Get camera and renderer from space
const internals = space.internals();
this.camera = internals.camera;
this.renderer = internals.renderer;
// External update callback (for datum labels, etc.)
this.onUpdate = null;
// Create SVG element
this.svg = document.createElementNS(SVG_NS, 'svg');
this.svg.style.width = '100%';
this.svg.style.height = '100%';
this.svg.style.position = 'absolute';
this.svg.style.top = '0';
this.svg.style.left = '0';
this.svg.style.pointerEvents = 'none';
// Don't set viewBox - let it use default user coordinates
this.container.appendChild(this.svg);
// Hook into camera movement for updates
this.setupCameraHook();
// Handle window resize
window.addEventListener('resize', () => {
this.updateAll();
});
console.log({ overlay_initialized: true });
},
/**
* Setup camera movement hook with debouncing
*/
setupCameraHook() {
// Rebind when projection/control object changes.
if (this.viewCtrl && this.onViewChange) {
this.viewCtrl.removeEventListener('change', this.onViewChange);
}
const viewCtrl = this.viewCtrl = space.view.ctrl;
if (viewCtrl && viewCtrl.addEventListener) {
let updateQueued = false;
this.onViewChange = () => {
if (this.enabled && !updateQueued) {
updateQueued = true;
requestAnimationFrame(() => {
this.updateAll();
updateQueued = false;
});
}
};
viewCtrl.addEventListener('change', this.onViewChange);
}
},
/**
* Called when camera/control internals are recreated (e.g. projection toggle).
*/
onProjectionChanged() {
const internals = space.internals();
this.camera = internals.camera;
this.renderer = internals.renderer;
this.setupCameraHook();
this.updateAll();
},
/**
* Project 3D world position to 2D screen coordinates
*/
project3Dto2D(worldPos) {
if (!this.camera || !this.renderer) return null;
const vector = worldPos.clone();
vector.project(this.camera);
// Use canvas client dimensions for accurate projection
const canvas = this.renderer.domElement;
const x = (vector.x + 1) / 2 * canvas.clientWidth;
const y = -(vector.y - 1) / 2 * canvas.clientHeight;
const z = vector.z; // For depth testing (z > 1 = behind camera)
return { x, y, z, visible: z < 1 };
},
/**
* Add overlay element
* @param {string} id - Unique identifier
* @param {string} type - Element type: 'point', 'text', 'line', 'path'
* @param {object} opts - Options specific to type
* @returns {SVGElement} Created element
*/
add(id, type, opts = {}) {
if (this.elements.has(id)) {
console.warn(`overlay: element ${id} already exists`);
return this.elements.get(id).el;
}
let el;
switch (type) {
case 'point':
el = this.createPoint(opts);
break;
case 'text':
el = this.createText(opts);
break;
case 'line':
el = this.createLine(opts);
break;
case 'path':
el = this.createPath(opts);
break;
default:
console.error(`overlay: unknown type ${type}`);
return null;
}
el.setAttribute('data-overlay-id', id);
this.svg.appendChild(el);
this.elements.set(id, {
el,
type,
pos3d: opts.pos3d || null,
pos3d2: opts.pos3d2 || null, // For lines
opts
});
// Initial projection
this.updateElement(id);
return el;
},
/**
* Create point element (circle)
*/
createPoint(opts) {
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('r', opts.radius || 4);
circle.setAttribute('fill', opts.color || '#5a9fd4');
circle.setAttribute('stroke', opts.stroke || 'none');
circle.setAttribute('stroke-width', opts.strokeWidth || 1);
if (opts.className) {
circle.setAttribute('class', opts.className);
}
return circle;
},
/**
* Create text element
*/
createText(opts) {
const text = document.createElementNS(SVG_NS, 'text');
text.textContent = opts.text || '';
text.setAttribute('fill', opts.color || '#e0e0e0');
text.setAttribute('font-size', opts.fontSize || 12);
text.setAttribute('font-family', opts.fontFamily || 'sans-serif');
text.setAttribute('text-anchor', opts.anchor || 'middle');
text.setAttribute('dominant-baseline', 'middle');
if (opts.className) {
text.setAttribute('class', opts.className);
}
return text;
},
/**
* Create line element
*/
createLine(opts) {
const line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('stroke', opts.color || '#5a9fd4');
line.setAttribute('stroke-width', opts.width || 1);
if (opts.dashed) {
line.setAttribute('stroke-dasharray', '4,4');
}
if (opts.className) {
line.setAttribute('class', opts.className);
}
return line;
},
/**
* Create path element
*/
createPath(opts) {
const path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('d', opts.d || '');
path.setAttribute('fill', opts.fill || 'none');
path.setAttribute('stroke', opts.stroke || '#5a9fd4');
path.setAttribute('stroke-width', opts.strokeWidth || 1);
if (opts.className) {
path.setAttribute('class', opts.className);
}
return path;
},
/**
* Remove overlay element
*/
remove(id) {
const item = this.elements.get(id);
if (item) {
this.svg.removeChild(item.el);
this.elements.delete(id);
}
},
/**
* Update element properties
*/
update(id, opts) {
const item = this.elements.get(id);
if (!item) {
console.warn(`overlay: element ${id} not found`);
return;
}
item.opts = item.opts || {};
// Update stored position if provided
if (opts.pos3d) {
item.pos3d = opts.pos3d;
}
if (opts.pos3d2) {
item.pos3d2 = opts.pos3d2;
}
if (opts.hidden !== undefined) {
item.opts.hidden = !!opts.hidden;
}
// Update text content
if (opts.text && item.type === 'text') {
item.el.textContent = opts.text;
}
// Update color
if (opts.color) {
if (item.type === 'point') {
item.el.setAttribute('fill', opts.color);
} else if (item.type === 'text') {
item.el.setAttribute('fill', opts.color);
} else if (item.type === 'line') {
item.el.setAttribute('stroke', opts.color);
}
}
// Re-project
this.updateElement(id);
},
/**
* Update single element projection
*/
updateElement(id) {
const item = this.elements.get(id);
if (!item || !item.pos3d) return;
// Respect caller-controlled visibility flags (e.g. tree eye toggles).
if (item.opts?.hidden) {
item.el.style.display = 'none';
return;
}
const proj = this.project3Dto2D(item.pos3d);
if (!proj) return;
// Hide if behind camera
if (!proj.visible) {
item.el.style.display = 'none';
return;
} else {
item.el.style.display = '';
}
// Update position based on type
if (item.type === 'point') {
item.el.setAttribute('cx', proj.x);
item.el.setAttribute('cy', proj.y);
} else if (item.type === 'text') {
item.el.setAttribute('x', proj.x);
item.el.setAttribute('y', proj.y);
} else if (item.type === 'line' && item.pos3d2) {
// Lines need two points
const proj2 = this.project3Dto2D(item.pos3d2);
if (!proj2 || !proj2.visible) {
item.el.style.display = 'none';
return;
}
item.el.setAttribute('x1', proj.x);
item.el.setAttribute('y1', proj.y);
item.el.setAttribute('x2', proj2.x);
item.el.setAttribute('y2', proj2.y);
}
// Optional: adjust opacity based on depth
if (item.opts.depthFade) {
const opacity = Math.max(0.2, 1 - (proj.z + 1) / 2);
item.el.style.opacity = opacity;
}
},
/**
* Update all overlay elements
*/
updateAll() {
if (!this.enabled) return;
for (const id of this.elements.keys()) {
this.updateElement(id);
}
// Trigger external update callbacks (e.g., for datum labels)
if (this.onUpdate) {
this.onUpdate();
}
},
/**
* Clear all elements
*/
clear() {
for (const item of this.elements.values()) {
this.svg.removeChild(item.el);
}
this.elements.clear();
},
/**
* Show overlay
*/
show() {
if (this.container) {
this.container.classList.remove('hidden');
this.updateAll();
}
},
/**
* Hide overlay
*/
hide() {
if (this.container) {
this.container.classList.add('hidden');
}
},
/**
* Enable/disable overlay updates
*/
setEnabled(enabled) {
this.enabled = enabled;
if (enabled) {
this.updateAll();
}
}
};
export { overlay };

53
src/void/palette.js Normal file
View file

@ -0,0 +1,53 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
const VOID_PALETTE = {
sketch: {
planeDefault: { fill: 0x5a9fd4, fillOpacity: 0.1, outline: 0x5a9fd4, outlineOpacity: 0.65 },
planeHover: { fill: 0xff9933, fillOpacity: 0.14, outline: 0xff9933, outlineOpacity: 0.95 },
planeEdit: { fill: 0x9ec7ff, fillOpacity: 0.08, outline: 0x5a9fd4, outlineOpacity: 0.9 },
linesGray: 0x747474,
linesHover: 0xff9933,
linesEdit: 0xffffff,
linesSelected: 0x9ec7ff,
linesProjectedFace: 0x5a9fd4,
linesDerivedActual: 0xff9933,
linesMirrorAxis: 0xb07cff,
pointsGray: 0x747474,
pointsPrimitiveCore: 0x101010,
pointsRingIdle: 0x747474,
pointsHover: 0xff9933,
pointsEdit: 0xffffff,
pointsSelected: 0x9ec7ff,
pointsDerivedActual: 0xff9933,
profileFillDefault: 0x747474,
profileFillHover: 0xff9933,
profileFillSelected: 0x5a9fd4,
profileOpacityDefault: 0.18,
profileOpacityHover: 0.24,
profileOpacitySelected: 0.28,
constraintGlyphDriven: 0x7e7e7e,
constraintGlyphDerived: 0xc6c6c6,
labelDefault: '#747474',
labelHover: '#ff9933',
labelEdit: '#a7cbff',
lineWidths: {
default: 1.2,
hover: 3.0,
selected: 3.4
}
},
viewcube: {
faces: {
front: 0x4a9eff,
back: 0x4a9eff,
right: 0xff4a4a,
left: 0xff4a4a,
top: 0x4aff4a,
bottom: 0x4aff4a
},
hover: 0xffaa33,
edge: 0x000000
}
};
export { VOID_PALETTE };

558
src/void/plane.js Normal file
View file

@ -0,0 +1,558 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
const { Group, PlaneGeometry, CircleGeometry, MeshBasicMaterial, Mesh, DoubleSide, EdgesGeometry, LineSegments, LineBasicMaterial } = THREE;
/**
* Plane primitive - a fundamental void:form feature
* Used for datum planes, sketch planes, and construction geometry
*/
class Plane {
constructor(options = {}) {
this.id = options.id || `plane-${Date.now()}`;
this.name = options.name || 'Plane';
this.label = options.label || null; // Optional label text
this.size = options.size || 200;
// Base colors
this.color = options.color || 0x404040; // Plane fill color
this.outlineColor = options.outlineColor || 0x808080; // Outline color
this.opacity = options.opacity !== undefined ? options.opacity : 0.15;
this.outlineOpacity = options.outlineOpacity !== undefined ? options.outlineOpacity : 0.5;
// State colors (like Onshape)
this.selectedColor = 0xff9933; // Orange tint when selected
this.selectedOutlineColor = 0xff9933; // Orange outline when selected
this.hoverOutlineColor = 0xff9933; // Orange outline when hovered
this.showHandles = options.showHandles !== undefined ? options.showHandles : true;
this.baseVisible = options.visible !== undefined ? !!options.visible : true;
// State tracking
this.selected = false;
this.hovered = false;
this.changeHandlers = new Set();
// Create the 3D group
this.group = new Group();
this.group.name = this.name;
this.group.userData.featureType = 'plane';
this.group.userData.featureId = this.id;
this.group.userData.plane = this; // Back reference for event handling
// Corner handles
this.handles = [];
// Build geometry
this.build();
}
/**
* Build or rebuild the plane geometry
*/
build() {
// Clear existing geometry
while (this.group.children.length > 0) {
const child = this.group.children[0];
child.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
});
this.group.remove(child);
}
this.handles = [];
// Create the plane mesh (translucent)
const width = this.size;
const height = this.height !== undefined ? this.height : this.size;
const geometry = new PlaneGeometry(width, height);
const material = new MeshBasicMaterial({
color: this.color,
transparent: true,
opacity: this.opacity,
side: DoubleSide,
depthWrite: false
});
this.mesh = new Mesh(geometry, material);
this.mesh.renderOrder = 1;
this.mesh.userData.plane = this; // Back reference for interaction
// Create the outline
const edges = new EdgesGeometry(geometry);
const lineMaterial = new LineBasicMaterial({
color: this.outlineColor,
transparent: true,
opacity: this.outlineOpacity,
depthWrite: false
});
this.outline = new LineSegments(edges, lineMaterial);
this.outline.renderOrder = 2; // Render outline on top of plane
this.outline.userData.plane = this; // Back reference for interaction
// Add to group
this.group.add(this.mesh);
this.group.add(this.outline);
// Create corner handles
if (this.showHandles) {
this.createHandles();
}
}
/**
* Create corner handles for resizing
*/
createHandles() {
const halfWidth = this.size / 2;
const halfHeight = (this.height !== undefined ? this.height : this.size) / 2;
const handleRadius = 4;
const handleGeometry = new CircleGeometry(handleRadius, 24);
const handleOutlineGeometry = new CircleGeometry(handleRadius, 24);
const handleMaterial = new MeshBasicMaterial({
color: 0x707070,
transparent: true,
opacity: 0.35,
depthWrite: false,
side: DoubleSide,
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2
});
const handleOutlineMaterial = new LineBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.95,
depthWrite: false
});
const corners = [
{ x: -halfWidth, y: -halfHeight, z: 0, name: 'bottom-left' },
{ x: halfWidth, y: -halfHeight, z: 0, name: 'bottom-right' },
{ x: halfWidth, y: halfHeight, z: 0, name: 'top-right' },
{ x: -halfWidth, y: halfHeight, z: 0, name: 'top-left' }
];
for (const corner of corners) {
const handle = new Mesh(handleGeometry.clone(), handleMaterial.clone());
handle.position.set(corner.x, corner.y, 0);
handle.renderOrder = 3;
handle.userData.handleType = 'plane-resize';
handle.userData.handleName = corner.name;
handle.userData.plane = this;
const handleOutline = new LineSegments(
new EdgesGeometry(handleOutlineGeometry.clone()),
handleOutlineMaterial.clone()
);
handleOutline.position.z = 0.01;
handleOutline.renderOrder = 4;
handle.add(handleOutline);
// Handles start hidden (only visible when selected)
handle.visible = false;
this.handles.push(handle);
this.group.add(handle);
}
}
/**
* Set plane size and update in place (no rebuild)
*/
setSize(size, height) {
this.size = size;
this.height = height !== undefined ? height : size;
const newWidth = this.size;
const newHeight = this.height !== undefined ? this.height : this.size;
// Update mesh geometry scale
if (this.mesh && this.mesh.geometry) {
this.mesh.geometry.dispose();
this.mesh.geometry = new PlaneGeometry(newWidth, newHeight);
}
// Update outline geometry
if (this.outline && this.mesh) {
this.outline.geometry.dispose();
this.outline.geometry = new EdgesGeometry(this.mesh.geometry);
}
// Update handle positions
if (this.handles && this.handles.length > 0) {
const halfWidth = newWidth / 2;
const halfHeight = newHeight / 2;
const positions = [
{ x: -halfWidth, y: -halfHeight }, // bottom-left
{ x: halfWidth, y: -halfHeight }, // bottom-right
{ x: halfWidth, y: halfHeight }, // top-right
{ x: -halfWidth, y: halfHeight } // top-left
];
for (let i = 0; i < this.handles.length && i < positions.length; i++) {
this.handles[i].position.set(positions[i].x, positions[i].y, this.handles[i].position.z);
}
}
this.notifyChange();
}
/**
* Set plane color
*/
setColor(color) {
this.color = color;
if (this.mesh) {
this.mesh.material.color.setHex(color);
}
}
/**
* Set outline color
*/
setOutlineColor(color) {
this.outlineColor = color;
if (this.outline) {
this.outline.material.color.setHex(color);
}
}
/**
* Set opacity
*/
setOpacity(opacity) {
this.opacity = opacity;
if (this.mesh) {
this.mesh.material.opacity = opacity;
}
}
/**
* Set outline opacity
*/
setOutlineOpacity(opacity) {
this.outlineOpacity = opacity;
if (this.outline) {
this.outline.material.opacity = opacity;
}
}
/**
* Set position
*/
setPosition(x, y, z) {
this.group.position.set(x, y, z);
this.notifyChange();
}
/**
* Set rotation (in radians)
*/
setRotation(x, y, z) {
this.group.rotation.set(x, y, z);
this.notifyChange();
}
/**
* Set plane frame using canonical document-space data.
* frame = { origin:{x,y,z}, normal:{x,y,z}, x_axis:{x,y,z}, size:{width,height} }
*/
setFrame(frame = {}) {
const { origin, normal, x_axis, size } = frame;
if (origin) {
this.group.position.set(origin.x || 0, origin.y || 0, origin.z || 0);
}
const zAxis = new THREE.Vector3(
normal?.x ?? 0,
normal?.y ?? 0,
normal?.z ?? 1
);
if (zAxis.lengthSq() < 1e-12) {
zAxis.set(0, 0, 1);
}
zAxis.normalize();
const xAxis = new THREE.Vector3(
x_axis?.x ?? 1,
x_axis?.y ?? 0,
x_axis?.z ?? 0
);
// Gram-Schmidt project x-axis onto plane normal.
xAxis.addScaledVector(zAxis, -xAxis.dot(zAxis));
if (xAxis.lengthSq() < 1e-12) {
// Choose stable fallback basis if provided axis is degenerate.
xAxis.copy(Math.abs(zAxis.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0));
xAxis.addScaledVector(zAxis, -xAxis.dot(zAxis));
}
xAxis.normalize();
const yAxis = new THREE.Vector3().crossVectors(zAxis, xAxis).normalize();
xAxis.crossVectors(yAxis, zAxis).normalize();
const basis = new THREE.Matrix4().makeBasis(xAxis, yAxis, zAxis);
this.group.quaternion.setFromRotationMatrix(basis);
if (size) {
this.setSize(
size.width !== undefined ? size.width : this.size,
size.height !== undefined ? size.height : this.height
);
}
this.notifyChange();
}
/**
* Set visibility
*/
setVisible(visible) {
this.baseVisible = !!visible;
this.group.visible = !!visible;
this.notifyChange();
}
getBaseVisible() {
return this.baseVisible;
}
/**
* Set label text
*/
setLabel(text) {
this.label = text;
this.notifyChange();
}
/**
* Get label text
*/
getLabel() {
return this.label;
}
/**
* Get top-left corner position in world coordinates
*/
getTopLeftCorner() {
const halfWidth = this.size / 2;
const halfHeight = (this.height !== undefined ? this.height : this.size) / 2;
const localPos = new THREE.Vector3(-halfWidth, halfHeight, 0);
this.group.updateMatrixWorld(true);
const worldPos = localPos.applyMatrix4(this.group.matrixWorld);
return worldPos;
}
/**
* Register callback for geometry/transform/label changes
*/
onChange(handler) {
if (typeof handler === 'function') {
this.changeHandlers.add(handler);
}
return this;
}
/**
* Remove registered change callback
*/
offChange(handler) {
this.changeHandlers.delete(handler);
return this;
}
/**
* Notify listeners plane changed
*/
notifyChange() {
for (const handler of this.changeHandlers) {
handler(this);
}
}
/**
* Show/hide handles
*/
setHandlesVisible(visible) {
this.showHandles = visible;
for (const handle of this.handles) {
handle.visible = visible;
}
}
/**
* Set selected state
*/
setSelected(selected) {
this.selected = selected;
this.updateAppearance();
}
/**
* Get selected state
*/
isSelected() {
return this.selected;
}
/**
* Set hovered state
*/
setHovered(hovered) {
this.hovered = hovered;
this.updateAppearance();
}
/**
* Get hovered state
*/
isHovered() {
return this.hovered;
}
/**
* Update appearance based on state
*/
updateAppearance() {
if (!this.mesh || !this.outline) return;
if (this.selected) {
// Selected: orange tint and outline, handles visible
this.mesh.material.color.setHex(this.selectedColor);
this.outline.material.color.setHex(this.selectedOutlineColor);
this.setHandlesVisible(true);
} else if (this.hovered) {
// Hovered: base color, orange outline, no handles
this.mesh.material.color.setHex(this.color);
this.outline.material.color.setHex(this.hoverOutlineColor);
this.setHandlesVisible(false);
} else {
// Default: base colors, no handles
this.mesh.material.color.setHex(this.color);
this.outline.material.color.setHex(this.outlineColor);
this.setHandlesVisible(false);
}
}
/**
* Get the THREE.Group for adding to scene
*/
getGroup() {
return this.group;
}
/**
* Get canonical plane frame in document space.
*/
getFrame() {
const origin = {
x: this.group.position.x,
y: this.group.position.y,
z: this.group.position.z
};
const xAxis = new THREE.Vector3(1, 0, 0).applyQuaternion(this.group.quaternion).normalize();
const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(this.group.quaternion).normalize();
return {
origin,
normal: { x: normal.x, y: normal.y, z: normal.z },
x_axis: { x: xAxis.x, y: xAxis.y, z: xAxis.z },
size: {
width: this.size,
height: this.height !== undefined ? this.height : this.size
}
};
}
/**
* Serialize plane to JSON
*/
toJSON() {
const frame = this.getFrame();
return {
id: this.id,
name: this.name,
label: this.label,
type: 'plane',
frame,
size: frame.size,
visible: this.baseVisible,
color: this.color,
outlineColor: this.outlineColor,
opacity: this.opacity,
outlineOpacity: this.outlineOpacity,
showHandles: this.showHandles
};
}
/**
* Create plane from JSON
*/
static fromJSON(data) {
const plane = new Plane({
id: data.id,
name: data.name,
label: data.label,
size: data?.size?.width ?? data.size,
color: data.color,
outlineColor: data.outlineColor,
opacity: data.opacity,
outlineOpacity: data.outlineOpacity,
showHandles: data.showHandles
});
if (data.frame) {
plane.setFrame(data.frame);
} else {
// Legacy fallback for pre-frame documents.
if (data.position) {
plane.setPosition(data.position.x, data.position.y, data.position.z);
}
if (data.rotation) {
plane.setRotation(data.rotation.x, data.rotation.y, data.rotation.z);
}
if (data.height !== undefined || data.size !== undefined) {
plane.setSize(data.size, data.height);
}
}
if (data.visible !== undefined) {
plane.setVisible(data.visible);
}
return plane;
}
/**
* Dispose of all resources
*/
dispose() {
while (this.group.children.length > 0) {
const child = this.group.children[0];
child.traverse(obj => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.dispose());
} else {
obj.material.dispose();
}
}
});
this.group.remove(child);
}
}
}
export { Plane };

1088
src/void/properties.js Normal file

File diff suppressed because it is too large Load diff

36
src/void/sketch/api.js Normal file
View 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 };

View 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
};

View 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 };

View 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
};

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

112
src/void/sketch/curve.js Normal file
View 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

File diff suppressed because it is too large Load diff

484
src/void/sketch/index.js Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

918
src/void/sketch/runtime.js Normal file
View 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 };

View 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
};

View 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
};

View 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
};

File diff suppressed because it is too large Load diff

543
src/void/sketch/tools.js Normal file
View 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
};

710
src/void/solid/chamfer.js Normal file
View file

@ -0,0 +1,710 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { booleanMeshes } from './kernel.js';
function vec3(x = 0, y = 0, z = 0) {
return { x: Number(x) || 0, y: Number(y) || 0, z: Number(z) || 0 };
}
function add(a, b) {
return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
}
function sub(a, b) {
return vec3(a.x - b.x, a.y - b.y, a.z - b.z);
}
function mul(a, s) {
return vec3(a.x * s, a.y * s, a.z * s);
}
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return vec3(
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
);
}
function length(a) {
return Math.hypot(a.x, a.y, a.z);
}
function normalize(a) {
const len = length(a) || 1;
return vec3(a.x / len, a.y / len, a.z / len);
}
function distanceSq(a, b) {
const d = sub(a, b);
return dot(d, d);
}
function centroidFromPositions(pos) {
const count = Math.floor((pos?.length || 0) / 3);
if (!count) return vec3(0, 0, 0);
let sx = 0, sy = 0, sz = 0;
for (let i = 0; i < pos.length; i += 3) {
sx += Number(pos[i] || 0);
sy += Number(pos[i + 1] || 0);
sz += Number(pos[i + 2] || 0);
}
return vec3(sx / count, sy / count, sz / count);
}
function pointFromPositions(pos, vi) {
const i = vi * 3;
return vec3(pos[i], pos[i + 1], pos[i + 2]);
}
function normalForTriangle(pos, i0, i1, i2) {
const a = pointFromPositions(pos, i0);
const b = pointFromPositions(pos, i1);
const c = pointFromPositions(pos, i2);
const ab = sub(b, a);
const ac = sub(c, a);
const n = cross(ab, ac);
const len = length(n);
return len > 1e-12 ? mul(n, 1 / len) : vec3(0, 0, 1);
}
function edgeKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function buildMeshAdjacency(mesh) {
const pos = mesh?.positions;
const idx = mesh?.indices;
if (!pos?.length || !idx?.length) return null;
const edgeToTris = new Map();
const edgeVerts = new Map();
const triCount = Math.floor(idx.length / 3);
for (let t = 0; t < triCount; t++) {
const i0 = idx[t * 3];
const i1 = idx[t * 3 + 1];
const i2 = idx[t * 3 + 2];
const edges = [[i0, i1], [i1, i2], [i2, i0]];
for (const [va, vb] of edges) {
const k = edgeKey(va, vb);
const list = edgeToTris.get(k);
if (list) list.push(t);
else edgeToTris.set(k, [t]);
if (!edgeVerts.has(k)) edgeVerts.set(k, [va, vb]);
}
}
return { positions: pos, indices: idx, edgeToTris, edgeVerts, centroid: centroidFromPositions(pos) };
}
function edgeEndpointScore(a, b, ea, eb) {
const d1 = Math.sqrt(distanceSq(a, ea)) + Math.sqrt(distanceSq(b, eb));
const d2 = Math.sqrt(distanceSq(a, eb)) + Math.sqrt(distanceSq(b, ea));
return Math.min(d1, d2);
}
function makeTriPrismMesh(a0, a1, a2, b0, b1, b2) {
const positions = new Float32Array([
a0.x, a0.y, a0.z,
a1.x, a1.y, a1.z,
a2.x, a2.y, a2.z,
b0.x, b0.y, b0.z,
b1.x, b1.y, b1.z,
b2.x, b2.y, b2.z
]);
const indices = new Uint32Array([
0, 2, 1,
3, 4, 5,
0, 1, 4,
0, 4, 3,
1, 2, 5,
1, 5, 4,
2, 0, 3,
2, 3, 5
]);
return { positions, indices };
}
function getEdgeRecordByKey(meshInfo, key) {
const parts = String(key || '').split(':');
if (parts.length !== 2) return null;
const va = Number(parts[0]);
const vb = Number(parts[1]);
if (!Number.isFinite(va) || !Number.isFinite(vb)) return null;
const ek = edgeKey(va, vb);
const rep = meshInfo?.edgeVerts?.get?.(ek) || null;
const tris = meshInfo?.edgeToTris?.get?.(ek) || null;
if (!rep || !Array.isArray(tris) || tris.length < 2) return null;
return {
va: Number(rep[0]),
vb: Number(rep[1]),
tris
};
}
function chooseTrianglePair(meshInfo, tris, va, vb, strictCrease = false) {
const positions = meshInfo.positions;
const indices = meshInfo.indices;
const creaseDotMax = Math.cos(30 * Math.PI / 180);
let pair = null;
let pairDot = 1;
for (let i = 0; i < tris.length; i++) {
for (let j = i + 1; j < tris.length; j++) {
const t0 = tris[i];
const t1 = tris[j];
const t0i0 = indices[t0 * 3];
const t0i1 = indices[t0 * 3 + 1];
const t0i2 = indices[t0 * 3 + 2];
const t1i0 = indices[t1 * 3];
const t1i1 = indices[t1 * 3 + 1];
const t1i2 = indices[t1 * 3 + 2];
const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2);
const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2);
const d = Math.max(-1, Math.min(1, dot(n1, n2)));
if (strictCrease && d > creaseDotMax) continue;
if (d < pairDot) {
pairDot = d;
pair = { t0, t1 };
}
}
}
return pair;
}
function buildCutterFromResolvedEdge(meshInfo, va, vb, tris, distance) {
const positions = meshInfo.positions;
const indices = meshInfo.indices;
const triPair = chooseTrianglePair(meshInfo, tris, va, vb, false);
if (!triPair) return null;
const t0 = triPair.t0;
const t1 = triPair.t1;
const t0i0 = indices[t0 * 3];
const t0i1 = indices[t0 * 3 + 1];
const t0i2 = indices[t0 * 3 + 2];
const t1i0 = indices[t1 * 3];
const t1i1 = indices[t1 * 3 + 1];
const t1i2 = indices[t1 * 3 + 2];
const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2);
const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2);
const a = pointFromPositions(positions, va);
const b = pointFromPositions(positions, vb);
const edge = sub(b, a);
const edgeLen = length(edge);
if (edgeLen <= 1e-8) return null;
const e = mul(edge, 1 / edgeLen);
let u1 = sub(n1, mul(e, dot(n1, e)));
let u2 = sub(n2, mul(e, dot(n2, e)));
if (length(u1) <= 1e-8 || length(u2) <= 1e-8) return null;
u1 = normalize(u1);
u2 = normalize(u2);
const normalDelta = Math.max(-1, Math.min(1, dot(u1, u2)));
if (normalDelta > 0.997) return null;
let i1 = mul(u1, -1);
let i2 = mul(u2, -1);
const mid = mul(add(a, b), 0.5);
const toCenter = sub(meshInfo.centroid, mid);
if (dot(i1, toCenter) < 0 && dot(i2, toCenter) < 0) {
i1 = mul(i1, -1);
i2 = mul(i2, -1);
}
const ext = distance * 1.4;
const spineA = add(a, mul(e, -ext));
const spineB = add(b, mul(e, ext));
let out = mul(add(i1, i2), -1);
if (length(out) <= 1e-8) {
out = mul(i1, -1);
} else {
out = normalize(out);
}
const insideScale = distance * 1.8;
const outsideScale = distance * 0.7;
const a0 = add(spineA, mul(out, outsideScale));
const a1 = add(spineA, mul(i1, insideScale));
const a2 = add(spineA, mul(i2, insideScale));
const b0 = add(spineB, mul(out, outsideScale));
const b1 = add(spineB, mul(i1, insideScale));
const b2 = add(spineB, mul(i2, insideScale));
const area = length(cross(sub(a1, a0), sub(a2, a0)));
if (area <= 1e-8) return null;
return {
key: edgeKey(va, vb),
mesh: makeTriPrismMesh(a0, a1, a2, b0, b1, b2)
};
}
function buildCutterForMeshEdgeKey(meshInfo, meshEdgeKey, distance) {
const rec = getEdgeRecordByKey(meshInfo, meshEdgeKey);
if (!rec) return null;
return buildCutterFromResolvedEdge(meshInfo, rec.va, rec.vb, rec.tris, distance);
}
function buildCutterForSegment(meshInfo, aPoint, bPoint, distance) {
if (!meshInfo || !aPoint || !bPoint || !(distance > 0)) return null;
const positions = meshInfo.positions;
let best = null;
let bestScore = Infinity;
const creaseDotMax = Math.cos(30 * Math.PI / 180);
const requestedLen = Math.sqrt(distanceSq(aPoint, bPoint));
if (!(requestedLen > 1e-9)) return null;
const reqDir = normalize(sub(bPoint, aPoint));
const reqMid = mul(add(aPoint, bPoint), 0.5);
const findBest = (relaxed = false) => {
let localBest = null;
let localBestScore = Infinity;
for (const [ek, tris] of meshInfo.edgeToTris.entries()) {
if (!Array.isArray(tris) || tris.length < 2) continue;
const parts = String(ek).split(':');
if (parts.length !== 2) continue;
const rep = meshInfo.edgeVerts?.get?.(ek) || null;
const va = Number(rep?.[0]);
const vb = Number(rep?.[1]);
if (!Number.isFinite(va) || !Number.isFinite(vb) || va === vb) continue;
const ea = pointFromPositions(positions, va);
const eb = pointFromPositions(positions, vb);
const cand = sub(eb, ea);
const candLen = length(cand);
if (!(candLen > 1e-9)) continue;
const candDir = mul(cand, 1 / candLen);
const dirAlign = Math.abs(dot(reqDir, candDir));
const candMid = mul(add(ea, eb), 0.5);
const midDist = Math.sqrt(distanceSq(reqMid, candMid));
if (!relaxed) {
if (dirAlign < 0.5) continue;
const maxMidDist = Math.max(1.5, requestedLen * 0.8, candLen * 0.8);
if (midDist > maxMidDist) continue;
}
// Find a usable face-pair on this edge.
const pair = chooseTrianglePair(meshInfo, tris, va, vb, false);
const pairDot = (() => {
if (!pair) return 1;
const idx = meshInfo.indices;
const t0i0 = idx[pair.t0 * 3];
const t0i1 = idx[pair.t0 * 3 + 1];
const t0i2 = idx[pair.t0 * 3 + 2];
const t1i0 = idx[pair.t1 * 3];
const t1i1 = idx[pair.t1 * 3 + 1];
const t1i2 = idx[pair.t1 * 3 + 2];
const n1 = normalForTriangle(meshInfo.positions, t0i0, t0i1, t0i2);
const n2 = normalForTriangle(meshInfo.positions, t1i0, t1i1, t1i2);
return Math.max(-1, Math.min(1, dot(n1, n2)));
})();
if (!pair) continue;
if (!relaxed && pairDot > creaseDotMax) continue;
const score = edgeEndpointScore(aPoint, bPoint, ea, eb) + midDist * 0.5 + (1 - dirAlign) * (relaxed ? 0.1 : 2);
if (score < localBestScore) {
localBestScore = score;
localBest = { va, vb, tris: [pair.t0, pair.t1] };
}
}
return { best: localBest, score: localBestScore };
};
const strict = findBest(false);
if (strict.best) {
best = strict.best;
bestScore = strict.score;
} else {
const relaxed = findBest(true);
best = relaxed.best;
bestScore = relaxed.score;
}
if (!best) return null;
return buildCutterFromResolvedEdge(meshInfo, best.va, best.vb, best.tris, distance);
}
function segmentsFromEdgeRef(edgeRef) {
if (!edgeRef) return [];
const path = Array.isArray(edgeRef.path) ? edgeRef.path : null;
if (path?.length >= 2) {
let pts = path.map(p => vec3(p.x, p.y, p.z));
// Remove duplicated closing point if present.
if (pts.length > 2) {
const first = pts[0];
const last = pts[pts.length - 1];
if (Math.sqrt(distanceSq(first, last)) <= 1e-7) {
pts = pts.slice(0, -1);
}
}
// Decimate very dense loops for cutter robustness/perf.
const maxPts = 49; // => at most 49 segments for closed loops
if (pts.length > maxPts) {
const reduced = [];
for (let i = 0; i < maxPts; i++) {
const t = i / (maxPts - 1);
const idx = Math.round(t * (pts.length - 1));
reduced.push(pts[Math.max(0, Math.min(pts.length - 1, idx))]);
}
pts = reduced;
}
const out = [];
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i];
const b = pts[i + 1];
if (!a || !b) continue;
out.push([a, b]);
}
// Close if this came from a loop path.
if (pts.length > 2) {
out.push([pts[pts.length - 1], pts[0]]);
}
return out;
}
const a = edgeRef?.a;
const b = edgeRef?.b;
if (a && b) return [[vec3(a.x, a.y, a.z), vec3(b.x, b.y, b.z)]];
return [];
}
function concatMeshes(meshes = []) {
if (!Array.isArray(meshes) || !meshes.length) return null;
let posLen = 0;
let idxLen = 0;
for (const mesh of meshes) {
if (!mesh?.positions?.length || !mesh?.indices?.length) continue;
posLen += mesh.positions.length;
idxLen += mesh.indices.length;
}
if (!posLen || !idxLen) return null;
const positions = new Float32Array(posLen);
const indices = new Uint32Array(idxLen);
let po = 0;
let io = 0;
let vBase = 0;
for (const mesh of meshes) {
if (!mesh?.positions?.length || !mesh?.indices?.length) continue;
positions.set(mesh.positions, po);
for (let i = 0; i < mesh.indices.length; i++) {
indices[io + i] = Number(mesh.indices[i] || 0) + vBase;
}
po += mesh.positions.length;
io += mesh.indices.length;
vBase += Math.floor(mesh.positions.length / 3);
}
return { positions, indices };
}
function scaleMeshAroundCentroid(mesh, scale = 1.001) {
if (!mesh?.positions?.length || !mesh?.indices?.length) return null;
const c = centroidFromPositions(mesh.positions);
const out = new Float32Array(mesh.positions.length);
for (let i = 0; i < mesh.positions.length; i += 3) {
const x = Number(mesh.positions[i] || 0);
const y = Number(mesh.positions[i + 1] || 0);
const z = Number(mesh.positions[i + 2] || 0);
out[i] = c.x + (x - c.x) * scale;
out[i + 1] = c.y + (y - c.y) * scale;
out[i + 2] = c.z + (z - c.z) * scale;
}
return {
positions: out,
indices: mesh.indices instanceof Uint32Array ? mesh.indices : new Uint32Array(mesh.indices || [])
};
}
function cloneMesh(mesh) {
if (!mesh?.positions?.length || !mesh?.indices?.length) return null;
return {
positions: mesh.positions instanceof Float32Array ? new Float32Array(mesh.positions) : new Float32Array(mesh.positions || []),
indices: mesh.indices instanceof Uint32Array ? new Uint32Array(mesh.indices) : new Uint32Array(mesh.indices || [])
};
}
function makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, reason = 'no-op') {
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids)
? targetSolid.source.sketch_ids.slice()
: [];
return {
id: nextId,
name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
pass_through: true,
reason,
sketch_ids: sketchIds
},
provenance: {
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
pass_through: true,
reason
},
parents: [solidId]
},
mesh: {
tri_count: targetSolid?.mesh?.tri_count || 0,
vert_count: targetSolid?.mesh?.vert_count || 0
},
status: 'manifold_chamfer_passthrough'
};
}
function parseBoundarySegmentRef(boundarySegmentId) {
const raw = String(boundarySegmentId || '');
if (!raw) return null;
const parts = raw.split(':');
if (raw.startsWith('segment:') && parts.length >= 5) {
const segInLoop = Number(parts[parts.length - 1]);
const loopIndex = Number(parts[parts.length - 2]);
const faceId = Number(parts[parts.length - 3]);
const solidId = parts.slice(1, -3).join(':');
if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex) || !Number.isFinite(segInLoop)) return null;
return { kind: 'segment', id: raw, solidId, faceId, loopIndex, segInLoop };
}
if (raw.startsWith('boundary:') && parts.length >= 4) {
const loopIndex = Number(parts[parts.length - 1]);
const faceId = Number(parts[parts.length - 2]);
const solidId = parts.slice(1, -2).join(':');
if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex)) return null;
return { kind: 'boundary', id: raw, solidId, faceId, loopIndex };
}
return null;
}
async function applyChamferFeature(solids, meshCache, feature, makeBodyId, bodySeqRef) {
const refs = Array.isArray(feature?.input?.edges) ? feature.input.edges : [];
const distance = Math.max(0.0001, Math.abs(Number(feature?.params?.distance ?? 1)));
const showCutters = feature?.params?.showCutters === true;
if (!refs.length || !(distance > 0)) return false;
const bySolid = new Map();
for (const ref of refs) {
let solidId = String(ref?.solidId || ref?.solid_id || '');
if (!solidId) {
const parsed = parseBoundarySegmentRef(ref?.boundary_segment_id || ref?.entity?.id || ref?.key || null);
solidId = String(parsed?.solidId || '');
}
if (!solidId) continue;
const list = bySolid.get(solidId);
if (list) list.push(ref);
else bySolid.set(solidId, [ref]);
}
if (!bySolid.size) return false;
let changed = false;
for (const [solidId, solidRefs] of bySolid.entries()) {
const targetSolid = solids.find(s => s?.id === solidId) || null;
const targetMesh = meshCache.get(solidId);
if (!targetSolid || !targetMesh?.positions?.length || !targetMesh?.indices?.length) continue;
const adj = buildMeshAdjacency(targetMesh);
if (!adj) continue;
const tools = [];
const usedEdgeKeys = new Set();
for (const ref of solidRefs) {
const meshEdgeKeys = Array.isArray(ref?.meshEdgeKeys) ? ref.meshEdgeKeys.filter(Boolean) : [];
if (meshEdgeKeys.length) {
for (const mek of meshEdgeKeys) {
const built = buildCutterForMeshEdgeKey(adj, mek, distance);
const cutter = built?.mesh || null;
const cutterKey = String(built?.key || mek || '');
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
if (cutter?.positions?.length && cutter?.indices?.length) {
if (cutterKey) usedEdgeKeys.add(cutterKey);
tools.push(cutter);
}
}
continue;
}
if (ref?.meshEdgeKey && !(Array.isArray(ref.path) && ref.path.length >= 2)) {
const built = buildCutterForMeshEdgeKey(adj, ref.meshEdgeKey, distance);
const cutter = built?.mesh || null;
const cutterKey = String(built?.key || '');
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
if (cutter?.positions?.length && cutter?.indices?.length) {
if (cutterKey) usedEdgeKeys.add(cutterKey);
tools.push(cutter);
continue;
}
}
const segs = segmentsFromEdgeRef(ref);
for (const [a, b] of segs) {
const built = buildCutterForSegment(adj, a, b, distance);
const cutter = built?.mesh || null;
const cutterKey = String(built?.key || '');
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
if (cutter?.positions?.length && cutter?.indices?.length) {
if (cutterKey) usedEdgeKeys.add(cutterKey);
tools.push(cutter);
}
}
}
if (!tools.length) {
console.warn('void.chamfer.no_cutters', { featureId: feature?.id, solidId, refs: solidRefs.length });
const targetIndex = solids.findIndex(s => s?.id === solidId);
if (targetIndex >= 0) {
const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'no_cutters');
const copied = cloneMesh(targetMesh);
if (copied?.positions?.length && copied?.indices?.length) {
solids[targetIndex] = nextSolid;
meshCache.delete(solidId);
meshCache.set(nextSolid.id, copied);
changed = true;
}
}
continue;
}
if (showCutters) {
const merged = concatMeshes(tools);
if (merged?.positions?.length && merged?.indices?.length) {
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
const nextSolid = {
id: nextId,
name: `${feature.name || 'Chamfer'}-cutters`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
debug: 'cutters',
distance
},
provenance: {
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
debug: 'cutters'
},
parents: [solidId]
},
mesh: {
tri_count: (merged.indices.length || 0) / 3,
vert_count: (merged.positions.length || 0) / 3
},
status: 'manifold_chamfer_debug_cutters'
};
solids.push(nextSolid);
meshCache.set(nextId, merged);
console.log('void.chamfer.debug_cutters', {
featureId: feature?.id,
solidId,
cutters: tools.length,
tri: Math.floor((merged.indices.length || 0) / 3)
});
changed = true;
}
continue;
}
let result = await booleanMeshes({
mode: 'subtract',
targets: [targetMesh],
tools
});
if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) {
// Fallback: sequential subtract can be more robust than bulk subtract.
let current = targetMesh;
let applied = 0;
for (const tool of tools) {
const step = await booleanMeshes({
mode: 'subtract',
targets: [current],
tools: [tool]
});
if (step?.mesh?.positions?.length && step?.mesh?.indices?.length) {
current = step.mesh;
applied++;
continue;
}
// Retry with tiny perturbation to avoid coplanar/not-manifold failures.
const grown = scaleMeshAroundCentroid(tool, 1.001);
if (grown) {
const stepGrown = await booleanMeshes({
mode: 'subtract',
targets: [current],
tools: [grown]
});
if (stepGrown?.mesh?.positions?.length && stepGrown?.mesh?.indices?.length) {
current = stepGrown.mesh;
applied++;
}
}
}
if (applied > 0) {
result = { mesh: current };
console.warn('void.chamfer.bulk_failed_sequential_used', {
featureId: feature?.id,
solidId,
cutters: tools.length,
applied
});
}
}
if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) {
console.warn('void.chamfer.boolean_failed', { featureId: feature?.id, solidId, cutters: tools.length });
const targetIndex = solids.findIndex(s => s?.id === solidId);
if (targetIndex >= 0) {
const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'boolean_failed');
const copied = cloneMesh(targetMesh);
if (copied?.positions?.length && copied?.indices?.length) {
solids[targetIndex] = nextSolid;
meshCache.delete(solidId);
meshCache.set(nextSolid.id, copied);
changed = true;
}
}
continue;
}
const targetIndex = solids.findIndex(s => s?.id === solidId);
if (targetIndex < 0) continue;
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids)
? targetSolid.source.sketch_ids.slice()
: [];
const nextSolid = {
id: nextId,
name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
edges: solidRefs.map(ref => ({
key: ref?.key || null,
boundary_segment_id: ref?.boundary_segment_id || null,
solidId: ref?.solidId || null,
edgeIndex: ref?.edgeIndex ?? null
})),
distance,
sketch_ids: sketchIds
},
provenance: {
source: {
feature_id: feature.id,
feature_type: feature.type,
parent: solidId,
distance
},
parents: [solidId]
},
mesh: {
tri_count: (result.mesh.indices.length || 0) / 3,
vert_count: (result.mesh.positions.length || 0) / 3
},
status: 'manifold_chamfer_ready'
};
solids.splice(targetIndex, 1, nextSolid);
meshCache.delete(solidId);
meshCache.set(nextId, result.mesh);
console.log('void.chamfer.applied', {
featureId: feature?.id,
solidId,
cutters: tools.length,
triIn: Math.floor((targetMesh.indices?.length || 0) / 3),
triOut: Math.floor((result.mesh.indices?.length || 0) / 3)
});
changed = true;
}
return changed;
}
export {
applyChamferFeature
};

158
src/void/solid/kernel.js Normal file
View file

@ -0,0 +1,158 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import manifold from '../../ext/manifold.js';
let _instance = null;
let _initPromise = null;
function locateFile(path) {
return `../wasm/${path}`;
}
async function ensureKernel() {
if (_instance) return _instance;
if (_initPromise) return _initPromise;
_initPromise = manifold({ locateFile }).then(inst => {
inst.setup();
_instance = inst;
return _instance;
}).catch(error => {
console.warn('void.solid.kernel init failed', error);
return null;
});
return _initPromise;
}
function isReady() {
return !!_instance;
}
function getInstance() {
return _instance;
}
async function extrudePolygons(polygons, height = 1) {
const inst = await ensureKernel();
if (!inst?.Manifold || !Array.isArray(polygons) || !polygons.length) {
return null;
}
try {
const man = inst.Manifold.extrude(polygons, Number(height) || 1);
const mesh = man.getMesh();
// Callers are responsible for consuming mesh data and deleting manifold.
return { manifold: man, mesh };
} catch (error) {
console.warn('void.solid.kernel extrude failed', error);
return null;
}
}
function toKernelMesh(inst, meshData) {
const positions = meshData?.positions;
const indices = meshData?.indices;
if (!positions?.length || !indices?.length) return null;
const vertCount = Math.floor(positions.length / 3);
const props = new Float32Array(vertCount * 3);
props.set(positions);
return new inst.Mesh({
numProp: 3,
vertProperties: props,
triVerts: Uint32Array.from(indices)
});
}
function fromKernelMesh(mesh) {
const numProp = Math.max(3, Number(mesh?.numProp || 3));
const verts = mesh?.vertProperties;
const triVerts = mesh?.triVerts;
if (!verts?.length || !triVerts?.length) return null;
const vertCount = Math.floor(verts.length / numProp);
const positions = new Float32Array(vertCount * 3);
for (let i = 0; i < vertCount; i++) {
const src = i * numProp;
const dst = i * 3;
positions[dst] = Number(verts[src] || 0);
positions[dst + 1] = Number(verts[src + 1] || 0);
positions[dst + 2] = Number(verts[src + 2] || 0);
}
return {
positions,
indices: Uint32Array.from(triVerts)
};
}
async function booleanMeshes(input, mode = 'add') {
const inst = await ensureKernel();
if (!inst?.Manifold) {
return null;
}
const options = Array.isArray(input)
? { meshes: input, mode }
: { ...(input || {}), mode: String((input || {}).mode || mode || 'add') };
const op = String(options.mode || 'add');
const meshes = Array.isArray(options.meshes) ? options.meshes : [];
const targetMeshes = Array.isArray(options.targets) ? options.targets : null;
const toolMeshes = Array.isArray(options.tools) ? options.tools : null;
const manifolds = [];
let result = null;
try {
const toManifold = meshData => {
const kernelMesh = toKernelMesh(inst, meshData);
return kernelMesh ? new inst.Manifold(kernelMesh) : null;
};
const combine = (list, kind = 'add') => {
if (!Array.isArray(list) || !list.length) return null;
if (list.length === 1) return list[0];
if (kind === 'intersect') return inst.Manifold.intersection(list);
if (kind === 'subtract') return inst.Manifold.difference(list);
return inst.Manifold.union(list);
};
if (op === 'subtract' && targetMeshes && toolMeshes) {
const targetMfs = targetMeshes.map(toManifold).filter(Boolean);
const toolMfs = toolMeshes.map(toManifold).filter(Boolean);
manifolds.push(...targetMfs, ...toolMfs);
if (!targetMfs.length || !toolMfs.length) {
return null;
}
const targetUnion = combine(targetMfs, 'add');
const toolUnion = combine(toolMfs, 'add');
if (!targetUnion || !toolUnion) return null;
if (!targetMfs.includes(targetUnion)) manifolds.push(targetUnion);
if (!toolMfs.includes(toolUnion)) manifolds.push(toolUnion);
result = inst.Manifold.difference([targetUnion, toolUnion]);
} else {
for (const meshData of meshes) {
const manifold = toManifold(meshData);
if (!manifold) continue;
manifolds.push(manifold);
}
if (manifolds.length < 2) {
return null;
}
if (op === 'intersect') {
result = inst.Manifold.intersection(manifolds);
} else {
result = inst.Manifold.union(manifolds);
}
}
const mesh = result?.getMesh?.();
return mesh ? { mesh: fromKernelMesh(mesh) } : null;
} catch (error) {
console.warn('void.solid.kernel boolean failed', error);
return null;
} finally {
for (const manifold of manifolds) {
manifold?.delete?.();
}
result?.delete?.();
}
}
export {
ensureKernel,
isReady,
getInstance,
extrudePolygons,
booleanMeshes
};

View file

@ -0,0 +1,22 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function buildSeedProvenance(feature, profileTarget, bodyIndex = 0) {
return {
source: {
feature_id: feature?.id || null,
feature_type: feature?.type || null,
profile: profileTarget || null
},
faces: [
{ role: 'cap_start', source: profileTarget || null },
{ role: 'cap_end', source: profileTarget || null },
{ role: 'side', source: profileTarget || null }
],
body_index: bodyIndex
};
}
export {
buildSeedProvenance
};

564
src/void/solid/rebuild.js Normal file
View file

@ -0,0 +1,564 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { buildSeedProvenance } from './provenance.js';
import { extrudePolygons, booleanMeshes } from './kernel.js';
import { ClipperLib } from '../../ext/clip2.esm.js';
import { applyChamferFeature } from './chamfer.js';
const CLIPPER_SCALE = 100000;
function resolveProfileTargetRef(profileTarget = {}) {
const regionId = String(profileTarget?.region_id || '');
const match = regionId.match(/^profile:([^:]+):([^:]+)$/);
if (!match) return { regionId: null, sketchId: null, profileId: null, key: null };
const sketchId = match[1];
const profileId = match[2];
return { regionId, sketchId, profileId, key: regionId };
}
function profileLoopsFromRuntime(api, profileTarget) {
const { sketchId, profileId } = resolveProfileTargetRef(profileTarget);
if (!sketchId || !profileId) return null;
const rec = api.sketchRuntime?.getRecord?.(sketchId);
const view = rec?.entityViews?.get?.(profileId);
const loops = view?.object?.userData?.sketchProfileLoops || view?.entity?.loops || null;
if (Array.isArray(loops) && loops.length) {
const out = loops.filter(loop => Array.isArray(loop) && loop.length >= 3);
return out.length ? out : null;
}
const loop = view?.object?.userData?.sketchProfileLoop || view?.entity?.loop || null;
if (Array.isArray(loop) && loop.length >= 3) return [loop];
return null;
}
function profileLoopsFromSnapshot(snapshot, profileTarget) {
const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget);
if (!key || !sketchId || !profileId) return null;
const map = snapshot?.profileLoops || {};
const loops = map[key];
if (!Array.isArray(loops) || !loops.length) return null;
return loops
.filter(loop => Array.isArray(loop) && loop.length >= 3)
.map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) })));
}
function makeBodyId(featureId, index) {
return `${featureId}:body:${index}`;
}
function getSketchIdsForSolid(solid) {
const ids = new Set();
const add = value => {
if (value) ids.add(value);
};
add(solid?.source?.profile?.sketchId);
for (const sid of solid?.source?.sketch_ids || []) {
add(sid);
}
add(solid?.provenance?.source?.profile?.sketchId);
for (const face of solid?.provenance?.faces || []) {
add(face?.source?.sketchId);
}
return ids;
}
function basisFromPlaneFrame(frame) {
const origin = {
x: Number(frame?.origin?.x ?? 0),
y: Number(frame?.origin?.y ?? 0),
z: Number(frame?.origin?.z ?? 0)
};
const normalRaw = {
x: Number(frame?.normal?.x ?? 0),
y: Number(frame?.normal?.y ?? 0),
z: Number(frame?.normal?.z ?? 1)
};
const nxLen = Math.hypot(normalRaw.x, normalRaw.y, normalRaw.z) || 1;
const normal = {
x: normalRaw.x / nxLen,
y: normalRaw.y / nxLen,
z: normalRaw.z / nxLen
};
const xAxisRaw = {
x: Number(frame?.x_axis?.x ?? 1),
y: Number(frame?.x_axis?.y ?? 0),
z: Number(frame?.x_axis?.z ?? 0)
};
// remove normal component
const xDotN = xAxisRaw.x * normal.x + xAxisRaw.y * normal.y + xAxisRaw.z * normal.z;
let xAxis = {
x: xAxisRaw.x - normal.x * xDotN,
y: xAxisRaw.y - normal.y * xDotN,
z: xAxisRaw.z - normal.z * xDotN
};
const xLen = Math.hypot(xAxis.x, xAxis.y, xAxis.z) || 1;
xAxis = { x: xAxis.x / xLen, y: xAxis.y / xLen, z: xAxis.z / xLen };
// y = n x x
const yAxis = {
x: normal.y * xAxis.z - normal.z * xAxis.y,
y: normal.z * xAxis.x - normal.x * xAxis.z,
z: normal.x * xAxis.y - normal.y * xAxis.x
};
return { origin, xAxis, yAxis, normal };
}
function transformMeshToWorld(mesh, basis, zShift = 0) {
const numProp = Math.max(3, Number(mesh?.numProp || 3));
const verts = mesh?.vertProperties;
const triVerts = mesh?.triVerts;
if (!verts?.length || !triVerts?.length) return null;
const vertCount = Math.floor(verts.length / numProp);
const positions = new Float32Array(vertCount * 3);
const { origin, xAxis, yAxis, normal } = basis;
for (let i = 0; i < vertCount; i++) {
const o = i * numProp;
const lx = Number(verts[o] || 0);
const ly = Number(verts[o + 1] || 0);
const lz = Number(verts[o + 2] || 0) + (Number(zShift) || 0);
const wx = origin.x + xAxis.x * lx + yAxis.x * ly + normal.x * lz;
const wy = origin.y + xAxis.y * lx + yAxis.y * ly + normal.y * lz;
const wz = origin.z + xAxis.z * lx + yAxis.z * ly + normal.z * lz;
const p = i * 3;
positions[p] = wx;
positions[p + 1] = wy;
positions[p + 2] = wz;
}
return {
positions,
indices: Uint32Array.from(triVerts)
};
}
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) || loop.length < 3) return loop;
const isCCW = polygonSignedArea(loop) > 0;
if ((ccw && isCCW) || (!ccw && !isCCW)) return loop;
return loop.slice().reverse();
}
function toClipperPath(loop) {
if (!Array.isArray(loop) || loop.length < 3) return null;
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)
});
}
return path.length >= 3 ? path : null;
}
function fromClipperPath(path) {
if (!Array.isArray(path) || path.length < 3) return null;
return path.map(pt => ({
x: Number(pt?.X || 0) / CLIPPER_SCALE,
y: Number(pt?.Y || 0) / CLIPPER_SCALE
}));
}
function unionSelectedRegions(profileLoopsList) {
if (!Array.isArray(profileLoopsList) || !profileLoopsList.length || !ClipperLib?.Clipper) {
return [];
}
const subject = [];
for (const loops of profileLoopsList) {
if (!Array.isArray(loops)) continue;
for (const loop of loops) {
const path = toClipperPath(loop);
if (path) subject.push(path);
}
}
if (!subject.length) return [];
const clip = new ClipperLib.Clipper();
clip.AddPaths(subject, ClipperLib.PolyType.ptSubject, true);
const tree = new ClipperLib.PolyTree();
const ok = clip.Execute(
ClipperLib.ClipType.ctUnion,
tree,
ClipperLib.PolyFillType.pftEvenOdd,
ClipperLib.PolyFillType.pftEvenOdd
);
if (!ok) return [];
const exPolys = ClipperLib.JS?.PolyTreeToExPolygons
? ClipperLib.JS.PolyTreeToExPolygons(tree)
: [];
const out = [];
for (const ex of exPolys || []) {
const outer = fromClipperPath(ex?.outer);
if (!outer || outer.length < 3) continue;
const holes = [];
for (const hole of ex?.holes || []) {
const loop = fromClipperPath(hole);
if (loop && loop.length >= 3) holes.push(loop);
}
out.push({
outer: ensureLoopWinding(outer, true),
holes: holes.map(loop => ensureLoopWinding(loop, false))
});
}
return out;
}
function pointInLoop(point, loop) {
if (!point || !Array.isArray(loop) || loop.length < 3) return false;
let inside = false;
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
const xi = Number(loop[i]?.x || 0);
const yi = Number(loop[i]?.y || 0);
const xj = Number(loop[j]?.x || 0);
const yj = Number(loop[j]?.y || 0);
const intersects = ((yi > point.y) !== (yj > point.y))
&& (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || 1e-12) + xi);
if (intersects) inside = !inside;
}
return inside;
}
function pointInRegion(point, region) {
if (!point || !region?.outer) return false;
if (!pointInLoop(point, region.outer)) return false;
const holes = Array.isArray(region.holes) ? region.holes : [];
for (const hole of holes) {
if (pointInLoop(point, hole)) return false;
}
return true;
}
function loopCentroid(loop) {
if (!Array.isArray(loop) || loop.length < 3) return null;
let sx = 0;
let sy = 0;
let n = 0;
for (const p of loop) {
if (!p) continue;
sx += Number(p.x || 0);
sy += Number(p.y || 0);
n++;
}
if (!n) return null;
return { x: sx / n, y: sy / n };
}
async function rebuildGeneratedSolidsFromSnapshot(snapshot, options = {}) {
const builtFeatures = Array.isArray(snapshot?.builtFeatures) ? snapshot.builtFeatures : [];
const sketchPlanes = snapshot?.sketchPlanes || {};
const solids = [];
const meshCache = new Map();
let bodySeq = 0;
for (const feature of builtFeatures) {
if (feature?.type === 'extrude') {
const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : [];
if (!profiles.length) continue;
const params = feature?.params || {};
const depth = Math.max(0.0001, Math.abs(Number(params.depth ?? params.distance ?? 1)));
const symmetric = params.symmetric === true;
const direction = params.direction === 'reverse' ? 'reverse' : 'normal';
const operation = ['new', 'add', 'subtract'].includes(String(params.operation || 'new'))
? String(params.operation || 'new')
: 'new';
const localZShift = symmetric ? (-depth / 2) : (direction === 'reverse' ? -depth : 0);
const createdBodyIds = [];
const bySketch = new Map();
for (const profileTarget of profiles) {
const { sketchId, profileId } = resolveProfileTargetRef(profileTarget);
if (!sketchId || !profileId) continue;
const profileLoops = profileLoopsFromSnapshot(snapshot, profileTarget);
if (!profileLoops?.length) continue;
const basis = basisFromPlaneFrame(sketchPlanes?.[sketchId] || {});
if (!bySketch.has(sketchId)) {
bySketch.set(sketchId, { sketchId, basis, entries: [] });
}
bySketch.get(sketchId).entries.push({ profileTarget, profileLoops });
}
for (const sketchPack of bySketch.values()) {
const resolvedRegions = unionSelectedRegions(sketchPack.entries.map(e => e.profileLoops));
for (const region of resolvedRegions) {
const polygons = [
region.outer,
...(region.holes || [])
].map(loop => loop.map(p => [p.x || 0, p.y || 0]));
if (!polygons.length) continue;
const bodyIndex = bodySeq++;
const id = makeBodyId(feature.id, bodyIndex);
let primaryTarget = null;
const contributingProfileKeys = [];
for (const entry of sketchPack.entries) {
const { key } = resolveProfileTargetRef(entry?.profileTarget || {});
if (!key) continue;
let contributes = false;
const loops = Array.isArray(entry?.profileLoops) ? entry.profileLoops : [];
for (const loop of loops) {
const sample = loopCentroid(loop);
if (sample && pointInRegion(sample, region)) {
contributes = true;
break;
}
}
if (contributes) {
contributingProfileKeys.push(key);
if (!primaryTarget) primaryTarget = entry.profileTarget || null;
}
}
if (!primaryTarget) {
primaryTarget = sketchPack.entries[0]?.profileTarget || null;
}
const primaryRef = resolveProfileTargetRef(primaryTarget || {});
if (!contributingProfileKeys.length && primaryRef?.key) {
contributingProfileKeys.push(primaryRef.key);
}
const body = {
id,
name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
profile: primaryTarget,
profile_keys: contributingProfileKeys
},
provenance: buildSeedProvenance(feature, primaryTarget, bodyIndex),
mesh: null,
status: 'pending_manifold'
};
const result = await extrudePolygons(polygons, depth);
if (result?.mesh) {
const meshWorld = transformMeshToWorld(result.mesh, sketchPack.basis, localZShift);
body.status = 'manifold_mesh_ready';
body.mesh = {
tri_count: (result.mesh?.triVerts?.length || 0) / 3,
vert_count: (result.mesh?.vertProperties?.length || 0) / Math.max(1, result.mesh?.numProp || 3)
};
body.extrude = { depth, direction, symmetric };
if (meshWorld) {
meshCache.set(id, meshWorld);
createdBodyIds.push(id);
}
result.manifold?.delete?.();
}
solids.push(body);
}
}
if ((operation === 'add' || operation === 'subtract') && createdBodyIds.length) {
const targetIds = Array.isArray(feature?.input?.targets)
? feature.input.targets.map(id => String(id || '')).filter(Boolean)
: [];
const createdSolids = createdBodyIds.map(id => solids.find(s => s?.id === id)).filter(Boolean);
const targetSolids = targetIds.map(id => solids.find(s => s?.id === id)).filter(Boolean);
const toolMeshes = createdSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
let merge = null;
if (operation === 'add') {
const meshes = [...targetMeshes, ...toolMeshes];
if (meshes.length >= 2) merge = await booleanMeshes(meshes, 'add');
} else if (operation === 'subtract') {
if (targetMeshes.length && toolMeshes.length) {
merge = await booleanMeshes({ mode: 'subtract', targets: targetMeshes, tools: toolMeshes });
}
}
if (merge?.mesh?.positions?.length && merge?.mesh?.indices?.length) {
const consumed = new Set([...targetSolids.map(s => s.id), ...createdSolids.map(s => s.id)]);
const sketchIds = new Set();
for (const solid of [...targetSolids, ...createdSolids]) {
for (const sid of getSketchIdsForSolid(solid)) {
sketchIds.add(sid);
}
meshCache.delete(solid?.id);
}
const kept = solids.filter(s => !consumed.has(s?.id));
solids.length = 0;
solids.push(...kept);
const bodyIndex = bodySeq++;
const id = makeBodyId(feature.id, bodyIndex);
const body = {
id,
name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
operation,
targets: targetIds,
tools: createdBodyIds,
sketch_ids: Array.from(sketchIds)
},
provenance: {
source: {
feature_id: feature.id,
feature_type: feature.type,
operation,
targets: targetIds,
tools: createdBodyIds
},
parents: [...targetIds, ...createdBodyIds]
},
mesh: {
tri_count: (merge.mesh?.indices?.length || 0) / 3,
vert_count: (merge.mesh?.positions?.length || 0) / 3
},
status: 'manifold_extrude_boolean_ready'
};
meshCache.set(id, merge.mesh);
solids.push(body);
}
}
continue;
}
if (feature?.type === 'boolean') {
const mode = String(feature?.params?.mode || 'add');
const targets = Array.isArray(feature?.input?.targets)
? feature.input.targets.map(id => String(id || '')).filter(Boolean)
: [];
const tools = Array.isArray(feature?.input?.tools)
? feature.input.tools.map(id => String(id || '')).filter(Boolean)
: [];
const selectedIds = mode === 'subtract'
? Array.from(new Set([...targets, ...tools]))
: targets.slice();
if (!selectedIds.length) continue;
const selectedSet = new Set(selectedIds);
const targetSolids = targets.map(id => solids.find(s => s?.id === id)).filter(Boolean);
const toolSolids = tools.map(id => solids.find(s => s?.id === id)).filter(Boolean);
if (mode === 'subtract') {
if (!targetSolids.length || !toolSolids.length) continue;
} else if (targetSolids.length < 2) {
continue;
}
const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
const toolMeshes = toolSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
if (mode === 'subtract') {
if (!targetMeshes.length || !toolMeshes.length) continue;
} else if (targetMeshes.length < 2) {
continue;
}
const sketchIds = new Set();
for (const solid of [...targetSolids, ...toolSolids]) {
for (const sid of getSketchIdsForSolid(solid)) sketchIds.add(sid);
}
const result = mode === 'subtract'
? await booleanMeshes({ mode, targets: targetMeshes, tools: toolMeshes })
: await booleanMeshes(targetMeshes, mode);
const kept = solids.filter(s => !selectedSet.has(s?.id));
for (const target of [...targetSolids, ...toolSolids]) meshCache.delete(target?.id);
solids.length = 0;
solids.push(...kept);
if (result?.mesh?.positions?.length && result?.mesh?.indices?.length) {
const bodyIndex = bodySeq++;
const id = makeBodyId(feature.id, bodyIndex);
const body = {
id,
name: `${feature.name || 'Boolean'}-${bodyIndex + 1}`,
visible: feature.visible !== false,
source: {
feature_id: feature.id,
feature_type: feature.type,
targets,
tools,
solids: selectedIds,
mode,
sketch_ids: Array.from(sketchIds)
},
provenance: {
source: {
feature_id: feature.id,
feature_type: feature.type,
targets,
tools,
solids: selectedIds,
mode
},
parents: selectedIds
},
mesh: {
tri_count: (result.mesh?.indices?.length || 0) / 3,
vert_count: (result.mesh?.positions?.length || 0) / 3
},
status: 'manifold_boolean_ready'
};
meshCache.set(id, result.mesh);
solids.push(body);
}
continue;
}
if (feature?.type === 'chamfer') {
const bodySeqRef = { value: bodySeq };
const changed = await applyChamferFeature(
solids,
meshCache,
feature,
makeBodyId,
bodySeqRef
);
bodySeq = bodySeqRef.value;
if (changed) {
// keep processing downstream features against updated solids
}
}
}
return { solids, meshCache };
}
async function rebuildGeneratedSolids(api, options = {}) {
const doc = api.document.current;
if (!doc) return { solids: [], meshCache: new Map() };
const builtFeatures = api.features.listBuilt();
const sketchPlanes = {};
for (const feature of (api.features.list() || [])) {
if (feature?.type === 'sketch' && feature?.id) {
sketchPlanes[feature.id] = feature.plane || {};
}
}
const profileLoops = {};
for (const feature of builtFeatures) {
if (feature?.type !== 'extrude') continue;
const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : [];
for (const profileTarget of profiles) {
const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget);
if (!sketchId || !profileId) continue;
const loops = profileLoopsFromRuntime(api, profileTarget);
if (!loops?.length) continue;
profileLoops[key] = loops;
}
}
const { solids, meshCache } = await rebuildGeneratedSolidsFromSnapshot({
builtFeatures,
sketchPlanes,
profileLoops
}, options);
doc.generated = doc.generated || {};
doc.generated.solids = solids;
if (options.persist !== false) {
await api.document.save({
kind: 'micro',
opType: 'solid.rebuild',
undoable: false,
clearRedo: false,
payload: {
reason: options.reason || 'rebuild',
solids: solids.length
}
});
}
return { solids, meshCache };
}
export {
rebuildGeneratedSolids,
rebuildGeneratedSolidsFromSnapshot
};

1451
src/void/toolbar.js Normal file

File diff suppressed because it is too large Load diff

82
src/void/tree.js Normal file
View file

@ -0,0 +1,82 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $ } from '../moto/webui.js';
import { api } from './api.js';
import { properties } from './properties.js';
import * as modelOps from './tree/model.js';
import * as renderOps from './tree/render.js';
const tree = {
container: null,
defaultGeometryExpanded: true,
featuresExpanded: true,
solidsExpanded: true,
selectedFeatureId: null,
selectedFeatureIds: new Set(),
selectedSolidIds: new Set(),
searchQuery: '',
_searchCaret: 0,
_searchRestorePending: false,
_boundRuntimeChanges: false,
build() {
this.container = $('left-panel');
if (!this.container) return;
this.bindRuntimeChanges();
window.addEventListener('void-state-change', () => this.render());
window.addEventListener('void-clear-selection', () => {
this.selectedFeatureId = null;
this.selectedFeatureIds.clear();
this.selectedSolidIds.clear();
api.solids?.setSelected?.([]);
api.sketchRuntime?.setSelected([]);
this.render();
});
this.container.addEventListener('mouseleave', () => {
const planes = api.datum?.getPlanes?.() || [];
for (const plane of planes) {
plane.setHovered(false);
}
api.sketchRuntime?.setHovered(null);
api.solids?.setHovered?.([]);
this.render();
});
window.addEventListener('keydown', event => {
const isDelete = event.key === 'Delete' || event.key === 'Backspace';
if (!isDelete) return;
if (event.defaultPrevented) return;
const activeTag = document.activeElement?.tagName;
const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
if (editing) return;
if (api.interact?.isSketchEditing?.()) return;
const ids = Array.from(this.selectedFeatureIds || []);
if (!ids.length) return;
for (const id of ids) {
const feature = api.features.findById(id);
if (!feature) continue;
api.features.remove(feature);
if (properties.currentFeatureId === id) {
properties.hide();
}
if (api.sketchRuntime?.editingId === id) {
api.sketchRuntime.setEditing(null);
api.interact?.clearSketchSelection?.();
}
}
this.selectedFeatureId = null;
this.selectedFeatureIds.clear();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
event.preventDefault();
});
this.render();
console.log({ tree_built: true });
}
};
Object.assign(tree, modelOps);
Object.assign(tree, renderOps);
export { tree };

836
src/void/tree/model.js Normal file
View file

@ -0,0 +1,836 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { api } from '../api.js';
import { properties } from '../properties.js';
const treePlaneHoverState = new WeakMap();
function getPlaneBaseVisible(plane) {
if (!plane) return true;
if (typeof plane.getBaseVisible === 'function') {
return !!plane.getBaseVisible();
}
return !!plane.getGroup()?.visible;
}
function isPlaneTreeHovered(plane) {
return !!treePlaneHoverState.get(plane);
}
function setPlaneTreeHovered(plane, hovered) {
treePlaneHoverState.set(plane, !!hovered);
}
function applyPlaneTreeVisibility(plane) {
const baseVisible = getPlaneBaseVisible(plane);
const hovered = isPlaneTreeHovered(plane);
const selected = !!api.interact?.selectedPlanes?.has?.(plane);
const group = plane.getGroup?.();
if (group) {
group.visible = baseVisible || hovered || selected;
}
}
function bindRuntimeChanges() {
if (this._boundRuntimeChanges) return;
this._boundRuntimeChanges = true;
api.datum.onChange(() => this.render());
for (const plane of api.datum.getPlanes()) {
plane.onChange(() => this.render());
}
api.origin.onChange(() => this.render());
}
function getActiveEditFeatureId() {
const sketchEditingId = api.sketchRuntime?.editingId || null;
if (sketchEditingId) return sketchEditingId;
return properties.currentFeatureId || null;
}
function getActiveEditIndex() {
const activeId = getActiveEditFeatureId();
if (!activeId) return -1;
const features = api.features.list();
return features.findIndex(feature => feature?.id === activeId);
}
function ensureEditingSketchIsRenderable() {
const editingId = api.sketchRuntime?.editingId;
if (!editingId) return;
const feature = api.features.findById(editingId);
if (!feature || feature.type !== 'sketch' || feature.suppressed === true || !api.features.isBuilt(editingId)) {
api.sketchRuntime?.setEditing(null);
api.interact?.clearSketchSelection?.();
}
}
function getSolidIdsForFeature(featureId) {
if (!featureId) return [];
return (api.solids?.list?.() || [])
.filter(solid => solid?.source?.feature_id === featureId)
.map(solid => solid.id)
.filter(Boolean);
}
function getSketchIdsForSolid(solid) {
const ids = new Set();
const add = value => {
if (value) ids.add(value);
};
add(solid?.source?.profile?.sketchId);
for (const sid of solid?.source?.sketch_ids || []) {
add(sid);
}
add(solid?.provenance?.source?.profile?.sketchId);
for (const face of solid?.provenance?.faces || []) {
add(face?.source?.sketchId);
}
return ids;
}
function getHoverContext() {
const hoveredSolidIds = new Set();
const hoveredSketchIds = new Set();
const hoveredFeatureIds = new Set();
const hoveredProfileKey = api.interact?.hoveredSketchProfileKey || null;
if (hoveredProfileKey) {
const sketchId = String(hoveredProfileKey).split(':')[0];
if (sketchId) hoveredSketchIds.add(sketchId);
}
const hoveredFaceKey = api.interact?.hoveredSolidFaceKey || null;
if (hoveredFaceKey) {
const raw = String(hoveredFaceKey);
const split = raw.lastIndexOf(':');
if (split > 0) {
hoveredSolidIds.add(raw.substring(0, split));
}
}
const hoveredEdgeKey = api.interact?.hoveredSolidEdgeKey || null;
if (hoveredEdgeKey) {
const raw = String(hoveredEdgeKey);
const split = raw.lastIndexOf(':');
if (split > 0) {
hoveredSolidIds.add(raw.substring(0, split));
}
}
if (hoveredSolidIds.size) {
const solids = api.solids?.list?.() || [];
for (const solidId of hoveredSolidIds) {
const solid = solids.find(s => s?.id === solidId);
if (!solid) continue;
const sourceFeatureId = solid?.source?.feature_id || null;
if (sourceFeatureId) hoveredFeatureIds.add(sourceFeatureId);
for (const sketchId of getSketchIdsForSolid(solid)) {
hoveredSketchIds.add(sketchId);
}
}
}
return { hoveredSolidIds, hoveredSketchIds, hoveredFeatureIds };
}
function getFaceSelectionContext() {
const faceKeys = api.solids?.getSelectedFaceKeys?.() || [];
const edgeKeys = api.solids?.getSelectedEdgeKeys?.() || [];
const selectedSolidIds = new Set();
const selectedFeatureIds = new Set();
for (const key of [...faceKeys, ...edgeKeys]) {
const raw = String(key || '');
const split = raw.lastIndexOf(':');
if (split <= 0) continue;
const solidId = raw.substring(0, split);
if (!solidId) continue;
selectedSolidIds.add(solidId);
}
if (selectedSolidIds.size) {
const solids = api.solids?.list?.() || [];
for (const solidId of selectedSolidIds) {
const solid = solids.find(s => s?.id === solidId);
const featureId = solid?.source?.feature_id || null;
if (featureId) selectedFeatureIds.add(featureId);
}
}
return { selectedSolidIds, selectedFeatureIds };
}
function render() {
if (!this.container) return;
this.container.innerHTML = '';
this.container.appendChild(this.createSearchHeader({
value: this.searchQuery || '',
onInput: event => {
const value = event?.target?.value || '';
this.searchQuery = String(value);
this._searchRestorePending = true;
this._searchCaret = Number.isFinite(event?.target?.selectionStart) ? event.target.selectionStart : this.searchQuery.length;
this.render();
},
onClear: () => {
this.searchQuery = '';
this._searchRestorePending = true;
this._searchCaret = 0;
this.render();
},
onFocus: () => {}
}));
this.container.appendChild(this.createDivider());
this.renderDefaultGeometrySection();
this.container.appendChild(this.createDivider());
this.renderFeaturesSection();
this.container.appendChild(this.createDivider());
this.renderSolidsSection();
if (this._searchRestorePending) {
const input = this.container.querySelector('.tree-search-input');
if (input) {
input.focus();
const caret = Number.isFinite(this._searchCaret) ? this._searchCaret : String(input.value || '').length;
input.setSelectionRange(caret, caret);
}
this._searchRestorePending = false;
}
}
function onFeatureSelected(feature) {
if (!this.selectedFeatureIds) {
this.selectedFeatureIds = new Set();
}
const id = feature?.id || null;
if (!id) return;
if (this.selectedFeatureIds.has(id)) {
this.selectedFeatureIds.delete(id);
} else {
this.selectedFeatureIds.add(id);
}
this.selectedSolidIds?.clear?.();
api.solids?.setSelected?.([]);
this.selectedFeatureId = this.selectedFeatureIds.values().next().value || null;
api.sketchRuntime?.setEditing(null);
api.interact?.selectedSketchProfiles?.clear?.();
api.interact.hoveredSketchProfileKey = null;
api.sketchRuntime?.setSelectedProfiles?.([]);
api.sketchRuntime?.setHoveredProfile?.(null);
const selectedSketchIds = Array.from(this.selectedFeatureIds).filter(fid => api.features.findById(fid)?.type === 'sketch');
api.sketchRuntime?.setSelected(selectedSketchIds);
api.interact?.clearSketchSelection?.();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
function onFeatureEdit(feature) {
this.selectedFeatureId = null;
if (!this.selectedFeatureIds) {
this.selectedFeatureIds = new Set();
}
this.selectedFeatureIds.clear();
if (!this.selectedSolidIds) {
this.selectedSolidIds = new Set();
} else {
this.selectedSolidIds.clear();
}
api.interact?.deselectAll?.();
api.solids?.setSelected?.([]);
api.solids?.clearFaceSelection?.();
api.interact?.selectedSketchProfiles?.clear?.();
api.interact.hoveredSketchProfileKey = null;
api.sketchRuntime?.setSelectedProfiles?.([]);
api.sketchRuntime?.setHoveredProfile?.(null);
api.sketchRuntime?.setSelected([]);
if (feature?.type === 'sketch') {
api.sketchRuntime?.setEditing(feature.id);
api.interact?.clearSketchSelection?.();
api.interact?.setSketchTool?.('select');
} else if (feature?.type === 'extrude') {
api.sketchRuntime?.setEditing(null);
api.interact?.clearSketchSelection?.();
} else if (feature?.type === 'boolean') {
api.sketchRuntime?.setEditing(null);
api.interact?.clearSketchSelection?.();
} else {
api.sketchRuntime?.setEditing(null);
api.interact?.clearSketchSelection?.();
}
properties.showFeature(feature, {
onChange: () => this.render()
});
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
function renderDefaultGeometrySection() {
const datum = api.datum;
const row = this.createRow({
label: 'Default Geometry',
depth: 0,
expanded: this.defaultGeometryExpanded,
onToggle: () => {
this.defaultGeometryExpanded = !this.defaultGeometryExpanded;
this.render();
}
});
this.container.appendChild(row);
if (!this.defaultGeometryExpanded) {
return;
}
const geometryRows = [
{ type: 'plane', key: 'xy', fallbackLabel: 'Top' },
{ type: 'plane', key: 'yz', fallbackLabel: 'Right' },
{ type: 'plane', key: 'xz', fallbackLabel: 'Front' },
{ type: 'origin', label: 'Origin' }
];
for (const entry of geometryRows) {
if (entry.type === 'origin') {
const visible = api.origin.isVisible();
const selected = !!api.interact?.selectedPoints?.has?.('origin-point');
this.container.appendChild(this.createRow({
label: entry.label,
depth: 1,
eyeVisible: visible,
selected,
onSelect: () => {
api.interact.selectPoint('origin-point', { ctrlKey: true, metaKey: false });
this.render();
},
onEye: () => {
api.origin.setVisible(!visible);
this.render();
}
}));
continue;
}
const plane = datum.getPlane(entry.key);
if (!plane) continue;
applyPlaneTreeVisibility(plane);
const visible = getPlaneBaseVisible(plane);
const selected = !!api.interact?.selectedPlanes?.has?.(plane);
this.container.appendChild(this.createRow({
label: plane.getLabel() || entry.fallbackLabel,
depth: 1,
eyeVisible: visible,
selected,
onSelect: () => {
api.interact.selectPlane(plane, { ctrlKey: true, metaKey: false });
applyPlaneTreeVisibility(plane);
this.render();
},
onHoverEnter: () => {
setPlaneTreeHovered(plane, true);
applyPlaneTreeVisibility(plane);
plane.setHovered(true);
},
onHoverLeave: () => {
plane.setHovered(false);
setPlaneTreeHovered(plane, false);
applyPlaneTreeVisibility(plane);
},
onEye: () => {
const next = !visible;
plane.setVisible(next);
applyPlaneTreeVisibility(plane);
this.render();
}
}));
}
}
function renderFeaturesSection() {
const hover = getHoverContext();
const faceSelection = getFaceSelectionContext();
const row = this.createRow({
label: 'Features',
depth: 0,
expanded: this.featuresExpanded,
onToggle: () => {
this.featuresExpanded = !this.featuresExpanded;
this.render();
}
});
this.container.appendChild(row);
if (!this.featuresExpanded) {
return;
}
const doc = api.document.current;
const folders = this.getFolders(doc);
const allFeatures = api.features.list();
const needle = String(this.searchQuery || '').trim().toLowerCase();
const filtering = needle.length > 0;
const features = filtering
? allFeatures.filter(feature => String(feature?.name || feature?.type || 'feature').toLowerCase().includes(needle))
: allFeatures;
const featureIds = new Set(allFeatures.map(f => f?.id).filter(Boolean));
for (const id of Array.from(this.selectedFeatureIds || [])) {
if (!featureIds.has(id)) {
this.selectedFeatureIds.delete(id);
}
}
const hasOnlyDefaultFolder = folders.length === 1 && folders[0]?.id === 'features';
const timelineCount = api.document.getTimelineCount();
const activeEditIndex = getActiveEditIndex();
const markerCount = this.timelinePointerDrag ? this.timelineDragTargetCount : timelineCount;
const setTimeline = async next => {
const changed = await api.document.setTimelineCount(next);
if (!changed) return;
ensureEditingSketchIsRenderable();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
};
const timelineCountFromPointer = event => {
const featuresAll = api.features.list();
if (!featuresAll.length) return 0;
const el = document.elementFromPoint(event.clientX, event.clientY);
const row = el?.closest?.('.tree-item-row');
if (row?.dataset?.featureIndex !== undefined) {
const index = Number(row.dataset.featureIndex);
if (Number.isFinite(index)) {
const rect = row.getBoundingClientRect();
const before = event.clientY < (rect.top + rect.height / 2);
return before ? index : index + 1;
}
}
const rows = Array.from(this.container.querySelectorAll('.tree-item-row[data-feature-index]'));
if (!rows.length) return 0;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
if (event.clientY < firstRect.top) return 0;
if (event.clientY > lastRect.bottom) return featuresAll.length;
return this.timelineDragTargetCount ?? timelineCount;
};
const beginTimelinePointerDrag = event => {
this.timelinePointerDrag = true;
this.timelineDragTargetCount = timelineCountFromPointer(event);
const onMove = moveEvent => {
if (!this.timelinePointerDrag) return;
const next = timelineCountFromPointer(moveEvent);
if (next !== this.timelineDragTargetCount) {
this.timelineDragTargetCount = next;
this.render();
}
};
const onUp = async upEvent => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
const finalCount = timelineCountFromPointer(upEvent);
this.timelinePointerDrag = false;
this.timelineDragTargetCount = null;
await setTimeline(finalCount);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const dropMove = (targetFeature, before = true) => {
const dragId = this.dragFeatureId || null;
const targetId = targetFeature?.id || null;
if (!dragId || !targetId || dragId === targetId) return;
const list = api.features.list();
const fromIndex = list.findIndex(f => f?.id === dragId);
const targetIndex = list.findIndex(f => f?.id === targetId);
if (fromIndex < 0 || targetIndex < 0) return;
let toIndex = targetIndex + (before ? 0 : 1);
if (fromIndex < toIndex) {
toIndex -= 1;
}
if (api.features.move(dragId, toIndex)) {
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
};
if (hasOnlyDefaultFolder) {
if (!features.length) {
this.container.appendChild(this.createEmptyRow(filtering ? 'No matching features' : 'No features yet', 1));
return;
}
for (let displayIndex = 0; displayIndex < features.length; displayIndex++) {
const feature = features[displayIndex];
const index = allFeatures.indexOf(feature);
if (!filtering && markerCount === index) {
this.container.appendChild(this.createTimelineMarkerRow({
active: true,
onSelect: () => setTimeline(index),
onPointerStart: beginTimelinePointerDrag
}));
}
const label = feature?.name || feature?.type || 'Feature';
const isSketch = feature?.type === 'sketch';
const isExtrude = feature?.type === 'extrude';
const isChamfer = feature?.type === 'chamfer';
const isBoolean = feature?.type === 'boolean';
const visible = feature?.visible !== false;
const suppressed = feature?.suppressed === true;
const beyondTimeline = !api.features.isIndexBuilt(index);
const blockedByEditing = activeEditIndex >= 0 && index > activeEditIndex;
this.container.appendChild(this.createItemRow(label, feature, 1, {
featureIndex: index,
selected: this.selectedFeatureIds?.has?.(feature?.id) || faceSelection.selectedFeatureIds.has(feature?.id),
hovered: !!(hover.hoveredFeatureIds.has(feature?.id) || (isSketch && hover.hoveredSketchIds.has(feature?.id))),
eyeVisible: visible,
suppressed,
beyondTimeline,
disabled: blockedByEditing,
onEye: isSketch ? f => {
api.features.setVisible(f.id, f.visible === false);
this.render();
} : null,
actions: [
{
label: suppressed ? '▶' : '⏸',
title: suppressed ? 'Unsuppress feature' : 'Suppress feature',
className: suppressed ? 'is-suppressed' : '',
onClick: f => {
api.features.setSuppressed(f.id, f.suppressed !== true);
ensureEditingSketchIsRenderable();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
}
],
draggable: true,
isTimelineDragging: () => false,
onDragStart: f => {
this.dragFeatureId = f?.id || null;
},
onDragOver: (_f, event) => {
event.preventDefault();
},
onDrop: (f, event, info) => {
event.preventDefault();
dropMove(f, info?.before !== false);
this.dragFeatureId = null;
},
onDragEnd: () => {
this.dragFeatureId = null;
},
onSelect: f => this.onFeatureSelected(f),
onEdit: f => this.onFeatureEdit(f),
onHoverEnter: f => {
if (isSketch) api.sketchRuntime?.setHovered(f.id);
if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.(getSolidIdsForFeature(f?.id));
},
onHoverLeave: () => {
if (isSketch) api.sketchRuntime?.setHovered(null);
if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.([]);
}
}));
}
if (!filtering && markerCount === allFeatures.length) {
this.container.appendChild(this.createTimelineMarkerRow({
active: true,
onSelect: () => setTimeline(allFeatures.length),
onPointerStart: beginTimelinePointerDrag
}));
}
return;
}
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
this.container.appendChild(this.createRow({
label: folder.name || 'Folder',
depth: 1,
expanded: !folder.collapsed,
onToggle: () => {
folder.collapsed = !folder.collapsed;
api.document.save({
kind: 'micro',
opType: 'tree.folder.toggle',
undoable: false,
payload: { folder_id: folder.id, collapsed: !!folder.collapsed }
});
this.render();
}
}));
if (folder.collapsed) {
continue;
}
const items = i === 0 ? features : [];
if (!items.length && i === 0) {
this.container.appendChild(this.createEmptyRow(filtering ? 'No matching features' : 'No features yet', 2));
}
for (let localIndex = 0; localIndex < items.length; localIndex++) {
const feature = items[localIndex];
const index = allFeatures.indexOf(feature);
if (!filtering && i === 0 && markerCount === index) {
this.container.appendChild(this.createTimelineMarkerRow({
active: true,
onSelect: () => setTimeline(index),
onPointerStart: beginTimelinePointerDrag
}));
}
const label = feature?.name || feature?.type || 'Feature';
const isSketch = feature?.type === 'sketch';
const isExtrude = feature?.type === 'extrude';
const isChamfer = feature?.type === 'chamfer';
const isBoolean = feature?.type === 'boolean';
const visible = feature?.visible !== false;
const suppressed = feature?.suppressed === true;
const beyondTimeline = !api.features.isIndexBuilt(index);
const blockedByEditing = activeEditIndex >= 0 && index > activeEditIndex;
this.container.appendChild(this.createItemRow(label, feature, 2, {
featureIndex: index,
selected: this.selectedFeatureIds?.has?.(feature?.id) || faceSelection.selectedFeatureIds.has(feature?.id),
hovered: !!(hover.hoveredFeatureIds.has(feature?.id) || (isSketch && hover.hoveredSketchIds.has(feature?.id))),
eyeVisible: visible,
suppressed,
beyondTimeline,
disabled: blockedByEditing,
onEye: isSketch ? f => {
api.features.setVisible(f.id, f.visible === false);
this.render();
} : null,
actions: [
{
label: suppressed ? '▶' : '⏸',
title: suppressed ? 'Unsuppress feature' : 'Suppress feature',
className: suppressed ? 'is-suppressed' : '',
onClick: f => {
api.features.setSuppressed(f.id, f.suppressed !== true);
ensureEditingSketchIsRenderable();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
}
}
],
draggable: true,
isTimelineDragging: () => false,
onDragStart: f => {
this.dragFeatureId = f?.id || null;
},
onDragOver: (_f, event) => {
event.preventDefault();
},
onDrop: (f, event, info) => {
event.preventDefault();
dropMove(f, info?.before !== false);
this.dragFeatureId = null;
},
onDragEnd: () => {
this.dragFeatureId = null;
},
onSelect: f => this.onFeatureSelected(f),
onEdit: f => this.onFeatureEdit(f),
onHoverEnter: f => {
if (isSketch) api.sketchRuntime?.setHovered(f.id);
if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.(getSolidIdsForFeature(f?.id));
},
onHoverLeave: () => {
if (isSketch) api.sketchRuntime?.setHovered(null);
if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.([]);
}
}));
}
if (!filtering && i === 0 && items.length && markerCount === allFeatures.length) {
this.container.appendChild(this.createTimelineMarkerRow({
active: true,
onSelect: () => setTimeline(allFeatures.length),
onPointerStart: beginTimelinePointerDrag
}));
}
}
}
function renderSolidsSection() {
const hover = getHoverContext();
const faceSelection = getFaceSelectionContext();
const row = this.createRow({
label: 'Solids',
depth: 0,
expanded: this.solidsExpanded,
onToggle: () => {
this.solidsExpanded = !this.solidsExpanded;
this.render();
}
});
this.container.appendChild(row);
if (!this.solidsExpanded) {
return;
}
const needle = String(this.searchQuery || '').trim().toLowerCase();
const filtering = needle.length > 0;
const solidsAll = api.solids?.list?.() || [];
const solids = filtering
? solidsAll.filter(solid => String(solid?.name || 'solid').toLowerCase().includes(needle))
: solidsAll;
const activeEditIndex = getActiveEditIndex();
if (!solids.length) {
this.container.appendChild(this.createEmptyRow(filtering ? 'No matching solids' : 'No solids yet', 1));
return;
}
for (const solid of solids) {
const label = solid?.name || 'Solid';
const visible = solid?.visible !== false;
const sourceFeatureId = solid?.source?.feature_id || null;
const featureIndex = sourceFeatureId
? api.features.list().findIndex(feature => feature?.id === sourceFeatureId)
: -1;
const blockedByEditing = activeEditIndex >= 0 && featureIndex > activeEditIndex;
this.container.appendChild(this.createItemRow(label, solid, 1, {
selected: this.selectedSolidIds?.has?.(solid?.id) || faceSelection.selectedSolidIds.has(solid?.id),
hovered: hover.hoveredSolidIds.has(solid?.id),
eyeVisible: visible,
disabled: blockedByEditing,
onEye: item => {
const doc = api.document.current;
if (!doc?.generated?.solids) return;
const target = doc.generated.solids.find(s => s?.id === item?.id);
if (!target) return;
target.visible = target.visible === false;
api.document.save({
kind: 'micro',
opType: 'solid.update',
undoable: false,
payload: { id: target.id, field: 'visible', value: !!target.visible }
});
api.solids?.syncRuntime?.();
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
},
onSelect: (item, event) => {
const id = item?.id || null;
if (!id) return;
if (!this.selectedSolidIds) this.selectedSolidIds = new Set();
const currentFeature = properties.currentFeatureId ? api.features.findById(properties.currentFeatureId) : null;
const editingBoolean = currentFeature?.type === 'boolean' && currentFeature?.id === properties.currentFeatureId;
const editingExtrude = currentFeature?.type === 'extrude' && currentFeature?.id === properties.currentFeatureId;
const extrudeOperation = String(currentFeature?.params?.operation || 'new');
const editingExtrudeTargets = editingExtrude && (extrudeOperation === 'add' || extrudeOperation === 'subtract');
const multi = editingBoolean || editingExtrudeTargets || !!(event?.ctrlKey || event?.metaKey);
if (!multi) {
if (this.selectedSolidIds.size === 1 && this.selectedSolidIds.has(id)) {
this.selectedSolidIds.clear();
} else {
this.selectedSolidIds.clear();
this.selectedSolidIds.add(id);
}
} else if (this.selectedSolidIds.has(id)) {
this.selectedSolidIds.delete(id);
} else {
this.selectedSolidIds.add(id);
}
if (!editingBoolean && !editingExtrudeTargets) {
this.selectedFeatureIds?.clear?.();
this.selectedFeatureId = null;
} else {
if (editingBoolean) {
const mode = String(currentFeature?.params?.mode || 'add');
const role = properties.getBooleanPickRole?.() || 'targets';
const input = currentFeature?.input || {};
const targets = Array.isArray(input.targets) ? input.targets.filter(Boolean) : [];
const tools = Array.isArray(input.tools) ? input.tools.filter(Boolean) : [];
let nextTargets = targets.slice();
let nextTools = tools.slice();
if (mode === 'subtract') {
if (role === 'tools') {
nextTargets = nextTargets.filter(sid => sid !== id);
if (this.selectedSolidIds.has(id)) {
if (!nextTools.includes(id)) nextTools.push(id);
} else {
nextTools = nextTools.filter(sid => sid !== id);
}
} else {
nextTools = nextTools.filter(sid => sid !== id);
if (this.selectedSolidIds.has(id)) {
if (!nextTargets.includes(id)) nextTargets.push(id);
} else {
nextTargets = nextTargets.filter(sid => sid !== id);
}
}
} else {
nextTargets = Array.from(this.selectedSolidIds);
nextTools = [];
}
const next = mode === 'subtract'
? Array.from(new Set([...nextTargets, ...nextTools]))
: nextTargets.slice();
api.features.update(currentFeature.id, feature => {
feature.input = feature.input || {};
feature.input.targets = nextTargets;
feature.input.tools = nextTools;
}, {
opType: 'feature.update',
payload: { field: 'boolean.inputs', targets: nextTargets, tools: nextTools }
});
this.selectedSolidIds = new Set(next);
} else if (editingExtrudeTargets) {
const nextTargets = Array.from(this.selectedSolidIds);
api.features.update(currentFeature.id, feature => {
feature.input = feature.input || {};
feature.input.targets = nextTargets;
}, {
opType: 'feature.update',
payload: { field: 'targets', value: nextTargets }
});
this.selectedSolidIds = new Set(nextTargets);
}
properties.onChanged?.();
}
api.solids?.setSelected?.(Array.from(this.selectedSolidIds));
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
},
onEdit: item => {
const id = item?.id || null;
if (!id) return;
const doc = api.document.current;
if (!doc?.generated?.solids) return;
const target = doc.generated.solids.find(s => s?.id === id);
if (!target) return;
const next = window.prompt('Rename solid', target.name || 'Solid');
if (next === null) return;
const name = String(next || '').trim();
if (!name || name === target.name) return;
target.name = name;
api.document.save({
kind: 'micro',
opType: 'solid.update',
undoable: false,
payload: { id: target.id, field: 'name', value: name }
});
this.render();
window.dispatchEvent(new CustomEvent('void-state-change'));
},
onHoverEnter: item => {
const id = item?.id || null;
if (!id) return;
api.solids?.setHovered?.([id]);
},
onHoverLeave: () => {
api.solids?.setHovered?.([]);
}
}));
}
}
function getFolders(doc) {
if (!doc) {
return [{ id: 'features', name: 'Features', collapsed: false }];
}
if (!doc.tree || !Array.isArray(doc.tree.folders) || doc.tree.folders.length === 0) {
doc.tree = {
folders: [{ id: 'features', name: 'Features', collapsed: false }]
};
}
return doc.tree.folders;
}
export {
bindRuntimeChanges,
render,
renderDefaultGeometrySection,
renderFeaturesSection,
renderSolidsSection,
getFolders,
onFeatureSelected,
onFeatureEdit
};

322
src/void/tree/render.js Normal file
View file

@ -0,0 +1,322 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createHeader(text) {
const el = document.createElement('div');
el.className = 'tree-header';
el.textContent = text;
return el;
}
function createSearchHeader({ value = '', onInput, onClear, onFocus, onBlur } = {}) {
const wrap = document.createElement('div');
wrap.className = 'tree-search';
const icon = document.createElement('span');
icon.className = 'tree-search-icon';
icon.textContent = '🔍';
wrap.appendChild(icon);
const input = document.createElement('input');
input.className = 'tree-search-input';
input.type = 'text';
input.placeholder = 'Search';
input.value = value || '';
input.autocomplete = 'off';
input.spellcheck = false;
input.oninput = event => onInput?.(event);
input.onfocus = event => onFocus?.(event);
input.onblur = event => onBlur?.(event);
wrap.appendChild(input);
const clear = document.createElement('button');
clear.className = 'tree-search-clear';
clear.textContent = '×';
clear.title = 'Clear search';
clear.style.visibility = (value && String(value).length) ? 'visible' : 'hidden';
clear.onclick = event => {
event.stopPropagation();
input.value = '';
onClear?.();
input.focus();
};
wrap.appendChild(clear);
return wrap;
}
function createDivider() {
const el = document.createElement('div');
el.className = 'tree-divider';
return el;
}
function createRow({ label, depth = 0, expanded, onToggle, eyeVisible, onEye, onSelect, onHoverEnter, onHoverLeave, selected = false, hovered = false }) {
const row = document.createElement('div');
row.className = 'tree-row';
if (selected) {
row.classList.add('active');
}
if (hovered) {
row.classList.add('hovered');
}
if (onEye && eyeVisible === false) {
row.classList.add('is-off');
}
row.style.paddingLeft = `${8 + depth * 16}px`;
const left = document.createElement('div');
left.className = 'tree-row-left';
if (onToggle) {
const twisty = document.createElement('button');
twisty.className = 'tree-twisty';
twisty.textContent = expanded ? '▾' : '▸';
twisty.onclick = event => {
event.stopPropagation();
onToggle();
};
left.appendChild(twisty);
} else {
const spacer = document.createElement('span');
spacer.className = 'tree-twisty-spacer';
spacer.textContent = '';
left.appendChild(spacer);
}
const text = document.createElement('div');
text.className = 'tree-row-label';
text.textContent = label;
left.appendChild(text);
row.appendChild(left);
if (onEye) {
const eye = document.createElement('button');
eye.className = `tree-eye ${eyeVisible ? 'visible' : 'off'}`;
eye.textContent = '👁';
eye.title = eyeVisible ? 'Hide' : 'Show';
eye.onclick = event => {
event.stopPropagation();
onEye();
};
row.appendChild(eye);
}
if (typeof onSelect === 'function') {
row.onclick = event => onSelect(event);
}
if (typeof onHoverEnter === 'function') {
row.onmouseenter = () => onHoverEnter();
}
if (typeof onHoverLeave === 'function') {
row.onmouseleave = () => onHoverLeave();
}
return row;
}
function createTimelineMarkerRow({ active = false, onSelect, onPointerStart }) {
const row = document.createElement('div');
row.className = `tree-timeline-marker ${active ? 'active' : ''}`;
row.onclick = () => onSelect?.();
row.onmousedown = event => {
if (event.button !== 0) return;
onSelect?.();
onPointerStart?.(event);
event.preventDefault();
};
row.title = 'Move history marker';
const line = document.createElement('div');
line.className = 'tree-timeline-line';
row.appendChild(line);
return row;
}
function createItemRow(label, feature, depth = 0, opts = {}) {
const row = document.createElement('div');
row.className = 'tree-item-row';
const disabled = !!opts.disabled;
if (opts.selected) {
row.classList.add('active');
}
if (opts.hovered) {
row.classList.add('hovered');
}
if (opts.eyeVisible === false) {
row.classList.add('is-off');
}
if (opts.suppressed) {
row.classList.add('is-suppressed');
}
if (opts.beyondTimeline) {
row.classList.add('is-future');
}
if (disabled) {
row.classList.add('is-disabled');
}
row.style.paddingLeft = `${8 + depth * 16}px`;
if (Number.isFinite(opts.featureIndex)) {
row.dataset.featureIndex = String(opts.featureIndex);
}
if (opts.draggable && !disabled) {
row.draggable = true;
}
const left = document.createElement('div');
left.className = 'tree-row-left';
const icon = document.createElement('span');
icon.className = 'tree-item-icon';
icon.textContent = this.getIcon(feature?.type);
const text = document.createElement('div');
text.className = 'tree-row-label';
text.textContent = label;
left.appendChild(icon);
left.appendChild(text);
row.appendChild(left);
const actions = Array.isArray(opts.actions) ? opts.actions : [];
if (actions.length && !disabled) {
const actionWrap = document.createElement('div');
actionWrap.className = 'tree-item-actions';
for (const action of actions) {
if (!action || typeof action.onClick !== 'function') continue;
const btn = document.createElement('button');
btn.className = 'tree-item-action';
if (action.className) {
btn.classList.add(action.className);
}
btn.textContent = action.label || '•';
btn.title = action.title || '';
btn.disabled = !!action.disabled;
btn.onclick = event => {
event.stopPropagation();
action.onClick(feature);
};
actionWrap.appendChild(btn);
}
row.appendChild(actionWrap);
}
if (!disabled) {
row.onclick = event => {
if (typeof opts.onSelect === 'function') {
opts.onSelect(feature, event);
} else {
console.log('Feature selected:', feature);
}
};
row.ondblclick = () => {
if (typeof opts.onEdit === 'function') {
opts.onEdit(feature);
}
};
}
if (!disabled && typeof opts.onHoverEnter === 'function') {
row.onmouseenter = () => opts.onHoverEnter(feature);
}
if (!disabled && typeof opts.onHoverLeave === 'function') {
row.onmouseleave = () => opts.onHoverLeave(feature);
}
if (!disabled && typeof opts.onEye === 'function') {
const eye = document.createElement('button');
eye.className = `tree-eye ${opts.eyeVisible !== false ? 'visible' : 'off'}`;
eye.textContent = '👁';
eye.title = opts.eyeVisible !== false ? 'Hide' : 'Show';
eye.onclick = event => {
event.stopPropagation();
opts.onEye(feature);
};
row.appendChild(eye);
}
if (!disabled && opts.draggable && typeof opts.onDragStart === 'function') {
row.ondragstart = event => {
row.classList.add('is-dragging');
event.dataTransfer?.setData('text/x-void-feature', String(feature?.id || ''));
event.dataTransfer.effectAllowed = 'move';
opts.onDragStart(feature, event);
};
}
if (!disabled && opts.draggable) {
row.ondragend = () => {
row.classList.remove('is-dragging');
row.classList.remove('drag-over-before');
row.classList.remove('drag-over-after');
if (typeof opts.onDragEnd === 'function') {
opts.onDragEnd(feature);
}
};
}
if (!disabled && opts.draggable && typeof opts.onDragOver === 'function') {
row.ondragover = event => {
event.preventDefault();
const timelineDrag = !!opts.isTimelineDragging?.();
const before = (event.offsetY || 0) < (row.clientHeight / 2);
row.classList.toggle('drag-over-before', before);
row.classList.toggle('drag-over-after', !before);
if (timelineDrag && typeof opts.onTimelineDragOver === 'function') {
opts.onTimelineDragOver(feature, event, { before });
} else {
opts.onDragOver(feature, event, { before });
}
};
}
if (!disabled && opts.draggable) {
row.ondragleave = () => {
row.classList.remove('drag-over-before');
row.classList.remove('drag-over-after');
};
}
if (!disabled && opts.draggable && typeof opts.onDrop === 'function') {
row.ondrop = event => {
event.preventDefault();
const timelineDrag = !!opts.isTimelineDragging?.();
const before = (event.offsetY || 0) < (row.clientHeight / 2);
row.classList.remove('drag-over-before');
row.classList.remove('drag-over-after');
if (timelineDrag && typeof opts.onTimelineDrop === 'function') {
opts.onTimelineDrop(feature, event, { before });
} else {
opts.onDrop(feature, event, { before });
}
};
}
return row;
}
function createEmptyRow(label, depth = 0) {
const row = document.createElement('div');
row.className = 'tree-empty-row';
row.style.paddingLeft = `${8 + depth * 16}px`;
row.textContent = label;
return row;
}
function getIcon(type) {
const icons = {
datum: '□',
sketch: '✏',
extrude: '⬆',
revolve: '↻',
boolean: '∪'
};
return icons[type] || '•';
}
export {
createHeader,
createSearchHeader,
createDivider,
createRow,
createTimelineMarkerRow,
createItemRow,
createEmptyRow,
getIcon
};

315
src/void/viewcube.js Normal file
View file

@ -0,0 +1,315 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { THREE } from '../ext/three.js';
import { space } from '../moto/space.js';
import { VOID_PALETTE } from './palette.js';
const {
Group, BoxGeometry, MeshBasicMaterial, Mesh,
EdgesGeometry, LineSegments, LineBasicMaterial,
Scene, PerspectiveCamera, Vector3, Raycaster, Vector2
} = THREE;
/**
* ViewCube - Interactive 3D navigation widget
* Renders in top-right corner using separate scene/camera to avoid z-fighting
*/
class ViewCube {
constructor(options = {}) {
this.size = options.size || 60; // Size of cube in pixels
this.padding = options.padding || 20; // Padding from corner
this.cubeSize = options.cubeSize || 1.5; // 3D cube size
// Create separate scene and camera for viewcube
this.scene = new Scene();
this.camera = new PerspectiveCamera(45, 1, 0.1, 100);
this.camera.position.set(0, 0, 5);
// Raycaster for mouse interaction
this.raycaster = new Raycaster();
this.mouse = new Vector2();
// State
this.hoveredFace = null;
this.viewport = { x: 0, y: 0, width: this.size, height: this.size }; // CSS pixels, top-left origin
this.enabled = true;
// Face colors
this.faceColors = { ...VOID_PALETTE.viewcube.faces };
this.hoverColor = VOID_PALETTE.viewcube.hover;
this.edgeColor = VOID_PALETTE.viewcube.edge;
// Build the cube
this.build();
// Setup event listeners
this.setupEvents();
}
/**
* Build the viewcube geometry
*/
build() {
this.group = new Group();
// Create cube with individual face materials
const geometry = new BoxGeometry(this.cubeSize, this.cubeSize, this.cubeSize);
// Materials for each face [right, left, top, bottom, front, back]
const materials = [
new MeshBasicMaterial({ color: this.faceColors.right }), // +X right
new MeshBasicMaterial({ color: this.faceColors.left }), // -X left
new MeshBasicMaterial({ color: this.faceColors.top }), // +Y top
new MeshBasicMaterial({ color: this.faceColors.bottom }), // -Y bottom
new MeshBasicMaterial({ color: this.faceColors.front }), // +Z front
new MeshBasicMaterial({ color: this.faceColors.back }) // -Z back
];
this.cube = new Mesh(geometry, materials);
this.cube.userData.isViewCube = true;
// Store original colors for hover restore
this.originalColors = materials.map(m => m.color.getHex());
// Create edges
const edges = new EdgesGeometry(geometry);
const lineMaterial = new LineBasicMaterial({
color: this.edgeColor,
linewidth: 2
});
this.edges = new LineSegments(edges, lineMaterial);
this.group.add(this.cube);
this.group.add(this.edges);
this.scene.add(this.group);
}
/**
* Setup mouse event listeners
*/
setupEvents() {
const { container } = space.internals();
// Mouse move for hover
container.addEventListener('mousemove', (event) => {
if (!this.enabled) return;
this.onMouseMove(event);
});
// Mouse click for view change
container.addEventListener('click', (event) => {
if (!this.enabled) return;
this.onClick(event);
});
}
/**
* Handle mouse move (hover detection)
*/
onMouseMove(event) {
// Convert mouse to viewcube viewport coordinates
if (!this.isMouseInViewport(event)) {
// Mouse outside viewcube, clear hover
if (this.hoveredFace !== null) {
this.clearHover();
}
return;
}
// Get intersection
this.updateMousePosition(event);
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObject(this.cube);
if (intersects.length > 0) {
const faceIndex = Math.floor(intersects[0].faceIndex / 2);
if (this.hoveredFace !== faceIndex) {
this.clearHover();
this.hoveredFace = faceIndex;
this.cube.material[faceIndex].color.setHex(this.hoverColor);
space.update();
}
} else if (this.hoveredFace !== null) {
this.clearHover();
}
}
/**
* Clear hover state
*/
clearHover() {
if (this.hoveredFace !== null) {
this.cube.material[this.hoveredFace].color.setHex(this.originalColors[this.hoveredFace]);
this.hoveredFace = null;
space.update();
}
}
/**
* Handle click (view change)
*/
onClick(event) {
if (!this.isMouseInViewport(event)) return;
this.updateMousePosition(event);
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObject(this.cube);
if (intersects.length > 0) {
const faceIndex = Math.floor(intersects[0].faceIndex / 2);
this.onFaceClick(faceIndex);
}
}
/**
* Handle face click - change view
*/
onFaceClick(faceIndex) {
// Map face index to view direction
// [right, left, top, bottom, front, back]
const views = ['right', 'left', 'top', 'bottom', 'front', 'back'];
const view = views[faceIndex];
// Call space.view preset methods
switch(view) {
case 'front':
space.view.front();
break;
case 'back':
space.view.back();
break;
case 'right':
space.view.right();
break;
case 'left':
space.view.left();
break;
case 'top':
space.view.top();
break;
case 'bottom':
space.view.bottom();
break;
}
}
/**
* Check if mouse is in viewcube viewport
*/
isMouseInViewport(event) {
const { container } = space.internals();
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
return x >= this.viewport.x &&
x <= this.viewport.x + this.viewport.width &&
y >= this.viewport.y &&
y <= this.viewport.y + this.viewport.height;
}
/**
* Update mouse position in viewcube coordinates
*/
updateMousePosition(event) {
const { container } = space.internals();
const rect = container.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// Convert to normalized device coordinates for viewcube viewport
this.mouse.x = ((x - this.viewport.x) / this.viewport.width) * 2 - 1;
this.mouse.y = -((y - this.viewport.y) / this.viewport.height) * 2 + 1;
}
/**
* Update viewcube rotation to match main camera
*/
update() {
if (!this.enabled) return;
// Get main camera orientation
const mainCamera = space.internals().camera;
// Copy inverse rotation - viewcube rotates opposite to scene
this.group.quaternion.copy(mainCamera.quaternion);
this.group.quaternion.invert();
}
/**
* Render the viewcube
*/
render(renderer) {
if (!this.enabled) return;
// Update rotation first
this.update();
// Save current state
const currentViewport = new Vector4();
renderer.getViewport(currentViewport);
const currentAutoClear = renderer.autoClear;
// Calculate viewport position using CSS pixels for hit-testing
const canvas = renderer.domElement;
const cssW = canvas.clientWidth || this.size;
const cssH = canvas.clientHeight || this.size;
const cssX = cssW - this.size - this.padding;
const cssY = this.padding;
this.viewport = { x: cssX, y: cssY, width: this.size, height: this.size };
// Convert CSS pixels to renderer drawing-buffer pixels (bottom-left origin)
const dpr = canvas.width / Math.max(1, cssW);
const vpX = Math.round(cssX * dpr);
const vpY = Math.round((cssH - cssY - this.size) * dpr);
const vpS = Math.round(this.size * dpr);
// Set viewcube viewport
renderer.setViewport(vpX, vpY, vpS, vpS);
renderer.setScissor(vpX, vpY, vpS, vpS);
renderer.setScissorTest(true);
renderer.autoClear = false;
// Ensure the cube is never occluded by main-scene depth.
renderer.clearDepth();
// Render viewcube scene
renderer.render(this.scene, this.camera);
// Restore state
renderer.setViewport(currentViewport);
renderer.setScissorTest(false);
renderer.autoClear = currentAutoClear;
}
/**
* Set visibility
*/
setEnabled(enabled) {
this.enabled = enabled;
if (!enabled) {
this.clearHover();
}
}
/**
* Dispose resources
*/
dispose() {
if (this.cube) {
this.cube.geometry.dispose();
this.cube.material.forEach(m => m.dispose());
}
if (this.edges) {
this.edges.geometry.dispose();
this.edges.material.dispose();
}
}
}
// Import Vector4 for viewport save/restore
const { Vector4 } = THREE;
export { ViewCube };

View file

@ -0,0 +1,59 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { ensureKernel } from '../solid/kernel.js';
import { rebuildGeneratedSolidsFromSnapshot } from '../solid/rebuild.js';
let kernelReady = false;
async function ensureReady() {
if (kernelReady) return;
await ensureKernel();
kernelReady = true;
}
function serializeMeshCache(meshCache) {
const meshes = [];
const transfer = [];
for (const [id, mesh] of meshCache.entries()) {
if (!id || !mesh?.positions?.length || !mesh?.indices?.length) continue;
const positions = mesh.positions instanceof Float32Array
? mesh.positions
: new Float32Array(mesh.positions);
const indices = mesh.indices instanceof Uint32Array
? mesh.indices
: new Uint32Array(mesh.indices);
meshes.push({ id, positions, indices });
transfer.push(positions.buffer, indices.buffer);
}
return { meshes, transfer };
}
self.onmessage = async (event) => {
const msg = event?.data || {};
const id = msg?.id ?? null;
const type = msg?.type || '';
if (type !== 'rebuild') {
self.postMessage({ id, ok: false, error: `unknown message type: ${type}` });
return;
}
try {
await ensureReady();
const result = await rebuildGeneratedSolidsFromSnapshot(msg.snapshot || {}, {
reason: msg.reason || 'worker'
});
const { meshes, transfer } = serializeMeshCache(result?.meshCache || new Map());
self.postMessage({
id,
ok: true,
solids: result?.solids || [],
meshes
}, transfer);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error?.message || String(error || 'unknown worker error')
});
}
};