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

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