void update boundary provenance

This commit is contained in:
Stewart Allen 2026-03-02 14:24:30 -05:00
commit f6bb8c1569
4 changed files with 598 additions and 7 deletions

View file

@ -4,6 +4,28 @@
Track which portions of resulting solids come from which sketch extrude regions, including after union/subtract, while keeping storage compact and spline-ready. Track which portions of resulting solids come from which sketch extrude regions, including after union/subtract, while keeping storage compact and spline-ready.
## Execution Status (2026-03-02)
Completed:
1. Plan authored and staged into phased implementation.
2. Debug visualization toggles added to preferences and wired to solids runtime:
3. Boundary loop rendering from GeometryStore.
4. Segment rendering from GeometryStore.
5. Segment/surface/region ID labels (overlay text).
6. Fixed world/local debug overlay transform bug:
7. GeometryStore points are world-space; line geometry parented under solids root must convert world -> root local because `space.WORLD` is rotated -90deg on X.
In progress:
1. Converting this plan into code-level milestone execution with strict acceptance checks.
Next up:
1. Add first `surface_patch_id` selection plumbing (read-only pass-through).
2. Thread Manifold relation metadata through kernel adapters.
3. Start mixed-face partition scaffold from seeded `surface_patches`.
## Decisions ## Decisions
1. Primary provenance is `boundary/region/surface-patch`, not raw triangle ownership. 1. Primary provenance is `boundary/region/surface-patch`, not raw triangle ownership.
@ -120,6 +142,7 @@ This enables deterministic attribution from boolean output back to input generat
1. Add `surface_patches` schema and runtime index container. 1. Add `surface_patches` schema and runtime index container.
2. Introduce generic segment schema (`kind + geom`) with current line/arc emitters. 2. Introduce generic segment schema (`kind + geom`) with current line/arc emitters.
3. Add compatibility normalizer for older docs (missing `surface_patches`).
### Phase 2: Kernel Metadata Plumbing ### Phase 2: Kernel Metadata Plumbing
@ -163,6 +186,39 @@ Regression guardrails:
1. No persisted triangle tables in doc snapshots. 1. No persisted triangle tables in doc snapshots.
2. No schema changes required to add spline segment kind later. 2. No schema changes required to add spline segment kind later.
3. Selection never reports mixed-source face entities. 3. Selection never reports mixed-source face entities.
4. Any debug/runtime geometry under solids root must explicitly convert GeometryStore world coordinates to root-local coordinates.
## Implementation Checklist
Phase 1:
1. Done: update `src/void/api/geometry_store.js` schema to include `surface_patches` and runtime topology patch map container.
2. Done: extend `buildGeometryStoreSnapshot()` in `src/void/api/solids.js` to emit seeded `surface_patches` per face-loop with source-region candidate fields.
3. In progress: keep current face-key canonical mapping intact while adding optional patch ID fields.
Phase 2:
1. Update `src/void/solid/kernel.js` mesh conversion to preserve manifold relation fields:
2. Input pass-through where provided (`runOriginalID`, `runIndex`, `faceID`, etc.).
3. Output pass-through into rebuild intermediate structures.
4. Add worker payload support for relation arrays when present.
Phase 3:
1. Implement mixed-source detection in `src/void/solid/rebuild.js`.
2. Add per-surface local partition pass and emit patch boundaries.
3. Assign one canonical `source_region_id` per patch.
Phase 4:
1. Extend selection resolver and solids hit mapping to prefer patch IDs over raw face IDs.
2. Add properties/tree hover mapping from extrude profile -> patch IDs.
3. Remove coarse fallback once parity checks pass.
Phase 5:
1. Add deterministic integration tests for union/subtract split-face provenance.
2. Remove temporary migration branches and finalize docs.
## Acceptance Criteria ## Acceptance Criteria

View file

@ -13,9 +13,12 @@ function createGeometryStoreApi(getApi) {
segments: [], segments: [],
points: [], points: [],
regions: [], regions: [],
surface_patches: [],
topology: { topology: {
surface_to_segments: {}, surface_to_segments: {},
segment_to_surfaces: {} segment_to_surfaces: {},
patch_to_tris: {},
tri_to_patch: {}
}, },
meta: { meta: {
feature_count: 0, feature_count: 0,
@ -35,9 +38,16 @@ function createGeometryStoreApi(getApi) {
out.segments = Array.isArray(out.segments) ? out.segments : []; out.segments = Array.isArray(out.segments) ? out.segments : [];
out.points = Array.isArray(out.points) ? out.points : []; out.points = Array.isArray(out.points) ? out.points : [];
out.regions = Array.isArray(out.regions) ? out.regions : []; out.regions = Array.isArray(out.regions) ? out.regions : [];
out.surface_patches = Array.isArray(out.surface_patches) ? out.surface_patches : [];
out.topology = out.topology && typeof out.topology === 'object' out.topology = out.topology && typeof out.topology === 'object'
? out.topology ? out.topology
: base.topology; : base.topology;
out.topology.patch_to_tris = out.topology.patch_to_tris && typeof out.topology.patch_to_tris === 'object'
? out.topology.patch_to_tris
: {};
out.topology.tri_to_patch = out.topology.tri_to_patch && typeof out.topology.tri_to_patch === 'object'
? out.topology.tri_to_patch
: {};
out.meta = out.meta && typeof out.meta === 'object' out.meta = out.meta && typeof out.meta === 'object'
? out.meta ? out.meta
: base.meta; : base.meta;
@ -84,6 +94,7 @@ function createGeometryStoreApi(getApi) {
state.segments = Array.isArray(snapshot.segments) ? snapshot.segments : []; state.segments = Array.isArray(snapshot.segments) ? snapshot.segments : [];
state.points = Array.isArray(snapshot.points) ? snapshot.points : []; state.points = Array.isArray(snapshot.points) ? snapshot.points : [];
state.regions = Array.isArray(snapshot.regions) ? snapshot.regions : []; state.regions = Array.isArray(snapshot.regions) ? snapshot.regions : [];
state.surface_patches = Array.isArray(snapshot.surface_patches) ? snapshot.surface_patches : [];
state.topology = snapshot.topology && typeof snapshot.topology === 'object' state.topology = snapshot.topology && typeof snapshot.topology === 'object'
? snapshot.topology ? snapshot.topology
: state.topology; : state.topology;

View file

@ -156,6 +156,26 @@ function edgeKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`; return a < b ? `${a}:${b}` : `${b}:${a}`;
} }
function colorFromId(id, sat = 70, light = 58) {
const raw = String(id || '');
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0;
}
const hue = Math.abs(hash % 360);
const color = new THREE.Color();
color.setHSL(hue / 360, sat / 100, light / 100);
return color;
}
function vec3FromRecord(rec) {
return new THREE.Vector3(
Number(rec?.x || 0),
Number(rec?.y || 0),
Number(rec?.z || 0)
);
}
function distancePointToSegmentSquared(p, a, b) { function distancePointToSegmentSquared(p, a, b) {
const ab = new THREE.Vector3().subVectors(b, a); const ab = new THREE.Vector3().subVectors(b, a);
const ap = new THREE.Vector3().subVectors(p, a); const ap = new THREE.Vector3().subVectors(p, a);
@ -637,6 +657,14 @@ function edgeKey(a, b) {
edgeHoverLineWidth: 2.5, edgeHoverLineWidth: 2.5,
edgeSelectedLineWidth: 3.25 edgeSelectedLineWidth: 3.25
}, },
_debugPrefs: {
showBoundaries: false,
showSegments: false,
showSegmentLabels: false,
showSurfaceLabels: false,
showRegionLabels: false,
showPatchLabels: false
},
_faceMats: null, _faceMats: null,
_worker: null, _worker: null,
_workerReady: false, _workerReady: false,
@ -650,6 +678,8 @@ function edgeKey(a, b) {
_loopKeyByGeomBoundaryId: new Map(), _loopKeyByGeomBoundaryId: new Map(),
_frozenChamferEdges: null, _frozenChamferEdges: null,
_frozenEdgeOverlays: null, _frozenEdgeOverlays: null,
_debugGroup: null,
_debugLabelIds: new Set(),
async init() { async init() {
await ensureKernel(); await ensureKernel();
@ -723,10 +753,353 @@ function edgeKey(a, b) {
this._root.name = 'void-solids-runtime'; this._root.name = 'void-solids-runtime';
this._frozenEdgeOverlays = new THREE.Group(); this._frozenEdgeOverlays = new THREE.Group();
this._frozenEdgeOverlays.name = 'void-solids-frozen-edge-overlays'; this._frozenEdgeOverlays.name = 'void-solids-frozen-edge-overlays';
this._debugGroup = new THREE.Group();
this._debugGroup.name = 'void-solids-debug-overlays';
this._root.add(this._frozenEdgeOverlays); this._root.add(this._frozenEdgeOverlays);
this._root.add(this._debugGroup);
world?.add?.(this._root); world?.add?.(this._root);
}, },
getSketchDerivedBoundarySegmentsForSolid(solid) {
const api = getApi();
if (!solid || String(solid?.source?.feature_type || '') !== 'extrude') return [];
const depth = Math.max(0.0001, Math.abs(Number(solid?.extrude?.depth ?? 0)));
if (!Number.isFinite(depth) || depth <= 0) return [];
const symmetric = solid?.extrude?.symmetric === true;
const direction = solid?.extrude?.direction === 'reverse' ? 'reverse' : 'normal';
const localZShift = symmetric ? (-depth / 2) : (direction === 'reverse' ? -depth : 0);
const keys = Array.isArray(solid?.source?.profile_keys) && solid.source.profile_keys.length
? solid.source.profile_keys
: (solid?.source?.profile?.region_id ? [solid.source.profile.region_id] : []);
if (!keys.length) return [];
const segments = [];
for (const key of keys) {
const ref = resolveProfileTargetRef({ region_id: String(key || '') });
if (!ref?.sketchId || !ref?.profileId) continue;
const sketch = api.features?.findById?.(ref.sketchId);
const basis = frameToBasis(sketch?.plane || null);
if (!basis) continue;
const loops = profileLoopsFromRuntime(api, { region_id: String(key || '') }) || [];
for (const loop of loops) {
if (!Array.isArray(loop) || loop.length < 3) continue;
const points = loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) }));
for (let i = 0; i < points.length; i++) {
const a2 = points[i];
const b2 = points[(i + 1) % points.length];
const aStart = basis.origin.clone()
.addScaledVector(basis.xAxis, a2.x)
.addScaledVector(basis.yAxis, a2.y)
.addScaledVector(basis.normal, localZShift);
const bStart = basis.origin.clone()
.addScaledVector(basis.xAxis, b2.x)
.addScaledVector(basis.yAxis, b2.y)
.addScaledVector(basis.normal, localZShift);
const aEnd = aStart.clone().addScaledVector(basis.normal, depth);
const bEnd = bStart.clone().addScaledVector(basis.normal, depth);
segments.push({ a: aStart, b: bStart });
segments.push({ a: aEnd, b: bEnd });
}
}
}
return segments;
},
getSketchDerivedBoundarySegments() {
const out = [];
for (const solid of this.list() || []) {
const segs = this.getSketchDerivedBoundarySegmentsForSolid(solid);
if (segs?.length) out.push(...segs);
}
return out;
},
clearDebugOverlays() {
if (this._debugGroup) {
while (this._debugGroup.children.length) {
const child = this._debugGroup.children[0];
child.geometry?.dispose?.();
child.material?.dispose?.();
this._debugGroup.remove(child);
}
}
const api = getApi();
for (const id of this._debugLabelIds) {
api.overlay?.remove?.(id);
}
this._debugLabelIds.clear();
},
syncDebugOverlays(snapshot = null) {
this.clearDebugOverlays();
const prefs = this._debugPrefs || {};
const enabled = !!(
prefs.showBoundaries
|| prefs.showSegments
|| prefs.showSegmentLabels
|| prefs.showSurfaceLabels
|| prefs.showRegionLabels
|| prefs.showPatchLabels
);
if (!enabled || !this._debugGroup) return;
const api = getApi();
const store = snapshot || api?.document?.current?.geometry_store || null;
const boundaries = Array.isArray(store?.boundaries) ? store.boundaries : [];
const segments = Array.isArray(store?.segments) ? store.segments : [];
const surfaces = Array.isArray(store?.surfaces) ? store.surfaces : [];
const regions = Array.isArray(store?.regions) ? store.regions : [];
const patches = Array.isArray(store?.surface_patches) ? store.surface_patches : [];
if (!boundaries.length && !segments.length && !surfaces.length && !regions.length) return;
const segById = new Map(segments.map(seg => [String(seg?.id || ''), seg]));
const boundaryById = new Map(boundaries.map(boundary => [String(boundary?.id || ''), boundary]));
const surfaceById = new Map(surfaces.map(surface => [String(surface?.id || ''), surface]));
const patchById = new Map(patches.map(patch => [String(patch?.id || ''), patch]));
this._root?.updateMatrixWorld?.(true);
// IMPORTANT: GeometryStore coordinates are scene/world-space.
// The solids runtime is attached under `space.WORLD`, which is rotated
// by -90deg on X in `space.js`. Any 3D debug geometry parented under
// this root MUST convert world -> root local first, or it will appear
// rotated/misaligned. Keep labels in world space (overlay expects world).
const toRootLocal = (worldVec3) => this._root?.worldToLocal?.(worldVec3.clone()) || worldVec3.clone();
const addLabel = (id, text, pos3d, color = '#ffd166') => {
if (!api.overlay || !pos3d || !text) return;
const labelId = `solid-debug:${id}`;
const opts = {
pos3d,
text,
color,
fontSize: 11,
className: 'overlay-text'
};
if (api.overlay.elements?.has?.(labelId)) {
api.overlay.update(labelId, opts);
} else {
api.overlay.add(labelId, 'text', opts);
}
this._debugLabelIds.add(labelId);
};
const hoveredFaceKey = String(this._hoveredFaceKey || '');
const hoveredSurfaceId = hoveredFaceKey
? (this._geomSurfaceIdByFaceKey.get(hoveredFaceKey) || `surface:${hoveredFaceKey}`)
: null;
const hoveredEdgeKey = String(this._hoveredEdgeKey || '');
let hoveredSegmentId = null;
let hoveredBoundaryId = null;
if (hoveredEdgeKey) {
hoveredSegmentId = this._geomSegmentIdByEdgeKey.get(hoveredEdgeKey) || null;
hoveredBoundaryId = this._geomBoundaryIdByLoopKey.get(hoveredEdgeKey) || null;
if (!hoveredSegmentId && !hoveredBoundaryId) {
const edge = this.getEdgeByKey(hoveredEdgeKey);
if (edge?.solidId && Number.isFinite(edge?.faceId) && Number.isFinite(edge?.index)) {
const basisKey = `faceedge:${edge.solidId}:${edge.faceId}:${edge.index}`;
hoveredSegmentId = this._geomSegmentIdByEdgeKey.get(basisKey) || null;
}
}
if (!hoveredBoundaryId && hoveredSegmentId) {
hoveredBoundaryId = String(segById.get(hoveredSegmentId)?.boundary_id || '') || null;
}
}
if (prefs.showBoundaries) {
const sketchDerived = this.getSketchDerivedBoundarySegments();
if (sketchDerived.length) {
const positions = [];
for (const seg of sketchDerived) {
// Sketch-derived loop points are generated from sketch plane
// frames in the same modeling frame as `space.WORLD` (root local).
// Do not apply an additional world->root conversion here or the
// debug boundaries will rotate off-axis by the WORLD transform.
const a = seg.a;
const b = seg.b;
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
if (positions.length) {
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({
color: 0x6ee7b7,
transparent: true,
opacity: 0.95,
depthTest: false,
depthWrite: false
});
const lines = new THREE.LineSegments(geom, mat);
lines.renderOrder = 95;
this._debugGroup.add(lines);
}
} else {
for (const boundary of boundaries) {
const segmentIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : [];
if (!segmentIds.length) continue;
const positions = [];
for (const segmentId of segmentIds) {
const seg = segById.get(String(segmentId || ''));
if (!seg?.a || !seg?.b) continue;
const a = toRootLocal(vec3FromRecord(seg.a));
const b = toRootLocal(vec3FromRecord(seg.b));
positions.push(a.x, a.y, a.z, b.x, b.y, b.z);
}
if (!positions.length) continue;
const color = colorFromId(boundary?.id, 62, 56);
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const mat = new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: 0.9,
depthTest: false,
depthWrite: false
});
const lines = new THREE.LineSegments(geom, mat);
lines.renderOrder = 95;
this._debugGroup.add(lines);
}
}
}
if (prefs.showSegments) {
for (const seg of segments) {
if (!seg?.a || !seg?.b) continue;
const a = toRootLocal(vec3FromRecord(seg.a));
const b = toRootLocal(vec3FromRecord(seg.b));
const geom = new THREE.BufferGeometry();
geom.setAttribute('position', new THREE.Float32BufferAttribute([
a.x, a.y, a.z,
b.x, b.y, b.z
], 3));
const mat = new THREE.LineBasicMaterial({
color: 0x66d9ef,
transparent: true,
opacity: 0.95,
depthTest: false,
depthWrite: false
});
const line = new THREE.LineSegments(geom, mat);
line.renderOrder = 96;
this._debugGroup.add(line);
}
}
if (prefs.showSegmentLabels) {
const shown = [];
if (hoveredBoundaryId) {
const boundary = boundaryById.get(String(hoveredBoundaryId || ''));
const ids = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : [];
for (const sid of ids) {
const seg = segById.get(String(sid || ''));
if (seg) shown.push(seg);
}
} else if (hoveredSegmentId) {
const seg = segById.get(String(hoveredSegmentId || ''));
if (seg) shown.push(seg);
}
for (const seg of shown) {
const mid = seg?.mid || null;
if (!mid) continue;
const text = String(seg?.id || 'segment');
addLabel(`segment:${text}`, text, vec3FromRecord(mid), '#9ad9ff');
}
}
if (prefs.showSurfaceLabels) {
if (hoveredSurfaceId) {
const surface = surfaceById.get(String(hoveredSurfaceId || ''));
const center = surface?.center || null;
if (center) {
const text = String(surface?.id || 'surface');
addLabel(`surface:${text}`, text, vec3FromRecord(center), '#ffcf7a');
}
}
}
if (prefs.showRegionLabels) {
for (const region of regions) {
if (hoveredSurfaceId && String(region?.surface_id || '') !== String(hoveredSurfaceId)) {
continue;
}
const boundaryIds = Array.isArray(region?.boundary_ids) ? region.boundary_ids : [];
const boundary = boundaryById.get(String(boundaryIds[0] || ''));
const segIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : [];
let anchor = null;
if (segIds.length) {
const sum = new THREE.Vector3();
let count = 0;
for (const sid of segIds) {
const seg = segById.get(String(sid || ''));
if (!seg?.mid) continue;
sum.add(vec3FromRecord(seg.mid));
count++;
}
if (count) {
anchor = sum.multiplyScalar(1 / count);
}
}
if (!anchor && region?.surface_id) {
const surface = surfaceById.get(String(region.surface_id));
if (surface?.center) anchor = vec3FromRecord(surface.center);
}
if (!anchor) continue;
const text = String(region?.id || 'region');
addLabel(`region:${text}`, text, anchor, '#ff9dc2');
}
}
if (prefs.showPatchLabels) {
const shown = [];
if (hoveredSurfaceId) {
for (const patch of patches) {
if (String(patch?.surface_id || '') === String(hoveredSurfaceId)) {
shown.push(patch);
}
}
} else if (hoveredSurfaceId === null && hoveredEdgeKey) {
// edge hover only path fallback: show matching patch by boundary id
if (hoveredBoundaryId) {
for (const patch of patches) {
const bids = Array.isArray(patch?.boundary_ids) ? patch.boundary_ids : [];
if (bids.includes(hoveredBoundaryId)) shown.push(patch);
}
}
}
for (const patch of shown) {
const regionId = String(patch?.source_region_id || '');
const boundaryIds = Array.isArray(patch?.boundary_ids) ? patch.boundary_ids : [];
const boundary = boundaryById.get(String(boundaryIds[0] || ''));
const segIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : [];
let anchor = null;
if (segIds.length) {
const sum = new THREE.Vector3();
let count = 0;
for (const sid of segIds) {
const seg = segById.get(String(sid || ''));
if (!seg?.mid) continue;
sum.add(vec3FromRecord(seg.mid));
count++;
}
if (count) anchor = sum.multiplyScalar(1 / count);
}
if (!anchor && patch?.surface_id) {
const surface = surfaceById.get(String(patch.surface_id));
if (surface?.center) anchor = vec3FromRecord(surface.center);
}
if (!anchor) continue;
const text = regionId
? `${String(patch?.id || 'patch')} -> ${regionId}`
: String(patch?.id || 'patch');
addLabel(`patch:${patch?.id || text}`, text, anchor, '#a6f57a');
}
}
api.overlay?.updateAll?.();
},
list() { list() {
const api = getApi(); const api = getApi();
return Array.isArray(api.document.current?.generated?.solids) return Array.isArray(api.document.current?.generated?.solids)
@ -735,6 +1108,7 @@ function edgeKey(a, b) {
}, },
buildGeometryStoreSnapshot() { buildGeometryStoreSnapshot() {
this._root?.updateMatrixWorld?.(true);
this._geomSurfaceIdByFaceKey = new Map(); this._geomSurfaceIdByFaceKey = new Map();
this._geomSegmentIdByEdgeKey = new Map(); this._geomSegmentIdByEdgeKey = new Map();
this._geomBoundaryIdByLoopKey = new Map(); this._geomBoundaryIdByLoopKey = new Map();
@ -745,10 +1119,13 @@ function edgeKey(a, b) {
const segments = []; const segments = [];
const points = []; const points = [];
const regions = []; const regions = [];
const surface_patches = [];
const pointIdByKey = new Map(); const pointIdByKey = new Map();
const topology = { const topology = {
surface_to_segments: {}, surface_to_segments: {},
segment_to_surfaces: {} segment_to_surfaces: {},
patch_to_tris: {},
tri_to_patch: {}
}; };
const getPointId = (p, role = 'boundary-vertex') => { const getPointId = (p, role = 'boundary-vertex') => {
@ -769,13 +1146,28 @@ function edgeKey(a, b) {
}; };
for (const [solidId, view] of this._meshViews.entries()) { for (const [solidId, view] of this._meshViews.entries()) {
const solid = this.list().find(item => String(item?.id || '') === String(solidId)) || null;
const sourceProfileKeys = Array.isArray(solid?.source?.profile_keys)
? solid.source.profile_keys.map(key => String(key || '')).filter(Boolean)
: [];
const primarySourceRegion = sourceProfileKeys.length === 1 ? sourceProfileKeys[0] : null;
if (!view?.faceGroups?.size) continue; if (!view?.faceGroups?.size) continue;
for (const [faceId, meta] of view.faceGroups.entries()) { for (const [faceId, meta] of view.faceGroups.entries()) {
const faceKey = `${solidId}:${faceId}`; const faceKey = `${solidId}:${faceId}`;
const surfaceId = `surface:${faceKey}`; const surfaceId = `surface:${faceKey}`;
const loops = this.getFaceBoundaryLoops(faceKey) || []; const loops = this.getFaceBoundaryLoops(faceKey) || [];
const normal = meta?.normal || {}; const mesh = view?.mesh || null;
const center = meta?.center || {}; const normalLocal = meta?.normal || new THREE.Vector3(0, 0, 1);
const centerLocal = meta?.center || new THREE.Vector3();
// GeometryStore should store scene/world-space coordinates.
// Face loops already do this via `getFaceBoundaryLoops()`.
// Keep surface center/normal in the same space for consistency.
const normal = normalLocal?.clone?.() && mesh?.matrixWorld
? normalLocal.clone().transformDirection(mesh.matrixWorld).normalize()
: normalLocal;
const center = centerLocal?.clone?.() && mesh?.matrixWorld
? centerLocal.clone().applyMatrix4(mesh.matrixWorld)
: centerLocal;
surfaces.push({ surfaces.push({
id: surfaceId, id: surfaceId,
solid_id: solidId, solid_id: solidId,
@ -868,6 +1260,24 @@ function edgeKey(a, b) {
loop_index: li loop_index: li
} }
}); });
const patchId = `surface-patch:${faceKey}:${li}`;
const sourceRegionIds = sourceProfileKeys.slice();
surface_patches.push({
id: patchId,
surface_id: surfaceId,
boundary_ids: [boundaryId],
source_region_id: primarySourceRegion,
source_region_ids: sourceRegionIds,
solid_id: solidId,
face_id: faceId,
status: 'seed',
source: {
type: 'solid-face-loop',
face_key: faceKey,
loop_index: li,
feature_id: solid?.source?.feature_id || null
}
});
} }
topology.surface_to_segments[surfaceId] = surfaceSegmentIds; topology.surface_to_segments[surfaceId] = surfaceSegmentIds;
} }
@ -878,6 +1288,7 @@ function edgeKey(a, b) {
segments, segments,
points, points,
regions, regions,
surface_patches,
topology, topology,
meta: { meta: {
feature_count: Number(getApi()?.features?.list?.()?.length || 0) feature_count: Number(getApi()?.features?.list?.()?.length || 0)
@ -1080,6 +1491,9 @@ function edgeKey(a, b) {
if (doc) { if (doc) {
const snapshot = this.buildGeometryStoreSnapshot(); const snapshot = this.buildGeometryStoreSnapshot();
api.geometryStore?.applySolidSnapshot?.(doc, snapshot); api.geometryStore?.applySolidSnapshot?.(doc, snapshot);
this.syncDebugOverlays(snapshot);
} else {
this.syncDebugOverlays(null);
} }
}, },
@ -1984,6 +2398,9 @@ function edgeKey(a, b) {
if (next === this._hoveredFaceKey) return; if (next === this._hoveredFaceKey) return;
this._hoveredFaceKey = next; this._hoveredFaceKey = next;
this.syncFaceOverlays(); this.syncFaceOverlays();
if (this._debugPrefs?.showSurfaceLabels || this._debugPrefs?.showRegionLabels) {
this.syncDebugOverlays();
}
}, },
setSelectedFaces(keys = []) { setSelectedFaces(keys = []) {
@ -2015,6 +2432,9 @@ function edgeKey(a, b) {
if (next === this._hoveredEdgeKey) return; if (next === this._hoveredEdgeKey) return;
this._hoveredEdgeKey = next; this._hoveredEdgeKey = next;
this.syncEdgeOverlays(); this.syncEdgeOverlays();
if (this._debugPrefs?.showSegmentLabels) {
this.syncDebugOverlays();
}
}, },
setSelectedEdges(keys = []) { setSelectedEdges(keys = []) {
@ -2057,6 +2477,25 @@ function edgeKey(a, b) {
return this.getRenderPreferences(); return this.getRenderPreferences();
}, },
getDebugPreferences() {
return { ...(this._debugPrefs || {}) };
},
setDebugPreferences(next = {}) {
const curr = this._debugPrefs || {};
const merged = {
showBoundaries: next.showBoundaries !== undefined ? next.showBoundaries === true : !!curr.showBoundaries,
showSegments: next.showSegments !== undefined ? next.showSegments === true : !!curr.showSegments,
showSegmentLabels: next.showSegmentLabels !== undefined ? next.showSegmentLabels === true : !!curr.showSegmentLabels,
showSurfaceLabels: next.showSurfaceLabels !== undefined ? next.showSurfaceLabels === true : !!curr.showSurfaceLabels,
showRegionLabels: next.showRegionLabels !== undefined ? next.showRegionLabels === true : !!curr.showRegionLabels,
showPatchLabels: next.showPatchLabels !== undefined ? next.showPatchLabels === true : !!curr.showPatchLabels
};
this._debugPrefs = merged;
this.syncDebugOverlays();
return this.getDebugPreferences();
},
getSketchTargetForFaceKey(key) { getSketchTargetForFaceKey(key) {
const face = this.getFaceByKey(key); const face = this.getFaceByKey(key);
if (!face) return null; if (!face) return null;

View file

@ -1082,12 +1082,19 @@ const toolbar = {
edgeSelectedLineWidth: 3.25, edgeSelectedLineWidth: 3.25,
sketchArcSegmentLength: 2.5, sketchArcSegmentLength: 2.5,
fitPaddingPerspective: 0.5, fitPaddingPerspective: 0.5,
fitPaddingOrthographic: 0.9 fitPaddingOrthographic: 0.9,
debugShowBoundaries: false,
debugShowSegments: false,
debugShowSegmentLabels: false,
debugShowSurfaceLabels: false,
debugShowRegionLabels: false,
debugShowPatchLabels: false
}; };
}, },
normalizePreferences(raw = {}) { normalizePreferences(raw = {}) {
const d = this.getDefaultPreferences(); const d = this.getDefaultPreferences();
const toBool = value => value === true || value === 'true' || value === 1 || value === '1';
return { return {
edgeLoopPromotionSegments: Math.max(3, Math.round(Number(raw.edgeLoopPromotionSegments ?? d.edgeLoopPromotionSegments) || d.edgeLoopPromotionSegments)), edgeLoopPromotionSegments: Math.max(3, Math.round(Number(raw.edgeLoopPromotionSegments ?? d.edgeLoopPromotionSegments) || d.edgeLoopPromotionSegments)),
edgeHoverLineWidth: Math.max(0.5, Number(raw.edgeHoverLineWidth ?? d.edgeHoverLineWidth) || d.edgeHoverLineWidth), edgeHoverLineWidth: Math.max(0.5, Number(raw.edgeHoverLineWidth ?? d.edgeHoverLineWidth) || d.edgeHoverLineWidth),
@ -1097,7 +1104,13 @@ const toolbar = {
Number(raw.sketchArcSegmentLength ?? raw.sketchArcSegments ?? d.sketchArcSegmentLength) || d.sketchArcSegmentLength Number(raw.sketchArcSegmentLength ?? raw.sketchArcSegments ?? d.sketchArcSegmentLength) || d.sketchArcSegmentLength
), ),
fitPaddingPerspective: Math.max(0.01, Number(raw.fitPaddingPerspective ?? d.fitPaddingPerspective) || d.fitPaddingPerspective), fitPaddingPerspective: Math.max(0.01, Number(raw.fitPaddingPerspective ?? d.fitPaddingPerspective) || d.fitPaddingPerspective),
fitPaddingOrthographic: Math.max(0.01, Number(raw.fitPaddingOrthographic ?? d.fitPaddingOrthographic) || d.fitPaddingOrthographic) fitPaddingOrthographic: Math.max(0.01, Number(raw.fitPaddingOrthographic ?? d.fitPaddingOrthographic) || d.fitPaddingOrthographic),
debugShowBoundaries: toBool(raw.debugShowBoundaries ?? d.debugShowBoundaries),
debugShowSegments: toBool(raw.debugShowSegments ?? d.debugShowSegments),
debugShowSegmentLabels: toBool(raw.debugShowSegmentLabels ?? d.debugShowSegmentLabels),
debugShowSurfaceLabels: toBool(raw.debugShowSurfaceLabels ?? d.debugShowSurfaceLabels),
debugShowRegionLabels: toBool(raw.debugShowRegionLabels ?? d.debugShowRegionLabels),
debugShowPatchLabels: toBool(raw.debugShowPatchLabels ?? d.debugShowPatchLabels)
}; };
}, },
@ -1137,6 +1150,14 @@ const toolbar = {
edgeHoverLineWidth: prefs.edgeHoverLineWidth, edgeHoverLineWidth: prefs.edgeHoverLineWidth,
edgeSelectedLineWidth: prefs.edgeSelectedLineWidth edgeSelectedLineWidth: prefs.edgeSelectedLineWidth
}); });
api.solids?.setDebugPreferences?.({
showBoundaries: prefs.debugShowBoundaries,
showSegments: prefs.debugShowSegments,
showSegmentLabels: prefs.debugShowSegmentLabels,
showSurfaceLabels: prefs.debugShowSurfaceLabels,
showRegionLabels: prefs.debugShowRegionLabels,
showPatchLabels: prefs.debugShowPatchLabels
});
api.sketchRuntime?.setRenderPreferences?.({ api.sketchRuntime?.setRenderPreferences?.({
arcSegmentLength: prefs.sketchArcSegmentLength arcSegmentLength: prefs.sketchArcSegmentLength
}); });
@ -1161,6 +1182,12 @@ const toolbar = {
if (inputs.sketchArcSegmentLength) inputs.sketchArcSegmentLength.value = String(prefs.sketchArcSegmentLength); if (inputs.sketchArcSegmentLength) inputs.sketchArcSegmentLength.value = String(prefs.sketchArcSegmentLength);
if (inputs.fitPaddingPerspective) inputs.fitPaddingPerspective.value = String(prefs.fitPaddingPerspective); if (inputs.fitPaddingPerspective) inputs.fitPaddingPerspective.value = String(prefs.fitPaddingPerspective);
if (inputs.fitPaddingOrthographic) inputs.fitPaddingOrthographic.value = String(prefs.fitPaddingOrthographic); if (inputs.fitPaddingOrthographic) inputs.fitPaddingOrthographic.value = String(prefs.fitPaddingOrthographic);
if (inputs.debugShowBoundaries) inputs.debugShowBoundaries.checked = !!prefs.debugShowBoundaries;
if (inputs.debugShowSegments) inputs.debugShowSegments.checked = !!prefs.debugShowSegments;
if (inputs.debugShowSegmentLabels) inputs.debugShowSegmentLabels.checked = !!prefs.debugShowSegmentLabels;
if (inputs.debugShowSurfaceLabels) inputs.debugShowSurfaceLabels.checked = !!prefs.debugShowSurfaceLabels;
if (inputs.debugShowRegionLabels) inputs.debugShowRegionLabels.checked = !!prefs.debugShowRegionLabels;
if (inputs.debugShowPatchLabels) inputs.debugShowPatchLabels.checked = !!prefs.debugShowPatchLabels;
}, },
buildPreferencesDialog() { buildPreferencesDialog() {
@ -1201,6 +1228,28 @@ const toolbar = {
this.preferencesInputs = this.preferencesInputs || {}; this.preferencesInputs = this.preferencesInputs || {};
this.preferencesInputs[key] = input; this.preferencesInputs[key] = input;
}; };
const makeCheckboxRow = (label, key, help = '') => {
const row = document.createElement('div');
row.className = 'doc-dialog-row prefs-row';
const name = document.createElement('div');
name.className = 'doc-dialog-name';
name.textContent = label;
if (help) {
name.title = help;
row.title = help;
}
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'prefs-input';
if (help) {
input.title = help;
}
row.appendChild(name);
row.appendChild(input);
list.appendChild(row);
this.preferencesInputs = this.preferencesInputs || {};
this.preferencesInputs[key] = input;
};
makeNumberRow( makeNumberRow(
'Edge Loop Promotion Segments', 'Edge Loop Promotion Segments',
@ -1238,6 +1287,36 @@ const toolbar = {
'0.01', '0.01',
'Extra margin used by Fit view in orthographic mode. Tune this separately from perspective for CAD-like framing.' 'Extra margin used by Fit view in orthographic mode. Tune this separately from perspective for CAD-like framing.'
); );
makeCheckboxRow(
'Debug: Show Boundaries',
'debugShowBoundaries',
'Render boundary loops from GeometryStore in 3D to verify boundary extraction and provenance partitioning.'
);
makeCheckboxRow(
'Debug: Show Segments',
'debugShowSegments',
'Render every boundary segment from GeometryStore in cyan for dense topology inspection.'
);
makeCheckboxRow(
'Debug: Segment Labels',
'debugShowSegmentLabels',
'Draw 2D overlay labels for segment IDs at segment midpoints.'
);
makeCheckboxRow(
'Debug: Surface Labels',
'debugShowSurfaceLabels',
'Draw 2D overlay labels for canonical surface IDs at surface centers.'
);
makeCheckboxRow(
'Debug: Region Labels',
'debugShowRegionLabels',
'Draw 2D overlay labels for canonical region IDs using boundary centroid anchors.'
);
makeCheckboxRow(
'Debug: Patch Labels',
'debugShowPatchLabels',
'Draw hover-scoped labels for seeded surface patch IDs and mapped source region IDs.'
);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'doc-dialog-actions'; actions.className = 'doc-dialog-actions';
const defaultsBtn = this.addButton(actions, 'Defaults', () => { const defaultsBtn = this.addButton(actions, 'Defaults', () => {
@ -1251,7 +1330,13 @@ const toolbar = {
edgeSelectedLineWidth: Number(this.preferencesInputs?.edgeSelectedLineWidth?.value), edgeSelectedLineWidth: Number(this.preferencesInputs?.edgeSelectedLineWidth?.value),
sketchArcSegmentLength: Number(this.preferencesInputs?.sketchArcSegmentLength?.value), sketchArcSegmentLength: Number(this.preferencesInputs?.sketchArcSegmentLength?.value),
fitPaddingPerspective: Number(this.preferencesInputs?.fitPaddingPerspective?.value), fitPaddingPerspective: Number(this.preferencesInputs?.fitPaddingPerspective?.value),
fitPaddingOrthographic: Number(this.preferencesInputs?.fitPaddingOrthographic?.value) fitPaddingOrthographic: Number(this.preferencesInputs?.fitPaddingOrthographic?.value),
debugShowBoundaries: !!this.preferencesInputs?.debugShowBoundaries?.checked,
debugShowSegments: !!this.preferencesInputs?.debugShowSegments?.checked,
debugShowSegmentLabels: !!this.preferencesInputs?.debugShowSegmentLabels?.checked,
debugShowSurfaceLabels: !!this.preferencesInputs?.debugShowSurfaceLabels?.checked,
debugShowRegionLabels: !!this.preferencesInputs?.debugShowRegionLabels?.checked,
debugShowPatchLabels: !!this.preferencesInputs?.debugShowPatchLabels?.checked
}, { persist: true, updateFields: true }); }, { persist: true, updateFields: true });
}); });
applyBtn.classList.add('compact'); applyBtn.classList.add('compact');