merge experimental void app
This commit is contained in:
parent
9e142fbe1e
commit
02fb3cd30d
80 changed files with 30437 additions and 813 deletions
616
src/void/api/document.js
Normal file
616
src/void/api/document.js
Normal 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
238
src/void/api/features.js
Normal 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 };
|
||||
98
src/void/api/geometry_store.js
Normal file
98
src/void/api/geometry_store.js
Normal 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
79
src/void/api/origin.js
Normal 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
2651
src/void/api/solids.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue