From 1fede28e62c9bdd5057377eac16357a0b37e2fac Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 15:48:01 -0500 Subject: [PATCH 001/131] invert dolly sign --- src/moto/orbit.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/moto/orbit.js b/src/moto/orbit.js index 4e7204e8..9e848dc5 100644 --- a/src/moto/orbit.js +++ b/src/moto/orbit.js @@ -467,14 +467,14 @@ class Orbit extends EventDispatcher { if (event.wheelDelta !== undefined) { // Chrome/Safari wheelDelta: scroll up = +120, scroll down = -120 // Negate to match deltaY convention - delta = -event.wheelDelta; + delta = event.wheelDelta; } else if (event.detail !== undefined) { // Old Firefox DOMMouseScroll detail: scroll up = -3, scroll down = +3 - delta = event.detail * 40; // Normalize to pixel values + delta = -event.detail * 40; // Normalize to pixel values } else if (event.deltaY !== undefined) { // Modern browsers deltaY: scroll up = negative, scroll down = positive // Already matches our convention - delta = event.deltaY; + delta = -event.deltaY; // Firefox's deltaMode indicates the unit of deltaY // DOM_DELTA_PIXEL (0x00) - pixels // DOM_DELTA_LINE (0x01) - lines (default for Firefox, ~3 units per notch) From 03e541d624b96b8f1e0217f20d277df49dd947b8 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 16:30:15 -0500 Subject: [PATCH 002/131] remove beta tag --- src/moto/license.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/moto/license.js b/src/moto/license.js index d307e578..83c6af45 100644 --- a/src/moto/license.js +++ b/src/moto/license.js @@ -6,6 +6,6 @@ const terms = { VERSION: "4.6.0" }; -export const beta = 4600; +export const beta = 0; export const license = terms; export const version = terms.VERSION; From 1e3be182c196c29a6edc8945ff4e6d261a8aca48 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 17:13:24 -0500 Subject: [PATCH 003/131] fix wireframe opacity --- src/kiri/app/init/sync.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js index a5ed5915..c64e452c 100644 --- a/src/kiri/app/init/sync.js +++ b/src/kiri/app/init/sync.js @@ -252,6 +252,7 @@ function setup_keybd_nav() { $('view-back').onclick = space.view.back; $('view-left').onclick = space.view.left; $('view-right').onclick = space.view.right; + $('unrotate').onclick = () => { api.widgets.for(w => w.unrotate()); selection.update_info(); @@ -274,7 +275,10 @@ function setup_keybd_nav() { $('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) }; // rendering options - $('render-edges').onclick = () => { api.view.set_edges({ toggle: true }); api.conf.save() }; + $('render-edges').onclick = () => { + api.view.set_edges({ toggle: true }); + api.conf.save() + }; $('render-ghost').onclick = () => { const opacity = api.view.is_arrange() ? 0.4 : 0.25; api.view.set_wireframe(false); @@ -285,7 +289,7 @@ function setup_keybd_nav() { }; $('render-wire').onclick = () => { api.view.set_wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5); - api.visuals.set_opacity(1.0); + api.visuals.set_opacity(0.25); api.conf.save(); }; $('render-solid').onclick = () => { From 5dbc102494fced50687df894ee9c825542320780 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 19:51:06 -0500 Subject: [PATCH 004/131] handle missing macro tokens --- src/kiri/core/print.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/kiri/core/print.js b/src/kiri/core/print.js index a0d59783..35ab7f25 100644 --- a/src/kiri/core/print.js +++ b/src/kiri/core/print.js @@ -288,11 +288,18 @@ class Print { if (safeEval) { safeEval.setContext(consts); } + function doSafeEval(tok) { + try { + return safeEval ? safeEval.eval(tok) : undefined; + } catch (e) { + console.log({ macro_error: tok }); + } + } function tryeval(str) { try { return eval(`{ ${str} }`) } catch (e) { - console.log({ eval_error: e, str }); + console.log({ macro_error: e, str }); return str; } } @@ -319,7 +326,7 @@ class Print { } eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`); eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`); - let evl = safeEval ? safeEval.eval(tok) : tryeval(eva.join('')); + let evl = doSafeEval(tok) ?? tryeval(eva.join('')); nutok = evl; if (pad === 666) { return evl; From 87d5ad23e0ec7a52c6cf9ea2ba61760020cf2fa3 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 20:20:38 -0500 Subject: [PATCH 005/131] fix docs formatting for controls.md Format markdown tables to pass prettier check. --- docs/kiri-moto/controls.md | 156 ++++++++++++++++++------------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/docs/kiri-moto/controls.md b/docs/kiri-moto/controls.md index 5f07edc8..256f5a42 100644 --- a/docs/kiri-moto/controls.md +++ b/docs/kiri-moto/controls.md @@ -6,117 +6,117 @@ description: Keyboard Shortcuts and Mouse Controls ## Layer Navigation -| Key | Action | Notes | -| --- | ------ | ----- | -| ` (backtick) | Show layer 0 | First layer | -| 1-9 | Show layers at 10-90% | In 10% increments | -| 0 | Show all layers (100%) | Show maximum layer | -| Meta+Up | Next layer | Navigate up one layer | -| Meta+Down | Previous layer | Navigate down one layer | -| [shift + wheel] | Cycle through layers | Preview mode only | -| v | Toggle single slice view | Preview mode: toggle single vs range | +| Key | Action | Notes | +| --------------- | ------------------------ | ------------------------------------ | +| ` (backtick) | Show layer 0 | First layer | +| 1-9 | Show layers at 10-90% | In 10% increments | +| 0 | Show all layers (100%) | Show maximum layer | +| Meta+Up | Next layer | Navigate up one layer | +| Meta+Down | Previous layer | Navigate down one layer | +| [shift + wheel] | Cycle through layers | Preview mode only | +| v | Toggle single slice view | Preview mode: toggle single vs range | ## View Navigation -| Key | Action | Notes | -| --- | ------ | ----- | -| h | Home View | Default 45° view | -| t | Top View | Top-down view | -| f | Front View | Direct front side-view | -| b | Back View | Direct back side-view | -| z | Reset View | Default zoom and home | -| F | Fit View to Contents | Zoom to fit all objects | -| `<` | Previous Side View | Cycle: left → back → right → front | -| `>` | Next Side View | Cycle: front → right → back → left | -| v | Focus on Selection | Arrange mode: center camera on object | +| Key | Action | Notes | +| --- | -------------------- | ------------------------------------- | +| h | Home View | Default 45° view | +| t | Top View | Top-down view | +| f | Front View | Direct front side-view | +| b | Back View | Direct back side-view | +| z | Reset View | Default zoom and home | +| F | Fit View to Contents | Zoom to fit all objects | +| `<` | Previous Side View | Cycle: left → back → right → front | +| `>` | Next Side View | Cycle: front → right → back → left | +| v | Focus on Selection | Arrange mode: center camera on object | ## File Operations -| Key | Action | Notes | -| --- | ------ | ----- | -| i | File Import Dialog | Import 3D models | -| r | Recent Files Dialog | Open recently used files | +| Key | Action | Notes | +| --- | ------------------- | ------------------------ | +| i | File Import Dialog | Import 3D models | +| r | Recent Files Dialog | Open recently used files | ## Workflow Actions -| Key | Action | Notes | -| --- | ------ | ----- | -| s / S | Slice Object | Process workspace objects | -| p / P | Preview Paths | Route planning (hidden in SLA mode) | -| g | CNC Animation | Enter milling animation mode (CNC only) | -| x / X | Export | Export as GCode, SVG, or DXF | -| a | Arrange | Switch to ARRANGE view, or auto-layout if already in ARRANGE | +| Key | Action | Notes | +| ----- | ------------- | ------------------------------------------------------------ | +| s / S | Slice Object | Process workspace objects | +| p / P | Preview Paths | Route planning (hidden in SLA mode) | +| g | CNC Animation | Enter milling animation mode (CNC only) | +| x / X | Export | Export as GCode, SVG, or DXF | +| a | Arrange | Switch to ARRANGE view, or auto-layout if already in ARRANGE | ## Object Manipulation -| Key | Action | Notes | -| --- | ------ | ----- | -| d | Duplicate Selected | Create copy of selected objects | -| m | Mirror Selected | Mirror vertices of selected objects | -| O | Manual Rotation Input | Enter rotation values manually | -| [delete] | Delete Selected | Remove selected objects | -| [ctrl + a] / [cmd + a] | Select All | Select all objects in workspace | -| [shift + click] | Toggle Selection | Select or deselect individual object | +| Key | Action | Notes | +| ---------------------- | --------------------- | ------------------------------------ | +| d | Duplicate Selected | Create copy of selected objects | +| m | Mirror Selected | Mirror vertices of selected objects | +| O | Manual Rotation Input | Enter rotation values manually | +| [delete] | Delete Selected | Remove selected objects | +| [ctrl + a] / [cmd + a] | Select All | Select all objects in workspace | +| [shift + click] | Toggle Selection | Select or deselect individual object | ## Rotation & Movement -| Key | Action | Notes | -| --- | ------ | ----- | -| [arrow] | Rotate 90° | Arrow keys rotate on respective axes | -| [shift + arrow] | Rotate 5° | Fine rotation control | -| [alt + arrow] | Move 5mm | Move object in X/Y plane | +| Key | Action | Notes | +| --------------- | ---------- | ------------------------------------ | +| [arrow] | Rotate 90° | Arrow keys rotate on respective axes | +| [shift + arrow] | Rotate 5° | Fine rotation control | +| [alt + arrow] | Move 5mm | Move object in X/Y plane | ## Rendering Modes -| Key | Action | Notes | -| --- | ------ | ----- | -| w | Toggle Ghost Rendering | Cycle between solid and semi-transparent | -| W | Toggle Wireframe | Cycle between solid and wireframe | -| Ctrl+W / Cmd+W | Toggle Edge Rendering | Show/hide model edges | +| Key | Action | Notes | +| -------------- | ---------------------- | ---------------------------------------- | +| w | Toggle Ghost Rendering | Cycle between solid and semi-transparent | +| W | Toggle Wireframe | Cycle between solid and wireframe | +| Ctrl+W / Cmd+W | Toggle Edge Rendering | Show/hide model edges | ## Settings & Dialogs -| Key | Action | Notes | -| --- | ------ | ----- | -| e | Device Dialog | Select and customize devices | -| o | Tool Dialog | CNC mode only | -| q | Preferences Dialog | Change application behaviors | -| l | Work Profile Dialog | Load, save, rename work profiles | -| ? | Help Dialog | Show help and documentation | -| C | Refresh Catalog | Reload device catalog | +| Key | Action | Notes | +| --- | ------------------- | -------------------------------- | +| e | Device Dialog | Select and customize devices | +| o | Tool Dialog | CNC mode only | +| q | Preferences Dialog | Change application behaviors | +| l | Work Profile Dialog | Load, save, rename work profiles | +| ? | Help Dialog | Show help and documentation | +| C | Refresh Catalog | Reload device catalog | ## Workspace Management -| Key | Action | Notes | -| --- | ------ | ----- | -| Ctrl+S | Save Settings | Save current settings | -| Cmd+S | Save Workspace | Save workspace state (macOS) | -| Cmd+L | Restore Workspace | Restore saved workspace (macOS) | -| Z | Reset All Preferences | Clear all settings (requires confirmation) | +| Key | Action | Notes | +| ------ | --------------------- | ------------------------------------------ | +| Ctrl+S | Save Settings | Save current settings | +| Cmd+S | Save Workspace | Save workspace state (macOS) | +| Cmd+L | Restore Workspace | Restore saved workspace (macOS) | +| Z | Reset All Preferences | Clear all settings (requires confirmation) | ## Mouse Controls ### General Navigation -| Input | Action | Notes | -| ----- | ------ | ----- | -| [left + drag] | Rotate View | Orbit camera around focus point | -| [right + drag] | Pan View | Move camera focus | -| [meta + drag] | Pan View | Alternative pan (macOS) | -| [middle + drag] | Zoom | Mouse wheel click + drag | -| [mouse wheel] | Zoom | Scroll to zoom in/out | +| Input | Action | Notes | +| --------------- | ----------- | ------------------------------- | +| [left + drag] | Rotate View | Orbit camera around focus point | +| [right + drag] | Pan View | Move camera focus | +| [meta + drag] | Pan View | Alternative pan (macOS) | +| [middle + drag] | Zoom | Mouse wheel click + drag | +| [mouse wheel] | Zoom | Scroll to zoom in/out | ### Object Interaction -| Input | Action | Notes | -| ----- | ------ | ----- | +| Input | Action | Notes | +| -------------- | -------- | ----------------------------------- | | [ctrl + click] | Lay Flat | Rotate clicked face toward platform | -| [meta + click] | Lay Flat | Alternative (macOS) | +| [meta + click] | Lay Flat | Alternative (macOS) | ### FDM Support Mode -| Input | Action | Notes | -| ----- | ------ | ----- | -| [left click] | Toggle Support Column | Add or remove support column | -| [ctrl + drag] / [cmd + drag] | Erase Columns | Remove support columns | -| [alt + drag] / [opt + drag] | Draw Columns | Add support columns | +| Input | Action | Notes | +| ---------------------------- | --------------------- | ---------------------------- | +| [left click] | Toggle Support Column | Add or remove support column | +| [ctrl + drag] / [cmd + drag] | Erase Columns | Remove support columns | +| [alt + drag] / [opt + drag] | Draw Columns | Add support columns | From fe0573fd02c9273da7dc1a028c466c188b8b98b7 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 11 Jan 2026 21:36:44 -0500 Subject: [PATCH 006/131] update electron app base --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e269f823..66faed4e 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "cross-env": "^10.1.0", "docusaurus-lunr-search": "^3.6.0", "dotenv": "latest", - "electron": "^35.0.1", + "electron": "^40.0.0-beta.6", "electron-builder": "^24.9.1", "esbuild": "^0.25.5", "fs-extra": "^11.2.0", From fb5093fca5aece7ca7ae22cc43de5cdcb3e99f20 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 12 Jan 2026 00:15:17 -0500 Subject: [PATCH 007/131] fix deep ease-down that wraps a polygon more than once --- src/kiri/mode/cam/work/prepare.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 04610e6b..8c3d9ede 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -834,7 +834,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { let pt = points[ii]; if (zat <= pt.z) { // rotate points to start at end of ease - points = [...points.slice(i), ...points.slice(0,i)]; + points = [...points.slice(ii), ...points.slice(0,ii)]; break; } if (i > 0) { From 5830b446802ba1175030bc28afa6354a1dbc52f6 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 12 Jan 2026 00:48:39 -0500 Subject: [PATCH 008/131] add ease down safeguards for bad geometry --- src/kiri/mode/cam/work/prepare.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 8c3d9ede..64e2a22d 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -828,9 +828,11 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (startPoint.z > point0.z) { let easeFeed = plungeRate + ((feedRate - plungeRate) * easeThrottle); let zat = startPoint.z; - let lp; - for (let i=0; ; i++) { - let ii = i % points.length; + let len = points.length; + let lp, lz = Infinity; + // hard cap on number of repeats to catch bad geometry + for (let i=0; i 0) { let dd = lp.distTo2D(pt); zat = Math.max(pt.z, zat - (dd * easeDzPerMm)); + if (zat > lz) { + // rotate points to start at end of ease + // also should never get here unless bad geometry + points = [...points.slice(ii), ...points.slice(0,ii)]; + break; + } + lz = zat; } lp = pt.clone().setZ(Math.max(pt.z, zat)); camOut(lp, 1, { feed: easeFeed }); From 84f4dcbcde07333379ecdd7d493433a2312f4a9e Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 12 Jan 2026 13:51:05 -0500 Subject: [PATCH 009/131] cap ease down speed by engage setting --- src/kiri/app/api.js | 1 + src/kiri/app/init/input.js | 3 --- src/kiri/app/init/sync.js | 3 +++ src/kiri/mode/cam/work/prepare.js | 4 +++- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js index 9c687c4d..fc7c3b46 100644 --- a/src/kiri/app/api.js +++ b/src/kiri/app/api.js @@ -163,6 +163,7 @@ export const api = { alerts(clr) { alerts.update(clr) }, bind(t,m,o) { return EVENT.bind(t,m,o) }, emit(t,m,o) { return EVENT.publish(t,m,o) }, + emitDefer(t,m,d) { setTimeout(() => EVENT.publish(t,m), d ?? 100) }, import() { api.ui.load.click() }, listeners(topic) { return EVENT.targets(topic) }, on(t,l) { EVENT.on(t,l); return api.event }, diff --git a/src/kiri/app/init/input.js b/src/kiri/app/init/input.js index 040e2dce..fb131ae6 100644 --- a/src/kiri/app/init/input.js +++ b/src/kiri/app/init/input.js @@ -87,9 +87,6 @@ function checkSeed(then) { return false; } -// upon restore, seed presets -api.event.emit('preset', api.conf.dbo()); - // api.event.on("set.threaded", bool => setThreaded(bool)); export function onBooleanClick(el) { diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js index c64e452c..d4eeb967 100644 --- a/src/kiri/app/init/sync.js +++ b/src/kiri/app/init/sync.js @@ -143,6 +143,9 @@ export async function init_sync() { history.replaceState({}, '', wlp.substring(0,kio + 6)); } + // upon restore, seed presets + api.event.emitDefer('preset', api.conf.get()); + // lift curtain $('curtain').style.display = 'none'; } diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 64e2a22d..35225178 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -826,7 +826,9 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // calculate ease down for poly path output if (startPoint.z > point0.z) { - let easeFeed = plungeRate + ((feedRate - plungeRate) * easeThrottle); + let easeMax = feedRate * camFullEngage; + let easeLerp = plungeRate + ((feedRate - plungeRate) * easeThrottle); + let easeFeed = Math.min(easeLerp, easeMax); let zat = startPoint.z; let len = points.length; let lp, lz = Infinity; From 69fdf1a60b9203418a46ce6f329d93689a26f947 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 12 Jan 2026 19:14:08 -0500 Subject: [PATCH 010/131] refactor slice progress tracking, comment out ununsed support var, tighten slice shadow range for fdm --- src/kiri/app/conf/defaults.js | 2 +- src/kiri/core/widget.js | 4 +- src/kiri/mode/fdm/app/init-menu.js | 3 +- src/kiri/mode/fdm/work/slice.js | 136 +++++++++++++++++++---------- 4 files changed, 95 insertions(+), 50 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 6ccd3143..0b9d6559 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -345,7 +345,7 @@ export const conf = { sliceSupportNozzle: 0, sliceSupportOffset: 1.0, sliceSupportOutline: true, - sliceSupportSpan: 5, + // sliceSupportSpan: 5, sliceSupportType: "disabled", sliceSupportTree: false, sliceTopLayers: 3, diff --git a/src/kiri/core/widget.js b/src/kiri/core/widget.js index c518ac29..298b83b5 100644 --- a/src/kiri/core/widget.js +++ b/src/kiri/core/widget.js @@ -786,7 +786,7 @@ class Widget { return this.cache.shadow = stack; } - async computeShadowStack(zlist, progress, pocket) { + async computeShadowStack(zlist, progress, pocket, up = 1) { let shadow_stack = this.cache.shadow_stack; if (!shadow_stack) { shadow_stack = this.cache.shadow_stack = {}; @@ -803,7 +803,7 @@ class Widget { let p = work.minions.queueAsync({ cmd: 'cam_shadow_z', z: z - 0.005, - t: z + 1 + t: z + up }).then(reply => { shadow_stack[z - 0.005] = decode(reply.data); pval += pinc; diff --git a/src/kiri/mode/fdm/app/init-menu.js b/src/kiri/mode/fdm/app/init-menu.js index d0dbd6cb..262f84e7 100644 --- a/src/kiri/mode/fdm/app/init-menu.js +++ b/src/kiri/mode/fdm/app/init-menu.js @@ -124,9 +124,10 @@ export function menu() { sliceSupportGap: newInput(LANG.sp_gaps_s, {title:LANG.sp_gaps_l, convert:toInt, bound:bound(0,5)}), sliceSupportOffset: newInput(LANG.sp_offs_s, {title:LANG.sp_offs_l, convert:toFloat, bound:bound(0.0,200.0)}), sliceSupportExtra: newInput(LANG.sp_xpnd_s, {title:LANG.sp_xpnd_l, convert:toFloat, bound:bound(0.0,10.0)}), - sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }), + // sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }), separator: newBlank({ class:"set-sep", driven }), sliceSupportOutline: newBoolean(LANG.sp_outl_s, onBooleanClick, {title:LANG.sp_outl_l, xshow: () => !isTree() }), + sliceSupportTree: newBoolean(LANG.sp_tree_s, onBooleanClick, {title:LANG.sp_tree_l }), separator: newBlank({ class:"set-sep", driven, show:manualSupport }), sliceSupportManual: newRow([ (ui.ssmAdd = newButton(undefined, onButtonClick, {icon:''})), diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 86055429..5db55d81 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -173,6 +173,29 @@ function vopt(opt, ctx) { return opt; } +/** + * return percentage values broken into ranges + * + * @param {number} plo 0.0-1.0 percentage value + * @param {number} phi 0.0-1.0 high percentage value + * @param {Array} pcts [{ lo, hi }, ...] + */ +function divide(plo, phi, pcts) { + let sum = 0; + let lo = plo; + let rval = pcts.map(pct => { + sum += pct; + let diff = (phi - plo) * pct; + let rval = { lo, hi: lo + diff }; + lo += diff; + return rval; + }); + if (Math.abs(1 - sum) > 0.001) { + console.log('SUM FAIL', { rval, sum }); + } + return rval; +} + /** * DRIVER SLICE CONTRACT * @@ -441,17 +464,18 @@ export function sliceOne(settings, widget, onupdate, ondone) { }).filter(s => s); } + // slicing is the first 50% of the update "time" + function trackupdate(pct, from, to, msg) { + // console.log(from.round(2), to.round(2), msg); + onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg); + } + // calculate % complete and call onupdate() function doupdate(index, from, to, msg) { trackupdate(index / slices.length, from, to, msg); } - // slicing is the first 50% of the update "time" - function trackupdate(pct, from, to, msg) { - onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg); - } - - // for each slice, performe a function and call doupdate() + // for each slice, perform a function and call doupdate() function forSlices(from, to, fn, msg) { slices.forEach(slice => { fn(slice); @@ -462,11 +486,12 @@ export function sliceOne(settings, widget, onupdate, ondone) { /** * Process automatic and manual shadow-based support generation */ - async function processSupports() { + async function processSupports(plo, phi) { if (process.sliceSupportType === 'disabled') { return; } + let div = divide(plo, phi, [ 0.5, 0.5 ]); let stack = slices.slice(); let indices = stack.map(s => s.z); let zAngNorm = Math.sin(process.sliceSupportAngle * Math.PI / 180); @@ -517,8 +542,8 @@ export function sliceOne(settings, widget, onupdate, ondone) { // create automatic shadows supports when on manual paint if (!manual) { await widget.computeShadowStack(indices, progress => { - trackupdate(progress, 0.05, 0.10, "shadow"); - }, zAngNorm); + trackupdate(progress, div[0].lo, div[0].hi, "shadow"); + }, zAngNorm, sliceHeight); for (let slice of stack) { slice.shadow = await widget.shadowAt(slice.z, true); @@ -532,9 +557,15 @@ export function sliceOne(settings, widget, onupdate, ondone) { let length = stack.length; let count = 0; + // convert shadows to trees, when specified + if (process.sliceSupportTree) { + // console.log('TREE OUTPUT'); + } + // perform accumulation top down for (let slice of stack.reverse()) { let shadow = slice.shadow ?? []; + if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow.clone(true)); if (process.sliceSupportExtra) { shadow = POLY.offset(shadow, process.sliceSupportExtra); } @@ -545,11 +576,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { // subtract slice.clips areas (widget boundaries) from shadow projection if (true) { let rem = []; - let clips = [ - slice.up?.clips, - slice.clips, - slice.down?.clips - ].filter(v => v).flat(); + let clips = [ slice.clips ].filter(v => v).flat(); clips = POLY.union(clips, minArea, true); POLY.subtract(shadow, clips, rem, null, slice.z, minArea, { wasm: false }); shadow = rem; @@ -573,8 +600,22 @@ export function sliceOne(settings, widget, onupdate, ondone) { if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]); } slice.supports = shadow; - if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); - trackupdate((++count/length), 0.10, 0.15, "support"); + // if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); + trackupdate((++count/length), div[1].lo, div[1].hi, "support"); + } + + // clip shadow to layers above/below clip areas (for part separation) + for (let slice of stack.reverse()) { + let clips = [ + slice.up?.clips, + slice.down?.clips + ].filter(v => v).flat(); + if (clips.length) { + let rem = []; + clips = POLY.union(clips, minArea, true); + POLY.subtract(slice.supports, clips, rem, null, slice.z, minArea, { wasm: false }); + slice.supports = rem; + } } } @@ -582,8 +623,8 @@ export function sliceOne(settings, widget, onupdate, ondone) { * Process top and bottom layers or any other * layers detected and marked for solid fill */ - async function processSolidLayers() { - forSlices(0.15, 0.2, slice => { + async function processSolidLayers(plo, phi) { + forSlices(plo, phi, slice => { let range = slice.params; let isBottom = slice.index < bottomLayers; let isTop = topLayers && slice.index > slices.length - topLayers - 1; @@ -604,10 +645,11 @@ export function sliceOne(settings, widget, onupdate, ondone) { /** * Process layer diffs and project solid areas */ - async function processLayerDiffs() { + async function processLayerDiffs(plo, phi) { + let div = divide(plo, phi, [ 0.9, 0.05, 0.05 ]); // boolean diff layers to detect bridges and flats profileStart("delta"); - forSlices(0.2, 0.33, slice => { + forSlices(div[0].lo, div[1].hi, slice => { let params = slice.params || process; let solidMinArea = params.sliceSolidMinArea; let sliceMinThick = params.sliceSolidMinThick; @@ -617,7 +659,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { profileEnd(); // project bridges and flats up and down into part profileStart("delta-project"); - forSlices(0.33, 0.34, slice => { + forSlices(div[1].lo, div[1].hi, slice => { let params = slice.params || process; topLayers = params.sliceTopLayers || 0; bottomLayers = params.sliceBottomLayers || 0; @@ -628,7 +670,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { profileEnd(); // union solid areas profileStart("solid-union"); - forSlices(0.34, 0.35, slice => { + forSlices(div[2].lo, div[2].hi, slice => { if (slice.solids) { slice.solids = POLY.union(slice.solids, 0, true); } @@ -639,10 +681,11 @@ export function sliceOne(settings, widget, onupdate, ondone) { /** * Process solid fill patterns */ - async function processSolidFills() { + async function processSolidFills(plo, phi) { profileStart("solid-fill") let promises = isConcurrent ? [] : undefined; - forSlices(0.35, promises ? 0.4 : 0.5, slice => { + let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]); + forSlices(div[0].lo, div[0].hi, slice => { let params = slice.params || process; let solidWidth = params.sliceFillWidth || 1; let fillSpace = fillSpacing * solidWidth; @@ -654,7 +697,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { slices.last().finishSolids = true if (promises) { await tracker(promises, (i, t) => { - trackupdate(i / t, 0.4, 0.5); + trackupdate(i / t, div[1].lo, div[1].hi); }); } profileEnd(); @@ -663,10 +706,11 @@ export function sliceOne(settings, widget, onupdate, ondone) { /** * Process sparse infill patterns */ - async function processSparseInfill() { + async function processSparseInfill(plo, phi) { let lastType; let promises = isConcurrent ? [] : undefined; - forSlices(0.5, promises ? 0.55 : 0.7, slice => { + let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]); + forSlices(div[0].lo, div[0].hi, slice => { let params = slice.params || process; if (!params.sliceFillSparse) { return; @@ -689,7 +733,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { }, "infill"); if (promises) { await tracker(promises, (i, t) => { - trackupdate(i / t, 0.55, 0.7); + trackupdate(i / t, div[1].lo, div[1].hi); }); } // filter out tiny fill points less than nozzle diameter @@ -715,10 +759,11 @@ export function sliceOne(settings, widget, onupdate, ondone) { /** * Process support structure fills */ - async function processSupportFills() { + async function processSupportFills(plo, phi) { profileStart("support-fill"); let promises = false && isConcurrent ? [] : undefined; - forSlices(0.8, promises ? 0.88 : 0.9, slice => { + let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]); + forSlices(div[0].lo, div[0].hi, slice => { let params = slice.params || process; let density = params.sliceSupportDensity; layerSupportFill({ @@ -733,7 +778,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { }, "support fill"); if (promises) { await tracker(promises, (i, t) => { - trackupdate(i / t, 0.88, 0.9); + trackupdate(i / t, div[1].lo, div[1].hi); }); } profileEnd(); @@ -788,8 +833,8 @@ export function sliceOne(settings, widget, onupdate, ondone) { slices.forEach((s,i) => s.index = i); } - async function renderSlices() { - forSlices(0.9, 1.0, slice => { + async function renderSlices(plo, phi) { + forSlices(plo, phi, slice => { let params = slice.params || process; layerRender(slice, params, { dark: controller.dark, @@ -850,7 +895,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { } // process solid layers (top/bottom) - await processSolidLayers(); + await processSolidLayers(0.10, 0.20); // add lead in anchor when specified in belt mode (but not for synths) if (isBelt) { @@ -883,10 +928,6 @@ export function sliceOne(settings, widget, onupdate, ondone) { }); } - // support generation using either - // enclosed shadow or manual painted supports - await processSupports(); - // calculations only relevant when solid layers are used // layer boolean diffs need to be computed to find flat areas to fill // and overhangs that need to be supported. these are stored in flats @@ -894,24 +935,27 @@ export function sliceOne(settings, widget, onupdate, ondone) { // for "real" objects, fill the remaining voids with sparse fill // sparse layers only present when non-vase mode and sparse % > 0 if (!vaseMode) { - await processLayerDiffs(); - await processSolidFills(); - await processSparseInfill(); + await processLayerDiffs(0.2, 0.4); + // support generation using either + // enclosed shadow or manual painted supports + await processSupports(0.4, 0.5); + await processSolidFills(0.5, 0.6); + await processSparseInfill(0.6, 0.8); } // fill all supports (auto and manual) if (supportDensity) { - await processSupportFills(); + await processSupportFills(0.8, 0.84); } // brick/interleave mode processing if (isBrick) { - await processBrickMode(); + await processBrickMode(0.84, 0.85); } // render if not explicitly disabled if (render) { - await renderSlices(); + await renderSlices(0.85, 1.0); } if (isBelt) { @@ -1384,10 +1428,10 @@ export function layerDiff(slice, options = {}) { newFlats = newFlats.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick); if (grow > 0 && newBridges.length) { - newBridges = POLY.offset(newBridges, grow); + newBridges = POLY.offset(newBridges, grow, { z: slice.z }); } if (grow > 0 && newFlats.length) { - newFlats = POLY.offset(newFlats, grow); + newFlats = POLY.offset(newFlats, grow, { z: slice.z }); } bridges.appendAll(newBridges); From 2c6403e865c883e0bf6d98ea566e0fa1bd43e150 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 12 Jan 2026 19:33:42 -0500 Subject: [PATCH 011/131] reorder auto/manual shadow code blocks, remove belt clip debug --- src/kiri/mode/fdm/work/slice.js | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 5db55d81..37978a8d 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -501,11 +501,25 @@ export function sliceOne(settings, widget, onupdate, ondone) { // since that is done later and clipped to slice.clips stack.sort((a,b) => a.z - b.z); + // automatic supports + if (!manual) { + // find where shadow areas begin (async) + await widget.computeShadowStack(indices, progress => { + trackupdate(progress, div[0].lo, div[0].hi, "shadow"); + }, zAngNorm, sliceHeight); + + // assign to slices (sync) + for (let slice of stack) { + slice.shadow = await widget.shadowAt(slice.z, true); + } + } + // process manual supports if they exist let { paint } = widget.anno; let { belt } = widget; + // apply belt transformations, if needed - if (belt && paint) { + if (manual && belt && paint?.length) { let { anchor, angle, dy, slope } = belt; // make a copy we can modify paint = structuredClone(paint); @@ -521,6 +535,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { } } } + // convert paint points to circles on matching slices if (manual && paint?.length) { let hpi = Math.PI/2; @@ -539,17 +554,6 @@ export function sliceOne(settings, widget, onupdate, ondone) { } } - // create automatic shadows supports when on manual paint - if (!manual) { - await widget.computeShadowStack(indices, progress => { - trackupdate(progress, div[0].lo, div[0].hi, "shadow"); - }, zAngNorm, sliceHeight); - - for (let slice of stack) { - slice.shadow = await widget.shadowAt(slice.z, true); - } - } - // 1. accumulate / union shadow coverage top down // 2. trim to area outside slice.clips let minArea = lineWidth; @@ -597,7 +601,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { .centerRectangle(newPoint(0, 0, slice.z), boundsx, boundsy) .move({ x: 0, y: -boundsy / 2 + skewy, z: 0 }); shadow = POLY.trimTo(shadow, [ clip ]); - if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]); + // if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]); } slice.supports = shadow; // if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); From c8c789b723c0a26b600629bf677217d2f0f156de Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 00:13:51 -0500 Subject: [PATCH 012/131] improve support clipping, change gap offset from number to boolean --- src/geo/polygons.js | 27 +++++++++++++++++++++------ src/kiri/app/conf/defaults.js | 2 +- src/kiri/mode/fdm/app/init-menu.js | 2 +- src/kiri/mode/fdm/work/slice.js | 29 +++++++++++++++++++++-------- web/kiri/lang/en.js | 2 +- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/geo/polygons.js b/src/geo/polygons.js index 5f06696f..649813eb 100644 --- a/src/geo/polygons.js +++ b/src/geo/polygons.js @@ -74,10 +74,23 @@ const POLYS = { union, unionFaces, xor, + verify }; export { POLYS }; +export function verify(polys) { + polys.forEach(p => { + if (!p.open && p.length < 3) console.trace('SHORT', p); + if (!p.open && p.area() < 0.001) console.trace('SMALL', p); + p.points.forEach(p => { + if (isNaN(p.x) || isNaN(p.y) ||isNaN(p.z)) { + console.trace('NaN', p); + } + }); + }); +} + export function outer(polys) { for (let p of polys) { p.inner = undefined; @@ -136,7 +149,7 @@ export function fromClipperNode(tnode, z) { export function fromClipperTree(tnode, z, tops, parent, minarea) { let poly, polys = tops || [], - min = numOrDefault(minarea, 0.1); + min = minarea ?? 0.1; for (let child of tnode.m_Childs) { poly = fromClipperNode(child, z); @@ -150,7 +163,7 @@ export function fromClipperTree(tnode, z, tops, parent, minarea) { polys.push(poly); } if (child.m_Childs) { - fromClipperTree(child, z, polys, parent ? null : poly, minarea); + fromClipperTree(child, z, polys, parent ? null : poly, min); } } @@ -641,13 +654,15 @@ export function xor(set, z) { * @param {Polygon[]} setB mask set * @returns {Polygon[]} */ -export function trimTo(setA, setB) { +export function trimTo(setA, setB, opt = {}) { // handle null/empty slices - if (setA === setB || setA === null || setB === null) return null; + if (setA === setB || setA === null || setB === null) { + return null; + } let out = [], tmp; - util.doCombinations(setA, setB, {}, function(a, b) { - if (tmp = a.mask(b)) { + util.doCombinations(setA, setB, {}, (a, b) => { + if (tmp = a.mask(b, opt.nullEq, opt.minArea)) { out.appendAll(tmp); } }); diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 0b9d6559..b4e6ed56 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -341,7 +341,7 @@ export const conf = { sliceSupportAngle: 50, sliceSupportDensity: 0.1, sliceSupportExtra: 0, - sliceSupportGap: 1, + sliceSupportGap: true, sliceSupportNozzle: 0, sliceSupportOffset: 1.0, sliceSupportOutline: true, diff --git a/src/kiri/mode/fdm/app/init-menu.js b/src/kiri/mode/fdm/app/init-menu.js index 262f84e7..1675037f 100644 --- a/src/kiri/mode/fdm/app/init-menu.js +++ b/src/kiri/mode/fdm/app/init-menu.js @@ -121,12 +121,12 @@ export function menu() { separator: newBlank({ class:"set-sep", driven }), sliceSupportAngle: newInput(LANG.sp_angl_s, {title:LANG.sp_angl_l, convert:toFloat, bound:bound(0.0,90.0)}), sliceSupportDensity: newInput(LANG.sp_dens_s, {title:LANG.sp_dens_l, convert:toFloat, bound:bound(0.0,1.0)}), - sliceSupportGap: newInput(LANG.sp_gaps_s, {title:LANG.sp_gaps_l, convert:toInt, bound:bound(0,5)}), sliceSupportOffset: newInput(LANG.sp_offs_s, {title:LANG.sp_offs_l, convert:toFloat, bound:bound(0.0,200.0)}), sliceSupportExtra: newInput(LANG.sp_xpnd_s, {title:LANG.sp_xpnd_l, convert:toFloat, bound:bound(0.0,10.0)}), // sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }), separator: newBlank({ class:"set-sep", driven }), sliceSupportOutline: newBoolean(LANG.sp_outl_s, onBooleanClick, {title:LANG.sp_outl_l, xshow: () => !isTree() }), + sliceSupportGap: newBoolean(LANG.sp_gaps_s, onBooleanClick, {title:LANG.sp_gaps_l }), sliceSupportTree: newBoolean(LANG.sp_tree_s, onBooleanClick, {title:LANG.sp_tree_l }), separator: newBlank({ class:"set-sep", driven, show:manualSupport }), sliceSupportManual: newRow([ diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 37978a8d..b5a29d55 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -540,6 +540,9 @@ export function sliceOne(settings, widget, onupdate, ondone) { if (manual && paint?.length) { let hpi = Math.PI/2; for (let slice of stack) { + if (!slice.up) { + continue; + } let polys = []; for (let rec of paint) { let { point, radius } = rec; @@ -550,13 +553,21 @@ export function sliceOne(settings, widget, onupdate, ondone) { polys.push(newPolygon().centerCircle(point, radius, 10)); } } - slice.shadow = POLY.union(polys, 0, true); + // trim polys to part overhangs + let top = slice.up.topPolys(); + let bot = slice.topPolys(); + let bridge = []; + let propose = POLY.setZ(POLY.union(polys, 0, true), slice.z); + POLY.subtract(top, bot, bridge, undefined, slice.z, 0, { wasm: true }); + propose = POLY.trimTo(propose, bridge, { minArea: 0 }); + slice.shadow = propose; + // if (devel) slice.output().setLayer("over", 0x8844aa).addPolys(bridge); } } // 1. accumulate / union shadow coverage top down // 2. trim to area outside slice.clips - let minArea = lineWidth; + let minArea = lineWidth * lineWidth; let shadowSum; let length = stack.length; let count = 0; @@ -569,7 +580,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { // perform accumulation top down for (let slice of stack.reverse()) { let shadow = slice.shadow ?? []; - if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow.clone(true)); + if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); if (process.sliceSupportExtra) { shadow = POLY.offset(shadow, process.sliceSupportExtra); } @@ -577,10 +588,10 @@ export function sliceOne(settings, widget, onupdate, ondone) { if (shadowSum) { shadow = POLY.union([...shadow, ...shadowSum], minArea, true); } - // subtract slice.clips areas (widget boundaries) from shadow projection + // subtract slice top areas (widget boundaries) from shadow projection if (true) { let rem = []; - let clips = [ slice.clips ].filter(v => v).flat(); + let clips = [ slice.topPolys() ].filter(v => v).flat(); clips = POLY.union(clips, minArea, true); POLY.subtract(shadow, clips, rem, null, slice.z, minArea, { wasm: false }); shadow = rem; @@ -608,11 +619,13 @@ export function sliceOne(settings, widget, onupdate, ondone) { trackupdate((++count/length), div[1].lo, div[1].hi, "support"); } - // clip shadow to layers above/below clip areas (for part separation) + // trim using support part offset value + let gaps = process.sliceSupportGap; for (let slice of stack.reverse()) { let clips = [ - slice.up?.clips, - slice.down?.clips + slice.clips, + gaps ? slice.up?.clips : undefined, + gaps ? slice.down?.clips : undefined ].filter(v => v).flat(); if (clips.length) { let rem = []; diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index 58546d42..a74666b9 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -472,7 +472,7 @@ self.lang['en-us'] = { sp_offs_s: "part offset", sp_offs_l: ["offset from part","in millimeters"], sp_gaps_s: "layer gap", - sp_gaps_l: ["number of layers","offset from part"], + sp_gaps_l: ["add a layer gap between support and part"], sp_span_s: "max span", sp_span_l: ["unsupported span that causes","a new support to be generated","in millimeters"], sp_angl_s: "angle", From 8b62e96ff01cc93ec59dd63b3d058c818c9697f0 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 07:48:32 -0500 Subject: [PATCH 013/131] remove input filter on iOS mobile --- src/kiri/app/init/sync.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js index d4eeb967..e3a1fba7 100644 --- a/src/kiri/app/init/sync.js +++ b/src/kiri/app/init/sync.js @@ -326,6 +326,16 @@ function setup_keybd_nav() { sdb.gdpr = Date.now(); }; + // fix file input on iOS + try { + if (/iPad|iPhone|iPod/.test(navigator.userAgent) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) { + $('load-file').removeAttribute('accept'); + } + } catch (e) { + console.log('iOS remediation fail', e); + } + // add app name hover info $('app-info').innerText = version; } From e8d2a111c02a0ef65aa24afd6d391620a13a3f2c Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 17:17:23 -0500 Subject: [PATCH 014/131] filter out invalid area polys --- src/kiri/mode/cam/work/op-area.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 03a4a9fe..af080a10 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -134,6 +134,10 @@ class OpArea extends CamOp { polys = POLY.union(nupolys, 0.00001, true); } + // filter out invalid polys + polys = polys.filter(p => p && p.length > 2); + console.log({ polys }); + // process each area separately let proc = 0; let pinc = 1 / polys.length; From 10c7bec3c8532b3759c83cd5aef28a3e9f490c32 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 20:36:38 -0500 Subject: [PATCH 015/131] use alt strategy for depth first with outline --- src/kiri/mode/cam/work/op-area.js | 4 ++-- src/kiri/mode/cam/work/prepare.js | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index af080a10..8297d419 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -136,7 +136,6 @@ class OpArea extends CamOp { // filter out invalid polys polys = polys.filter(p => p && p.length > 2); - console.log({ polys }); // process each area separately let proc = 0; @@ -489,8 +488,9 @@ class OpArea extends CamOp { min.area.used = true; pocket({ cutdir: op.ov_conv, - depthFirst: process.camDepthFirst && !op.drape, + depthFirst: process.camDepthFirst, easeDown: op.down && process.easeDown ? op.down : 0, + outline: op.drape || op.mode === 'trace', progress: (n,m) => progress(n/m, "area"), slices: min.area.filter(slice => slice.camLines) }); diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 35225178..43a26e1d 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -667,16 +667,21 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { * @param {boolean} cutdir true=CW false=CCW * @param {boolean} depthFirst prioritize cut depth in pockets by nesting */ - function pocket({ slices, cutdir, depthFirst, progress }) { + function pocket({ slices, cutdir, depthFirst, outline, progress }) { let total = 0; let depthData = []; for (let slice of slices) { let polys = [], t = [], c = []; - // use shadow + tool radius offset when available (roughing) + + // collect polys in to tops (parents) and children + // so we can have the windings be opposite POLY.flatten(slice.camLines).forEach((poly) => { + // poly is child if has parent let child = poly.parent; + // for depth, collapse parent to 1 or 0 (has, missing) if (depthFirst) { poly = poly.clone(); poly.parent = child ? 1 : 0 } + // place poly into top or child bucket if (child) c.push(poly); else t.push(poly); polys.push(poly); }); @@ -687,6 +692,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { POLY.setWinding(c, !cutdir); if (depthFirst) { + // re-nest layer polys and add to depth stack polys = POLY.nest(polys,true,true); polys.tool_shadow = POLY.flatten(slice.tool_shadow.clone(true)); depthData.push(polys); @@ -706,18 +712,18 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (depthFirst) { for (let i=0; i !poly.marked); + let flat = POLY.flatten(tops).filter(poly => !poly.marked); if (flat.length === 0) return; if (inside) { - flat = flat.filter(p => p.isNested(inside)); + flat = flat.filter(p => p.isInside(inside)); } for (;;) { @@ -736,7 +742,13 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { polyEmit(poly, CLOSEST_TO_PP, engage); engage = false; } - descend(stack.slice(1), poly); + if (outline) { + output.forEach(poly => { + descend(stack.slice(1), poly); + }); + } else { + descend(stack.slice(1), poly); + } } else { return; } From 8a7b9f9a5219a3f67e05863e9d34cc0fdef2078b Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 20:39:52 -0500 Subject: [PATCH 016/131] second half of outline strategy --- src/kiri/mode/cam/work/prepare.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 43a26e1d..43061901 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -720,7 +720,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { function descend(stack, inside, outline) { if (stack.length === 0) return; let tops = stack[0]; - let flat = POLY.flatten(tops).filter(poly => !poly.marked); + let flat = (outline ? POLY.flatten(tops) : tops).filter(poly => !poly.marked); if (flat.length === 0) return; if (inside) { flat = flat.filter(p => p.isInside(inside)); @@ -744,10 +744,10 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } if (outline) { output.forEach(poly => { - descend(stack.slice(1), poly); + descend(stack.slice(1), poly, outline); }); } else { - descend(stack.slice(1), poly); + descend(stack.slice(1), poly, outline); } } else { return; From 46df283300ba4ab501fcd01957b78eac9081021c Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 22:08:40 -0500 Subject: [PATCH 017/131] raise zSafe if zTop is above it --- src/kiri/mode/cam/work/prepare.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 43061901..97443409 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -87,7 +87,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { { alignTop } = settings.controller, { camArcEnabled, camArcResolution, camArcTolerance } = process, { camDepthFirst, camEaseAngle, camEaseDown } = process, - { camFastFeed, camFastFeedZ } = process, + { camFastFeed, camFastFeedZ, camZTop } = process, { camStockX, camStockY, camStockZ, camStockIndexed, camStockOffset } = process, { camForceZMax, camFullEngage, camInnerFirst, camOriginCenter } = process, { camOriginOffX, camOriginOffY, camOriginOffZ, camZClearance } = process, @@ -110,7 +110,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { wmx = wmpos.x, wmy = wmpos.y, wmz = !camStockIndexed ? stock.z - boundsZ : alignTop ? 0 : 0, - zSafe = camStockIndexed ? Math.hypot(stock.y, stock.z) / 2 + camZClearance : stockZClear, + zSafe = Math.max(camZTop, camStockIndexed ? Math.hypot(stock.y, stock.z) / 2 + camZClearance : stockZClear), originx = (camOriginCenter ? 0 : -stock.x / 2) + (camOriginOffX || 0), originy = (camOriginCenter ? 0 : -stock.y / 2) + (camOriginOffY || 0), origin = newPoint(originx, originy, zSafe), From b705bd6391cccc8a71a0a72a7265b247a69bf41e Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 13 Jan 2026 23:43:39 -0500 Subject: [PATCH 018/131] trim support shadow to overhangs --- src/kiri/mode/fdm/work/slice.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index b5a29d55..a3666da7 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -511,6 +511,13 @@ export function sliceOne(settings, widget, onupdate, ondone) { // assign to slices (sync) for (let slice of stack) { slice.shadow = await widget.shadowAt(slice.z, true); + if (!(slice.up && slice.shadow?.length)) continue; + // trim shadow to part overhangs + let top = slice.up.topPolys(); + let bot = slice.topPolys(); + let bridge = []; + POLY.subtract(top, bot, bridge, undefined, slice.z, 0, { wasm: true }); + slice.shadow = POLY.trimTo(slice.shadow, bridge, { minArea: 0 }); } } From 672b3d10771f26ba88bd72fdc4f9d9cf094ed280 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 14 Jan 2026 10:30:34 -0500 Subject: [PATCH 019/131] fix: unit scale when visual z set, gcode import origin, negative level z order --- src/kiri/mode/cam/app/init-ui.js | 2 +- src/kiri/mode/cam/work/export.js | 2 ++ src/kiri/mode/cam/work/op-level.js | 4 ++++ src/kiri/mode/cam/work/prepare.js | 4 ++-- src/kiri/run/worker.js | 6 +++--- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/kiri/mode/cam/app/init-ui.js b/src/kiri/mode/cam/app/init-ui.js index 2f315981..ae2498ee 100644 --- a/src/kiri/mode/cam/app/init-ui.js +++ b/src/kiri/mode/cam/app/init-ui.js @@ -587,7 +587,7 @@ export function zPlaneSelect({ which, onselect }) { } clearPops(); zPlaneStart(which, value => { - onselect(parseFloat(value)); + onselect(parseFloat(value) / api.view.unit_scale()); }); } diff --git a/src/kiri/mode/cam/work/export.js b/src/kiri/mode/cam/work/export.js index f8bd0b2a..ee762acf 100644 --- a/src/kiri/mode/cam/work/export.js +++ b/src/kiri/mode/cam/work/export.js @@ -79,6 +79,8 @@ export function cam_export(print, online) { time: 0 }; + // console.log({ offset, origin, stock }); + function section(section) { append(); online({ section }); diff --git a/src/kiri/mode/cam/work/op-level.js b/src/kiri/mode/cam/work/op-level.js index 2f90b81e..ab1651d5 100644 --- a/src/kiri/mode/cam/work/op-level.js +++ b/src/kiri/mode/cam/work/op-level.js @@ -27,6 +27,9 @@ class OpLevel extends CamOp { let zBot = zTop - down; let zList = stepz && down ? util.lerp(zTop, zBot, stepz) : [ zBot ]; + // ensure zList is descending + zList.sort((a,b) => b - a); + if (share.ran) { console.log('skip'); this.skip = true; @@ -49,6 +52,7 @@ class OpLevel extends CamOp { POLY.fillArea(clear, 1090, stepOver, points); let layers = this.layers = []; + for (let z of zList) { let lines = []; layers.push(lines); diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 97443409..23f44a18 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -567,7 +567,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } else // otherwise move over before descending if (deltaZ <= -tolerance) { - if (debug) console.log('over before descend'); + if (debug) console.log('over before descend', deltaZ, -tolerance); layerPush(point.clone().setZ(printPoint.z), 0, 0, tool); newLayer(); } @@ -636,7 +636,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // plunge safety catch if (deltaZ < 0 && !contouring) { - if (debug) console.log('deltaZ snap', rate, plungeRate); + if (debug) console.log('plunge safety', deltaZ, rate, plungeRate); emit = 1; rate = plungeRate; } diff --git a/src/kiri/run/worker.js b/src/kiri/run/worker.js index fa115dc0..086ac6c5 100644 --- a/src/kiri/run/worker.js +++ b/src/kiri/run/worker.js @@ -586,9 +586,9 @@ const dispatch = { const { process } = settings; const origin = settings.origin; const offset = { - x: origin.x - (process.camOriginOffX ?? 0), - y: -origin.y - (process.camOriginOffY ?? 0), - z: origin.z - (process.camOriginOffZ ?? 0) + x: origin.x,// - (process.camOriginOffX ?? 0), + y: -origin.y,// - (process.camOriginOffY ?? 0), + z: origin.z,// + (process.camOriginOffZ ?? 0) }; const device = settings.device; const print = setPrint(newPrint(settings, Object.values(wcache))); From bdf3de7b047062f49adfd9453e3197ecea5e0216 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 14 Jan 2026 17:03:26 -0500 Subject: [PATCH 020/131] async-ify fdm slicing layer diffs for ~3x speedup --- src/kiri/mode/fdm/work/slice.js | 33 +++++++++-- src/kiri/run/minion.js | 14 +++++ src/kiri/run/worker.js | 97 +++++++++++++++++++++------------ 3 files changed, 106 insertions(+), 38 deletions(-) diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index a3666da7..970e7152 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -672,14 +672,22 @@ export function sliceOne(settings, widget, onupdate, ondone) { async function processLayerDiffs(plo, phi) { let div = divide(plo, phi, [ 0.9, 0.05, 0.05 ]); // boolean diff layers to detect bridges and flats + let promises = []; profileStart("delta"); forSlices(div[0].lo, div[1].hi, slice => { let params = slice.params || process; let solidMinArea = params.sliceSolidMinArea; let sliceMinThick = params.sliceSolidMinThick; let sliceFillGrow = params.sliceFillGrow; - layerDiff(slice, { area: solidMinArea, grow: sliceFillGrow, thick: sliceMinThick }); + let p = layerDiff(slice, { + area: solidMinArea, + grow: sliceFillGrow, + thick: sliceMinThick, + async: true + }); + promises.push(p); }, "layer deltas"); + await Promise.all(promises); profileEnd(); // project bridges and flats up and down into part profileStart("delta-project"); @@ -1443,9 +1451,26 @@ export function layerDiff(slice, options = {}) { let newBridges = []; let newFlats = []; - POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, area, { - wasm: true - }); + if (options.async) { + return self.kiri_worker.minions + .subtract({ + a: topInner, b: downInner, + outA: newBridges, outB: newFlats, + area, wasm: true, z: slice.z, + }) + .then(() => { + layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options }); + }); + } else { + POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, area, { + wasm: true + }); + layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options }); + } +} + +function layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options }) { + const { sla, grow, area, thick } = options; // console.log(slice.z, { newBridges, newFlats }); newBridges = newBridges.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick); diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index ab6edb7f..0a229558 100644 --- a/src/kiri/run/minion.js +++ b/src/kiri/run/minion.js @@ -78,6 +78,20 @@ const funcs = self.minion = { } }, + subtract(data, seq) { + let { arg, opt } = data; + let { area, wasm, z } = opt; + let a = codec.decode(arg.a); + let b = codec.decode(arg.b); + let outA = [], outB = []; + POLY.subtract(a, b, outA, outB, z, area, { wasm }); + reply({ + seq, + outA: codec.encode(outA), + outB: codec.encode(outB) + }); + }, + union(data, seq) { if (!(data.polys && data.polys.length)) { reply({ seq, union: codec.encode([]) }); diff --git a/src/kiri/run/worker.js b/src/kiri/run/worker.js index 086ac6c5..34594561 100644 --- a/src/kiri/run/worker.js +++ b/src/kiri/run/worker.js @@ -101,6 +101,9 @@ function minhandler(msg) { // for concurrent operations const minwork = { + + // core functions + get concurrent() { return concurrent }, @@ -137,6 +140,66 @@ const minwork = { minions.length = 0; }, + queue(work, ondone, direct) { + minionq.push({work, ondone, direct}); + minwork.kick(); + }, + + queueAsync(work, direct) { + return new Promise(resolve => { + minwork.queue(work, resolve, direct); + }); + }, + + kick() { + if (minions.length && minionq.length) { + let qrec = minionq.shift(); + let minion = minions.shift(); + let seq = miniseq++; + qrec.work.seq = seq; + minifns[seq] = (data) => { + qrec.ondone(data); + minions.push(minion); + minwork.kick(); + }; + minion.postMessage(qrec.work, qrec.direct); + } + }, + + broadcast(cmd, data, direct) { + for (let minion of minions) { + minion.postMessage({ + cmd, ...data + }, direct); + } + }, + + // added functions (should be namespaced) + + subtract({ a, b, outA, outB, z, area, wasm }) { + return new Promise((resolve, reject) => { + if (concurrent < 2 || a.length + b.length < concurrent * 2 || POLY.points([...a,...b]) < concurrent * 50) { + POLY.subtract(a, b, outA, outB, z, area, { wasm }); + resolve(); + return; + } + minwork.queue({ + cmd: "subtract", + opt: { area, wasm, z }, + arg: { + a: codec.encode(a), + b: codec.encode(b), + outA: outA ? 1 : 0, + outB: outB ? 1 : 0, + } + }, result => { + if (outA) outA.push(...codec.decode(result.outA)); + if (outB) outB.push(...codec.decode(result.outB)); + resolve(); + }); + }); + }, + union(polys, minarea) { return new Promise((resolve, reject) => { if (concurrent < 2 || polys.length < concurrent * 2 || POLY.points(polys) < concurrent * 50) { @@ -245,40 +308,6 @@ const minwork = { }, [ floatP.buffer ]); }); }, - - queue(work, ondone, direct) { - minionq.push({work, ondone, direct}); - minwork.kick(); - }, - - queueAsync(work, direct) { - return new Promise(resolve => { - minwork.queue(work, resolve, direct); - }); - }, - - kick() { - if (minions.length && minionq.length) { - let qrec = minionq.shift(); - let minion = minions.shift(); - let seq = miniseq++; - qrec.work.seq = seq; - minifns[seq] = (data) => { - qrec.ondone(data); - minions.push(minion); - minwork.kick(); - }; - minion.postMessage(qrec.work, qrec.direct); - } - }, - - broadcast(cmd, data, direct) { - for (let minion of minions) { - minion.postMessage({ - cmd, ...data - }, direct); - } - } }; console.log(`kiri | init work | ${version || "rogue"}`); From 8a0d827c2aa7462e12c4e1d6c7eaf16c3cd0bed5 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 14 Jan 2026 19:24:30 -0500 Subject: [PATCH 021/131] add trace option to ignore part boundaries --- src/kiri/app/conf/defaults.js | 1 + src/kiri/mode/cam/app/cl-ops.js | 2 ++ src/kiri/mode/cam/work/op-area.js | 9 +++++++-- src/kiri/mode/cam/work/op-outline.js | 2 ++ src/kiri/mode/cam/work/op-trace.js | 3 ++- src/kiri/mode/fdm/app/init-ui.js | 2 ++ web/kiri/lang/en.js | 2 ++ 7 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index b4e6ed56..6bd700e4 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -604,6 +604,7 @@ export const conf = { camToolInit: true, camTraceDogbone: false, camTraceDown: 0, + camTraceIgnore: false, camTraceLines: false, camTraceMerge: false, camTraceOffOver: 0, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 9fe753cf..0e7edf71 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -487,6 +487,7 @@ export function createPopOps() { offover: 'camTraceOffOver', dogbone: 'camTraceDogbone', revbone: 'camTraceDogbone', + ignore: 'camTraceIgnore', merge: 'camTraceMerge', ov_topz: 0, ov_botz: 0, @@ -502,6 +503,7 @@ export function createPopOps() { offover: UC.newInput(LANG.cc_offd_s, { title: LANG.cc_offd_l, convert: toFloat, units, show: () => env.poppedRec.offset !== "none" || env.poppedRec.mode === "clear" }), sep: UC.newBlank({ class: "pop-sep", modes: MODES.CAM, xshow: zDogSep }), thru: UC.newBoolean(LANG.cc_thru_s, undefined, { title: LANG.cc_thru_l }), + ignore: UC.newBoolean(LANG.co_igno_s, undefined, { title: LANG.co_igno_l, show: () => env.poppedRec.mode === 'clear' }), merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }), dogbone: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: canDogBones }), revbone: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: canDogBonesRev }), diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 8297d419..148671ae 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -191,7 +191,12 @@ class OpArea extends CamOp { let outs = []; let clip = []; let firstOff = -(toolDiam / 2 + (op.leave_xy ?? 0)); - POLY.subtract([ area ], shadow, clip, undefined, undefined, 0); + // remove shadow from area + if (op.ignore) { + clip = [ area ]; + } else { + POLY.subtract([ area ], shadow, clip, undefined, undefined, 0); + } POLY.offset(clip, [ firstOff, -toolOver ], { count: op.walls ? 1 : (op.steps ?? 999), outs, flat: true, z: z - zMov, ...offopt }); @@ -258,7 +263,7 @@ class OpArea extends CamOp { for (let z of zs) { let slice = newLayer(z); let layers = slice.output(); - let shadow = await shadowAt(z); + let shadow = op.base ? state.shadow.base : await shadowAt(z); let outs = []; if (tr_type === 'none') { // todo: move this out of the zs loop and only setZ when needed diff --git a/src/kiri/mode/cam/work/op-outline.js b/src/kiri/mode/cam/work/op-outline.js index 198eab31..e21dfaa2 100644 --- a/src/kiri/mode/cam/work/op-outline.js +++ b/src/kiri/mode/cam/work/op-outline.js @@ -25,9 +25,11 @@ class OpOutline extends CamOp { let areas = shadow.base.clone(true); ops_list.push(new OpArea(state, { areas: { [widget.id]: areas.map(p => p.toArray()) }, + base: true, direction, dogbones, down, + drape: true, expand: 0, mode: 'trace', omitinner: omitvoid, diff --git a/src/kiri/mode/cam/work/op-trace.js b/src/kiri/mode/cam/work/op-trace.js index 25ae2065..cbf3512e 100644 --- a/src/kiri/mode/cam/work/op-trace.js +++ b/src/kiri/mode/cam/work/op-trace.js @@ -12,7 +12,7 @@ class OpTrace extends CamOp { async slice(progress) { let { op, state } = this; - let { areas, direction, down, expand, follow, offover, offset, outline, mode, ov_botz, ov_topz } = op; + let { areas, direction, down, expand, follow, offover, offset, outline, ignore, mode, ov_botz, ov_topz } = op; let { plunge, rate, refine, smooth, spindle, step, steps, thru, tolerance, tool } = op; let trace = { areas, @@ -21,6 +21,7 @@ class OpTrace extends CamOp { down, expand, follow, + ignore, mode: mode === 'clear' ? 'clear' : 'trace', outline, ov_botz, diff --git a/src/kiri/mode/fdm/app/init-ui.js b/src/kiri/mode/fdm/app/init-ui.js index 00837bd5..d7d4642f 100644 --- a/src/kiri/mode/fdm/app/init-ui.js +++ b/src/kiri/mode/fdm/app/init-ui.js @@ -380,6 +380,8 @@ function supportDone() { // Use popVisualState to restore original material w.popVisualState('paint'); }); + api.conf.save(); + api.space.save(); } // manual supports clear diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index a74666b9..5c3fdfc3 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -549,6 +549,8 @@ self.lang['en-us'] = { co_menu: "outline", co_merg_s: "merge overlap", co_merg_l: ["merge overlapping lines to prevent overcutting into adjacent solids"], + co_igno_s: "ignore part", + co_igno_l: ["do not constrain cutting areas","to part boundaries. this allows","using the part as negative space"], co_dogb_s: "dogbones", co_dogb_l: ["insert dogbone cuts","into inside corners"], co_dogr_s: "reverse bones", From e569dd5b932278025d647ac23b3f0bc1edd17f41 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 14 Jan 2026 19:28:50 -0500 Subject: [PATCH 022/131] disable trace merge until area can implement properly --- src/kiri/mode/cam/app/cl-ops.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 0e7edf71..df37d770 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -504,7 +504,7 @@ export function createPopOps() { sep: UC.newBlank({ class: "pop-sep", modes: MODES.CAM, xshow: zDogSep }), thru: UC.newBoolean(LANG.cc_thru_s, undefined, { title: LANG.cc_thru_l }), ignore: UC.newBoolean(LANG.co_igno_s, undefined, { title: LANG.co_igno_l, show: () => env.poppedRec.mode === 'clear' }), - merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }), + // merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }), dogbone: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: canDogBones }), revbone: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: canDogBonesRev }), exp: UC.newExpand("feeds & speeds", { }), From 93ae09533a1bb2a8753e27ad86bd29559a51c525 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 14 Jan 2026 20:16:52 -0500 Subject: [PATCH 023/131] correct tool shadow epsilon --- src/kiri/mode/cam/work/op-area.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 148671ae..1647d0ea 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -142,7 +142,7 @@ class OpArea extends CamOp { let pinc = 1 / polys.length; for (let area of polys) { let bounds = area.getBounds3D(); - let ts_off = toolDiam / 2 - ts_eps + (op.leave_xy ?? 0); + let ts_off = toolDiam / 2 + (op.leave_xy ?? 0) + ts_eps; let offopt = { arc: 250, join: roundSharps ? ClipperLib.JoinType.jtRound : undefined, From 3e3b71edd127da3a12b70c2d85282fed4df1078e Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Thu, 15 Jan 2026 13:49:57 -0500 Subject: [PATCH 024/131] fix LOCAL const --- src/kiri/app/api.js | 2 +- src/kiri/app/conf/manager.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js index fc7c3b46..275a123b 100644 --- a/src/kiri/app/api.js +++ b/src/kiri/app/api.js @@ -45,7 +45,7 @@ let LOC = self.location, EVENT = broker, SETUP = utils.parseOpt(LOC.search.substring(1)), FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')), - LOCAL = self.debug && !SETUP.remote, + LOCAL = (LOC.host.startsWith('localhost') || self.debug) && !SETUP.remote, SECURE = isSecure(LOC.protocol); // todo: fix in widget.js b/c front-end and back-end do not share api diff --git a/src/kiri/app/conf/manager.js b/src/kiri/app/conf/manager.js index c15f57ec..70d9c9e9 100644 --- a/src/kiri/app/conf/manager.js +++ b/src/kiri/app/conf/manager.js @@ -559,6 +559,7 @@ function settingsImport(data, ask) { } if (api.const.LOCAL) console.log('import', data); + let isSettings = (data.settings && data.time); let isProcess = (data.process && data.time && data.mode && data.name); let isDevice = (data.device && data.time); From fb150f64e7eaa2b50c8dfc4d456b657149b01ad5 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sat, 17 Jan 2026 21:52:20 -0500 Subject: [PATCH 025/131] bump rev 4.6.1 --- package.json | 2 +- src/moto/license.js | 2 +- web/kiri/manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 66faed4e..9e2640a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grid-apps", - "version": "4.6.0", + "version": "4.6.1", "description": "grid.space 3d slicing & modeling tools", "author": "Stewart Allen ", "license": "MIT", diff --git a/src/moto/license.js b/src/moto/license.js index 83c6af45..b0a40326 100644 --- a/src/moto/license.js +++ b/src/moto/license.js @@ -3,7 +3,7 @@ const terms = { COPYRIGHT: "Copyright (C) Stewart Allen - All Rights Reserved", LICENSE: "See the license.md file included with the source distribution", - VERSION: "4.6.0" + VERSION: "4.6.1" }; export const beta = 0; diff --git a/web/kiri/manifest.json b/web/kiri/manifest.json index b7ba182c..510c6eac 100644 --- a/web/kiri/manifest.json +++ b/web/kiri/manifest.json @@ -1,5 +1,5 @@ { - "name": "Kiri:Moto 4.6.0", + "name": "Kiri:Moto 4.6.1", "short_name": "Kiri:Moto", "description": "Slicer for 3D printers, CNC mills, laser cutters and more", "start_url": "/boot/index.html", From 1cc271ab25218dc70fffe7e8f6e373995ea438ba Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 20 Jan 2026 09:49:34 -0500 Subject: [PATCH 026/131] allow drill to obey zBottom --- src/kiri/mode/cam/work/op-drill.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/kiri/mode/cam/work/op-drill.js b/src/kiri/mode/cam/work/op-drill.js index 3189e6d5..fb8e4b91 100644 --- a/src/kiri/mode/cam/work/op-drill.js +++ b/src/kiri/mode/cam/work/op-drill.js @@ -13,8 +13,7 @@ class OpDrill extends CamOp { async slice(progress) { let { op, state } = this; - let { settings, addSlices, widget, updateToolDiams } = state; - let { color } = state; + let { color, settings, addSlices, widget, updateToolDiams, zBottom } = state; let { drills } = op let drillTool = new Tool(settings, op.tool), @@ -39,6 +38,10 @@ class OpDrill extends CamOp { } drill.zBottom = drill.z - drill.depth; + + // honor zBottom when set + if (zBottom) drill.zBottom = Math.max(zBottom, drill.zBottom); + // for thru holes, follow z thru when set if ((op.thru > 0)) { drill.zBottom -= op.thru; From 5cb7731f6ceb3a1c6e679eaf879e04cadd279f29 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 20 Jan 2026 15:56:15 -0500 Subject: [PATCH 027/131] account for floating point noise in z vertices with shadow clipping --- src/kiri/mode/cam/work/op-area.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 1647d0ea..262ff90d 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -178,7 +178,7 @@ class OpArea extends CamOp { for (let z of zs) { let slice = newLayer(z); let layers = slice.output(); - let shadow = await shadowAt(z); + let shadow = await shadowAt(z + 0.01); let tool_shadow = [ ...POLY.offset(shadow, [ ts_off ], { count: 1, z, ...offopt }), ...POLY.offset(shadow, [ -ts_off ], { count: 1, z, ...offopt }), From 07220655d682c4d83b2ebc170a0d2eb9e1e7a597 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 20 Jan 2026 18:49:42 -0500 Subject: [PATCH 028/131] update shared-array-buffer origin trial token --- web/kiri/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/kiri/index.html b/web/kiri/index.html index ad439837..c85472fd 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -14,7 +14,7 @@ - + Kiri:Moto From 4a683bd389eaf0be6eb21c0c2863e65143b9deca Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 21 Jan 2026 18:00:05 -0500 Subject: [PATCH 029/131] add new slicePre()/slicePost() requirement to engine and enforce new threading/worker requirement --- src/kiri/mode/fdm/work/slice.js | 5 +++-- src/kiri/run/engine.js | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 970e7152..6852f5ba 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -585,7 +585,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { } // perform accumulation top down - for (let slice of stack.reverse()) { + for (let slice of stack.slice().reverse()) { let shadow = slice.shadow ?? []; if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow); if (process.sliceSupportExtra) { @@ -628,7 +628,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { // trim using support part offset value let gaps = process.sliceSupportGap; - for (let slice of stack.reverse()) { + for (let slice of stack) { let clips = [ slice.clips, gaps ? slice.up?.clips : undefined, @@ -1162,6 +1162,7 @@ export function slicePost(settings, onupdate) { // assign grid_id which can be embedded in gcode and // used by the controller to cancel objects during print let { bounds } = settings; + if (!bounds) return; for (let widget of widgets) { let { pos, box } = widget.track; // calculate top/left coordinate for widget diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index c8d995cd..e1abe19c 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -26,6 +26,7 @@ class Engine { client.setWorkPath(workURL); client.setPoolPath(poolURL); client.restart(); + client.pool.start(); } catch (error) { console.log({ error }); } @@ -63,11 +64,8 @@ class Engine { } setThreading(bool) { - if (bool) { - client.pool.start(); - } else { - client.pool.stop(); - } + console.log('setThreading() deprecated'); + return this; } setListener(listener) { @@ -163,6 +161,7 @@ class Engine { client.clear(); client.sync([this.widget]); client.rotate(this.settings); + client.slicePre(this.settings, () => {}); client.slice(this.settings, this.widget, msg => { this.listener({ slice: msg }); if (msg.error) { @@ -170,6 +169,7 @@ class Engine { } if (msg.done) { accept(this); + client.slicePost(this.settings, () => {}); } }); }); From 181224b59e15a3b4c5c98d31aacfc929afd0787b Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 21 Jan 2026 19:36:21 -0500 Subject: [PATCH 030/131] if in mesh tool you are unable to close() a window, bounce back to kiri --- src/mesh/build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/build.js b/src/mesh/build.js index 5413222a..d8da4482 100644 --- a/src/mesh/build.js +++ b/src/mesh/build.js @@ -404,7 +404,7 @@ function ui_build() { menu_item('Slicer', api.kirimoto), menu_item('Script', api.script.toggle), hr(), - menu_item('Close', window.close), + menu_item('Close', () => window.close() || api.kirimoto()), ]) ]), div({ class: "menu sketch-on" }, [ From 7aefc51e1fceadcaeea46d0b25b314e80d217da8 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 21 Jan 2026 20:17:41 -0500 Subject: [PATCH 031/131] add trace 'offset z' option --- src/kiri/app/conf/defaults.js | 1 + src/kiri/mode/cam/app/cl-ops.js | 2 ++ src/kiri/mode/cam/work/op-area.js | 3 ++- src/kiri/mode/cam/work/op-trace.js | 3 ++- web/kiri/lang/en.js | 2 ++ 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 6bd700e4..1efecc0b 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -608,6 +608,7 @@ export const conf = { camTraceLines: false, camTraceMerge: false, camTraceOffOver: 0, + camTraceOffZ: 0, camTraceOffset: "none", camTraceOver: 0.5, camTracePlunge: 200, diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index df37d770..ad6b364e 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -485,6 +485,7 @@ export function createPopOps() { rate: 'camTraceSpeed', plunge: 'camTracePlunge', offover: 'camTraceOffOver', + offz: 'camTraceOffZ', dogbone: 'camTraceDogbone', revbone: 'camTraceDogbone', ignore: 'camTraceIgnore', @@ -501,6 +502,7 @@ export function createPopOps() { step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: (op) => env.popOp.trace.rec.mode === "clear" }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }), offover: UC.newInput(LANG.cc_offd_s, { title: LANG.cc_offd_l, convert: toFloat, units, show: () => env.poppedRec.offset !== "none" || env.poppedRec.mode === "clear" }), + offz: UC.newInput(LANG.cc_offz_s, { title: LANG.cc_offz_l, convert: toFloat, units, show: () => env.poppedRec.mode === "follow" }), sep: UC.newBlank({ class: "pop-sep", modes: MODES.CAM, xshow: zDogSep }), thru: UC.newBoolean(LANG.cc_thru_s, undefined, { title: LANG.cc_thru_l }), ignore: UC.newBoolean(LANG.co_igno_s, undefined, { title: LANG.co_igno_l, show: () => env.poppedRec.mode === 'clear' }), diff --git a/src/kiri/mode/cam/work/op-area.js b/src/kiri/mode/cam/work/op-area.js index 262ff90d..4c42ee5c 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -256,10 +256,11 @@ class OpArea extends CamOp { progress(proc, 'clear'); } else if (mode === 'trace') { - let { tr_over, tr_type } = op; + let { tr_over, tr_offz, tr_type } = op; let zs = down ? base_util.lerp(zTop, op.thru ? zBottom : Math.max(zBottom, area.minZ()), down) : [ bounds.min.z ]; let zroc = 0; let zinc = 1 / zs.length; + if (tr_offz) zs = zs.map(z => z - tr_offz); for (let z of zs) { let slice = newLayer(z); let layers = slice.output(); diff --git a/src/kiri/mode/cam/work/op-trace.js b/src/kiri/mode/cam/work/op-trace.js index cbf3512e..3e1c6d86 100644 --- a/src/kiri/mode/cam/work/op-trace.js +++ b/src/kiri/mode/cam/work/op-trace.js @@ -12,7 +12,7 @@ class OpTrace extends CamOp { async slice(progress) { let { op, state } = this; - let { areas, direction, down, expand, follow, offover, offset, outline, ignore, mode, ov_botz, ov_topz } = op; + let { areas, direction, down, expand, follow, offover, offset, offz, outline, ignore, mode, ov_botz, ov_topz } = op; let { plunge, rate, refine, smooth, spindle, step, steps, thru, tolerance, tool } = op; let trace = { areas, @@ -40,6 +40,7 @@ class OpTrace extends CamOp { tool, thru, tr_over: offover, + tr_offz: offz, tr_type: offset }; this.op_trace = new OpArea(state, trace); diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index 5c3fdfc3..69254f63 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -504,6 +504,8 @@ self.lang['en-us'] = { cc_thru_l: ["allow trace depth to extend thru","the bottom of the selected area","use with negative Z bottom"], cc_offd_s: "offset", cc_offd_l: ["overrides default offset distance (which is the tool radius) when set to any value but zero"], + cc_offz_s: "offset z", + cc_offz_l: ["shift add or subtract Z values by this amount","in workspace units"], cc_feed_s: "feed rate", cc_feed_l: ["max cutting speed in","workspace units / minute"], cc_plng_s: "plunge rate", From 00f34f2cee355c2f57d91f234cca4fe5440b5e4c Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Thu, 22 Jan 2026 22:35:57 -0500 Subject: [PATCH 032/131] fix engine setMode() --- src/kiri/run/engine.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index e1abe19c..abfa9ace 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -84,7 +84,16 @@ class Engine { * @returns {Engine} this */ setMode(mode) { - this.settings.mode = mode; + let lmode = mode.toLowerCase(); + Object.assign(this.settings, { + mode: mode, + controller: {}, + render: false, + filter: { [mode]: "internal" }, + device: conf.defaults[lmode].d, + process: conf.defaults[lmode].p, + }); + console.log({ settings: this.settings }); return this; } From cd33794819a02a0790829f57d4e83207a6a9875f Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sat, 24 Jan 2026 00:27:48 -0500 Subject: [PATCH 033/131] fixes for engine stock origin and center --- src/kiri/mode/cam/work/slice.js | 2 +- src/kiri/run/engine.js | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/kiri/mode/cam/work/slice.js b/src/kiri/mode/cam/work/slice.js index c9735b9d..d4968bc7 100644 --- a/src/kiri/mode/cam/work/slice.js +++ b/src/kiri/mode/cam/work/slice.js @@ -141,7 +141,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) { bottom_stock, bottom_part, bottom_gap, bottom_z, }, 3); - // console.log({ bounds, stock, track, workarea }); + // console.log({ bounds, stock, track, workarea, camZBottom }); return structuredClone(workarea); }; diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index abfa9ace..0f7db8e4 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -38,6 +38,7 @@ class Engine { new load.STL().load(url, vertices => { this.listener({ loaded: url, vertices }); this.widget.loadVertices(vertices).center(); + this.setTopOffset(0); accept(this); }); } catch (error) { @@ -93,7 +94,6 @@ class Engine { device: conf.defaults[lmode].d, process: conf.defaults[lmode].p, }); - console.log({ settings: this.settings }); return this; } @@ -130,17 +130,24 @@ class Engine { process.camStockX = stock.x; process.camStockY = stock.y; process.camStockZ = stock.z; - if (this.origin) settings.stock.center = origin; + settings.stock.center = { + x: stock.x / 2, + y: stock.y / 2, + z: stock.z / 2 + }; return this; } setTopOffset(offset = 0) { this.topOffset = offset; + let wbb = this.widget.getBoundingBox(); + this.widget.setTopZ(wbb.max.z - offset); + return this; } setOrigin(x, y, z) { this.origin = { x, y, z }; - if (this.settings.stock) this.settings.stock.center = { x, y, z }; + this.settings.origin = this.origin; return this; } @@ -165,7 +172,6 @@ class Engine { } slice() { - this.widget.setTopZ((this.settings?.stock?.z || 0) - (this.topOffset || 0)); return new Promise((accept, reject) => { client.clear(); client.sync([this.widget]); From bfc54463b91367cc42fb406bfacc208352615025 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 25 Jan 2026 11:44:11 -0500 Subject: [PATCH 034/131] fix api url object loading, add engine workspace export --- src/kiri/app/conf/manager.js | 4 ++-- src/kiri/app/platform.js | 3 ++- src/kiri/run/engine.js | 7 ++++++- src/load/url.js | 6 ++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/kiri/app/conf/manager.js b/src/kiri/app/conf/manager.js index 70d9c9e9..52489e93 100644 --- a/src/kiri/app/conf/manager.js +++ b/src/kiri/app/conf/manager.js @@ -526,7 +526,7 @@ function settingsExport(opts = {}) { const shot = opts.work || opts.screen ? space.screenshot() : undefined; const work = opts.work ? codec.encode(widgets,{_json_:true}) : undefined; const view = opts.work ? space.view.save() : undefined; - const setn = Object.clone(settings); + const setn = Object.clone(opts.engine ?? settings); // stuff in legacy annotations for re-import for (let w of widgets) { setn.widget[w.id] = w.anno; @@ -539,7 +539,7 @@ function settingsExport(opts = {}) { note: note, work: work, view: view, - moto: moto.id, + moto: self.moto?.id, init: local.getItem('kiri-init'), time: Date.now() }; diff --git a/src/kiri/app/platform.js b/src/kiri/app/platform.js index a1529fb6..ca375317 100644 --- a/src/kiri/app/platform.js +++ b/src/kiri/app/platform.js @@ -6,6 +6,7 @@ import { api } from './api.js'; import { MODES } from './consts.js'; import { colorSchemeRegistry } from './color/schemes.js'; import { load as file_load } from '../../load/file.js'; +import { load_url as url_load } from '../../load/url.js'; import { newBounds } from '../../geo/bounds.js'; import { Packer } from './pack.js'; import { space } from '../../moto/space.js'; @@ -491,7 +492,7 @@ function load_stl(url, onload, formdata, credentials, headers) { */ function load_url(url, options = {}) { platform.group(); - file_load.URL.load(url, options).then(objects => { + url_load(url, options).then(objects => { let widgets = []; for (let object of objects) { let widget = newWidget(undefined, options.group).loadVertices(object.mesh); diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index 0f7db8e4..512d2b72 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -19,7 +19,8 @@ class Engine { filter: { FDM: "internal" }, device: conf.defaults.fdm.d, // device profile process: conf.defaults.fdm.p, // slicing settings - widget: { [this.widget.id]: {} } + widget: { [this.widget.id]: {} }, + time: Date.now() }; this.listener = () => { }; try { @@ -51,6 +52,10 @@ class Engine { api.platform.clear(); } + workspace() { + return api.settings.export({ engine: this.settings }); + } + parse(data) { return new Promise((accept, reject) => { try { diff --git a/src/load/url.js b/src/load/url.js index 3cb8d711..6b481a1a 100644 --- a/src/load/url.js +++ b/src/load/url.js @@ -2,11 +2,9 @@ 'use strict'; -import { STL } from './stl.js'; -import { OBJ } from './obj.js'; -import { TMF } from './3mf.js'; -import { SVG } from './svg.js'; +import { load_file } from './file.js'; +const { STL, OBJ, TMF, SVG } = load_file; const CDH = 'Content-Disposition'; export function load_url(url, options = {}) { From 6205487406488a17d8ae0a7162cbe08b03bf3041 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 25 Jan 2026 14:29:39 -0500 Subject: [PATCH 035/131] resize engine code and preview boxes --- web/kiri/engine.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/kiri/engine.html b/web/kiri/engine.html index 40b62df6..5a54167a 100644 --- a/web/kiri/engine.html +++ b/web/kiri/engine.html @@ -31,12 +31,17 @@ margin-right: 1rem; } #jcode, #gcode { - width: 400px; height: 400px; margin: 5px; border-radius: 3px; border: 1px solid #aaa; } + #jcode { + width: 500px; + } + #gcode { + width: 300px; + } #jcode, #gcode { display: flex; flex-direction: column; From 3be19e4b38e410cfc335d69b853eaa90498639dd Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 25 Jan 2026 20:25:53 -0500 Subject: [PATCH 036/131] add carvera air --- src/kiri/dev/cam/Makera.Carvera.Air.json | 181 +++++++++++++++++++++++ src/kiri/dev/cam/Makera.Carvera.json | 2 +- 2 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/kiri/dev/cam/Makera.Carvera.Air.json diff --git a/src/kiri/dev/cam/Makera.Carvera.Air.json b/src/kiri/dev/cam/Makera.Carvera.Air.json new file mode 100644 index 00000000..7fc8c816 --- /dev/null +++ b/src/kiri/dev/cam/Makera.Carvera.Air.json @@ -0,0 +1,181 @@ +{ + "mode": "CAM", + "internal": 0, + "bedHeight": 2.5, + "bedWidth": 300, + "bedDepth": 200, + "originCenter": false, + "spindleMax": 20000, + "gcodePre": [ + "G21 ; set units to MM (required)", + "G90 ; absolute position mode (required)", + "G0 F2000 ; default rapid move speed", + "G1 F1000 ; default cutting speed" + ], + "gcodePost": [ + "M5 ; spindle off", + "G0 F4000 ; default rapid move speed", + "G1 F1000 ; default cutting speed", + "M30 ; program end" + ], + "gcodeDwell": [ + "G4 P{time} ; dwell for {time}ms" + ], + "gcodeSpindle": [ + "M3 S{spindle}" + ], + "gcodeChange": [ + "M6 T{tool} ; change tool to '{tool_name}'" + ], + "gcodeFExt": "nc", + "gcodeSpace": true, + "gcodeStrip": false, + "gcodeResetA": ["G92.4 A0 R0"], + "new": false, + "deviceName": "Makera Carvera Air", + "maxHeight": 130, + "useLaser": true, + "useIndexed": true, + "imageURL": "", + "bedBelt": false, + "bedRound": false, + "fwRetract": false, + "profiles": [ + { + "processName": "default", + "camLevelTool": 1000, + "camLevelSpindle": 1000, + "camLevelOver": 0, + "camLevelSpeed": 1000, + "camLevelDown": 0, + "camRoughTool": 1666284436865, + "camRoughSpindle": 20000, + "camRoughDown": 3, + "camRoughOver": 0.25, + "camRoughSpeed": 1000, + "camRoughPlunge": 250, + "camRoughStock": 0, + "camRoughVoid": false, + "camRoughFlat": false, + "camRoughTop": false, + "camRoughIn": false, + "camRoughOn": true, + "camOutlineTool": 1665437735549, + "camOutlineSpindle": 0, + "camOutlineDown": 1, + "camOutlineOver": 0.4, + "camOutlineSpeed": 2000, + "camOutlinePlunge": 500, + "camOutlineWide": false, + "camOutlineDogbone": false, + "camOutlineOmitThru": false, + "camOutlineOut": false, + "camOutlineIn": false, + "camOutlineOn": true, + "camContourTool": 1666284436865, + "camContourSpindle": 20000, + "camContourOver": 0.15, + "camContourSpeed": 1500, + "camContourAngle": 85, + "camContourCurves": true, + "camContourIn": false, + "camContourXOn": true, + "camContourYOn": true, + "camTraceTool": 1002, + "camTraceSpindle": 1000, + "camTraceType": "clear", + "camTraceOver": 0.5, + "camTraceDown": 1, + "camTraceSpeed": 750, + "camTracePlunge": 200, + "camTraceLines": false, + "camDrillTool": 1000, + "camDrillSpindle": 1000, + "camDrillDownSpeed": 250, + "camDrillDown": 2, + "camDrillDwell": 250, + "camDrillLift": 2, + "camDrillingOn": false, + "camRegisterSpeed": 1000, + "camFlipAxis": "X", + "camFlipOther": "", + "camTabsWidth": 20, + "camTabsHeight": 5, + "camTabsDepth": 10, + "camTabsMidline": false, + "camDepthFirst": true, + "camEaseDown": false, + "camOriginTop": true, + "camZAnchor": "middle", + "camZOffset": 0, + "camZBottom": 0, + "camZClearance": 1, + "camZThru": 0, + "camFastFeed": 3000, + "camFastFeedZ": 500, + "camTolerance": 0, + "camStockX": 0, + "camStockY": 0, + "camStockZ": 0, + "camStockOffset": true, + "camStockClipTo": false, + "camStockOn": true, + "camConventional": false, + "camOriginCenter": false, + "outputInvertX": false, + "outputInvertY": false, + "camExpertFast": false, + "ops": [], + "op2": [ + { + "type": "flip", + "axis": "X", + "invert": true, + "disabled": false + } + ], + "camTrueShadow": false, + "camDrillMark": true, + "camPocketSpindle": 10000, + "camPocketTool": 1001, + "camPocketOver": 0.25, + "camPocketDown": 5, + "camPocketSpeed": 2000, + "camPocketPlunge": 500, + "camPocketExpand": 0, + "camContourBottom": false, + "camTraceBottom": false, + "cmaPocketOutline": false, + "camRegisterThru": 5, + "camFlatness": 0.001, + "camContourBridge": 10, + "camForceZMax": false, + "camPocketSmooth": 1, + "camPocketContour": true, + "cmaPocketRefine": 20, + "camPocketEngrave": false, + "camLaserEnable": [ + "M321" + ], + "camLaserDisable": "M322", + "camLaserOn": [ + "M3" + ], + "camLaserOff": [ + "M5" + ], + "camLaserSpeed": 100, + "camLaserPower": 1, + "camLaserAdaptive": true, + "camLaserAdaptMod": true, + "camLaserFlatten": false, + "camLaserFlatZ": 0, + "camLaserPowerMin": 1, + "camLaserPowerMax": 0.1, + "camLaserZMin": 0, + "camLaserZMax": 0, + "camOutlineTop": false, + "X": "X" + } + ] +} diff --git a/src/kiri/dev/cam/Makera.Carvera.json b/src/kiri/dev/cam/Makera.Carvera.json index e61770d5..a4f885c5 100644 --- a/src/kiri/dev/cam/Makera.Carvera.json +++ b/src/kiri/dev/cam/Makera.Carvera.json @@ -32,7 +32,7 @@ "gcodeStrip": false, "gcodeResetA": ["G92.4 A0 R0"], "new": false, - "deviceName": "My Makera Carvera", + "deviceName": "Makera Carvera", "maxHeight": 150, "useLaser": true, "useIndexed": true, From e103ffddbd89de0fd52cbbe0e255a5198f381426 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 25 Jan 2026 20:27:14 -0500 Subject: [PATCH 037/131] reduce max spindle speed for air --- src/kiri/dev/cam/Makera.Carvera.Air.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kiri/dev/cam/Makera.Carvera.Air.json b/src/kiri/dev/cam/Makera.Carvera.Air.json index 7fc8c816..56df45e4 100644 --- a/src/kiri/dev/cam/Makera.Carvera.Air.json +++ b/src/kiri/dev/cam/Makera.Carvera.Air.json @@ -5,7 +5,7 @@ "bedWidth": 300, "bedDepth": 200, "originCenter": false, - "spindleMax": 20000, + "spindleMax": 13000, "gcodePre": [ "G21 ; set units to MM (required)", "G90 ; absolute position mode (required)", From e9d34048326abac626e0baf51077280caa01d4dd Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Mon, 26 Jan 2026 20:48:53 -0500 Subject: [PATCH 038/131] add spindle spin up delay for carvera air --- src/kiri/dev/cam/Makera.Carvera.Air.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/kiri/dev/cam/Makera.Carvera.Air.json b/src/kiri/dev/cam/Makera.Carvera.Air.json index 56df45e4..8474dd18 100644 --- a/src/kiri/dev/cam/Makera.Carvera.Air.json +++ b/src/kiri/dev/cam/Makera.Carvera.Air.json @@ -22,7 +22,8 @@ "G4 P{time} ; dwell for {time}ms" ], "gcodeSpindle": [ - "M3 S{spindle}" + "M3 S{spindle}", + "G4 P4000" ], "gcodeChange": [ "M6 T{tool} ; change tool to '{tool_name}'" From 0879efd05c59fa9059676a617d3414effb10253f Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 27 Jan 2026 17:37:19 -0500 Subject: [PATCH 039/131] fix leave_xy offset for roughing cutout --- src/kiri/mode/cam/work/op-rough.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/kiri/mode/cam/work/op-rough.js b/src/kiri/mode/cam/work/op-rough.js index fbe50235..c1df6cbc 100644 --- a/src/kiri/mode/cam/work/op-rough.js +++ b/src/kiri/mode/cam/work/op-rough.js @@ -83,6 +83,10 @@ class OpRough extends CamOp { // outside only if we're not clearing all of stock if (cutOutside && !op.all) { + if (op.leave) { + // recompute area with offset when provided + areas = POLY.flatten(POLY.expand(shadowBase, tool.fluteDiameter() / 2 - 0.001 + op.leave)); + } ops_list.push(new OpArea(state, { rename: op.rename ?? "cutout", spindle: op.spindle, From e38287beb72d7a3a8e1766ca125c7a79a19906de Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Tue, 27 Jan 2026 21:50:59 -0500 Subject: [PATCH 040/131] support layer diff ... todo for solid top/base --- src/kiri/mode/fdm/work/slice.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 6852f5ba..1dafe0aa 100644 --- a/src/kiri/mode/fdm/work/slice.js +++ b/src/kiri/mode/fdm/work/slice.js @@ -626,6 +626,26 @@ export function sliceOne(settings, widget, onupdate, ondone) { trackupdate((++count/length), div[1].lo, div[1].hi, "support"); } + // TODO layerDiff shadows to identify tops/bottoms of pillars + if (false) { + let ps = []; + for (let slice of stack.slice().reverse()) { + let { supports } = slice; + let supportsDown = slice.down ? slice.down.supports : []; + let bridges = [], flats = []; + slice.supportsDiff = { bridges, flats }; + ps.push(self.kiri_worker.minions + .subtract({ + a: supports, b: supportsDown, + outA: bridges, outB: flats, + area: 1, wasm: true, z: slice.z, + }) + ); + } + await Promise.all(ps); + console.log({ slices: slices.map(s => s.supportsDiff) }); + } + // trim using support part offset value let gaps = process.sliceSupportGap; for (let slice of stack) { From c7a6fe1920d98df91bd9c2a54f5b105bf51bdeb6 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Thu, 29 Jan 2026 16:03:00 -0500 Subject: [PATCH 041/131] add opfs and api link to it --- src/kiri/app/api.js | 3 ++ src/moto/opfs.js | 121 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 src/moto/opfs.js diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js index 275a123b..aa5ea324 100644 --- a/src/kiri/app/api.js +++ b/src/kiri/app/api.js @@ -40,6 +40,8 @@ import { visuals } from './visuals.js'; import { widgets } from './widgets.js'; import { workspace } from './workspace.js'; +import { OPFS } from '../../moto/opfs.js'; + // environment setup let LOC = self.location, EVENT = broker, @@ -228,6 +230,7 @@ export const api = { onkey(fn) { api.feature.on_key2.push(fn); }, + opfs: OPFS, platform, process: processModule, sdb: dataLocal, diff --git a/src/moto/opfs.js b/src/moto/opfs.js new file mode 100644 index 00000000..5b23196b --- /dev/null +++ b/src/moto/opfs.js @@ -0,0 +1,121 @@ +const root = await navigator.storage.getDirectory(); + +function resolvePath(path) { + if (!path) { + return []; + } else if (typeof path === 'string') { + path = path.trim(); + while (path.charAt(0) === '/') { + path = path.substring(1); + } + return path.length ? path.split('/') : []; + } else if (Array.isArray(path)) { + return path; + } else { + throw "invalid path value"; + } +} + +export async function dirHandle(path, options = { create: true }) { + path = resolvePath(path); + if (path.length === 0) { + return root; + } + let dir = root; + for (let tok of path) { + try { + dir = await dir.getDirectoryHandle(tok, options); + } catch (error) { + if (options.report) { + console.log({ path, error }); + } + return undefined; + } + } + return dir; +} + +export async function fileHandle(path, options = { create: false }) { + path = resolvePath(path); + if (path.length === 0) { + return undefined; + } + let target = path.pop(); + let dir = await dirHandle(path); + return dir.getFileHandle(target, options); +} + +export async function clear() { + for await (let name of root.keys()) { + await root.removeEntry(name, { recursive: true }); + } +} + +export async function remove(path) { + path = resolvePath(path); + if (path.length === 0) { + return clear(); + } + let target = path.pop(); + let dir = await dirHandle(path); + console.log({ dir, target }); + await dir.removeEntry(target, { recursive: true }); +} + +export async function entries(path) { + return (await dirHandle(path)).entries(); +} + +export async function values(path) { + return (await dirHandle(path)).values(); +} + +export async function keys(path) { + return (await dirHandle(path)).keys(); +} + +export async function tree(path) { + path = resolvePath(path); + let dir = await dirHandle(path); + return treeFrom(dir, path, []); +} + +async function treeFrom(dir, path, list) { + let entries = await dir.entries() + for await (let [ name, handle ] of entries) { + path.push(name); + if (handle.kind === 'directory') { + await treeFrom(handle, path, list); + } else { + list.push(path.join('/')); + } + path.pop(); + } + return list; +} + +export async function getText(path) { + let handle = await fileHandle(path); + let file = await handle.getFile(); + return file.text(); +} + +export async function putText(path, text) { + let handle = await fileHandle(path, { create: true }); + let stream = await handle.createWritable(); + await stream.write(text); + return stream.close(); +} + +export const OPFS = { + dirHandle, + fileHandle, + entries, + keys, + values, + tree, + clear, + remove, + getText, + putText +}; From c64337f3c42969c7da2af7d2031d17830b57f6cb Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 1 Feb 2026 09:43:42 -0500 Subject: [PATCH 042/131] expose mesh worker log function --- src/mesh/geom.js | 1 - src/mesh/tool.js | 2 +- src/mesh/work.js | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mesh/geom.js b/src/mesh/geom.js index a3041072..24cfea4f 100644 --- a/src/mesh/geom.js +++ b/src/mesh/geom.js @@ -2,7 +2,6 @@ import { THREE } from '../ext/three.js'; const { Matrix4, Matrix3, Vector3, Box3 } = THREE; -import { license as motoLicense } from '../moto/license.js'; // geometry helper functions const geom = { diff --git a/src/mesh/tool.js b/src/mesh/tool.js index 466bb692..82d8d9d0 100644 --- a/src/mesh/tool.js +++ b/src/mesh/tool.js @@ -929,7 +929,7 @@ class MeshTool { } pitch = p.round(3); - geom.log(`gear pitch radius: ${pitch}`); + log(`gear pitch radius: ${pitch}`); } return { gear, pitch }; diff --git a/src/mesh/work.js b/src/mesh/work.js index ccbd0d54..107279ad 100644 --- a/src/mesh/work.js +++ b/src/mesh/work.js @@ -35,6 +35,8 @@ function log(msg) { return worker.publish("mesh.log", msg); } +self.log = log; + function cacheUpdate(id, data) { return Object.assign(cache[id], data); } From a001089f0f5a50a0a9fda70343164ea4cb1b35ab Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 1 Feb 2026 11:09:34 -0500 Subject: [PATCH 043/131] add install progress bar --- web/boot/index.html | 36 ++++++++++++++++++++++++++++++++---- web/boot/service.js | 2 ++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/web/boot/index.html b/web/boot/index.html index cfee6307..0507bc19 100644 --- a/web/boot/index.html +++ b/web/boot/index.html @@ -18,6 +18,20 @@ font-family: sans-serif; font-weight: bold; } + #progress { + margin-top: 20px; + width: 300px; + height: 20px; + border: 1px solid black; + border-radius: 10px; + background-color: white; + overflow: hidden; + } + #bar { + background-color: #555; + height: 20px; + width: 0%; + } -Booting Grid.Space + +
Installing Grid.Space
+
+
+
+ \ No newline at end of file diff --git a/web/boot/service.js b/web/boot/service.js index 3de012a6..ff7e83e4 100644 --- a/web/boot/service.js +++ b/web/boot/service.js @@ -193,8 +193,10 @@ async function preloadBundle() { const res = await fetch(BUNDLE_URL, { cache: 'no-store' }); const buf = await res.arrayBuffer(); const files = await unpackBundle(buf); + const total = Object.keys(files).length; await Promise.all( Object.entries(files).map(([path, blob]) => { + broadcast({ progress: loaded/total }); const ext = path.split('.').pop(); const type = ext === 'html' ? 'text/html' : From be97a79cb299c59f1063f54b3886f9d193d6d227 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 1 Feb 2026 11:10:54 -0500 Subject: [PATCH 044/131] bump rev 4.6.2 --- package.json | 2 +- src/moto/license.js | 2 +- web/kiri/manifest.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9e2640a0..c119275e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grid-apps", - "version": "4.6.1", + "version": "4.6.2", "description": "grid.space 3d slicing & modeling tools", "author": "Stewart Allen ", "license": "MIT", diff --git a/src/moto/license.js b/src/moto/license.js index b0a40326..c21b3544 100644 --- a/src/moto/license.js +++ b/src/moto/license.js @@ -3,7 +3,7 @@ const terms = { COPYRIGHT: "Copyright (C) Stewart Allen - All Rights Reserved", LICENSE: "See the license.md file included with the source distribution", - VERSION: "4.6.1" + VERSION: "4.6.2" }; export const beta = 0; diff --git a/web/kiri/manifest.json b/web/kiri/manifest.json index 510c6eac..5d0c1b50 100644 --- a/web/kiri/manifest.json +++ b/web/kiri/manifest.json @@ -1,5 +1,5 @@ { - "name": "Kiri:Moto 4.6.1", + "name": "Kiri:Moto 4.6.2", "short_name": "Kiri:Moto", "description": "Slicer for 3D printers, CNC mills, laser cutters and more", "start_url": "/boot/index.html", From becb464715b0d637c99e77f4f3221e5df9d5d0fe Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sun, 1 Feb 2026 11:23:06 -0500 Subject: [PATCH 045/131] update esbuild target --- bin/esbuild.config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/esbuild.config.mjs b/bin/esbuild.config.mjs index 1a55e3e8..15073505 100644 --- a/bin/esbuild.config.mjs +++ b/bin/esbuild.config.mjs @@ -29,7 +29,7 @@ async function appendExtraModules(extras, outfile, minify = false) { const result = await transform(code, { minify: true, loader: 'js', - target: 'es2020', + target: 'es2022', }); return result.code; }) @@ -92,7 +92,7 @@ const rec = { minify: isProd, // false for dev, true for prod platform: 'browser', sourcemap: false, - target: 'es2020', + target: 'es2022', }; async function buildApp() { From 3e517d88dc247c8a2d107d3de295d30c84cd3d3d Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Fri, 6 Feb 2026 13:53:24 -0500 Subject: [PATCH 046/131] fix op regsiter -,= modes --- src/geo/paths.js | 2 +- src/kiri/mode/cam/work/op-register.js | 4 ++-- src/kiri/mode/cam/work/prepare.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/geo/paths.js b/src/geo/paths.js index 1e72369b..a88480eb 100644 --- a/src/geo/paths.js +++ b/src/geo/paths.js @@ -121,7 +121,7 @@ export function poly2polyEmit(array, startPoint, emitter, opt = {}) { continue; } let area = poly.open ? 1 : poly.area(); - poly.forEachPoint(function (point, index) { + poly.forEachPoint((point, index) => { dist = opt.weight ? startPoint.distTo3D(point) * area * area : startPoint.distTo2D(point); diff --git a/src/kiri/mode/cam/work/op-register.js b/src/kiri/mode/cam/work/op-register.js index 69f7199d..c6db5215 100644 --- a/src/kiri/mode/cam/work/op-register.js +++ b/src/kiri/mode/cam/work/op-register.js @@ -147,13 +147,13 @@ class OpRegister extends CamOp { prepare(ops, progress) { let { op } = this; - let { emitDrills, setDrill, setTool, setTravelBoundary } = ops; + let { emitDrills, emitTraces, setDrill, setTool, setTravelBoundary } = ops; setTravelBoundary(); if (op.axis === '-' || op.axis === '=') { setTool(op.tool, op.feed, op.rate); for (let slice of this.sliceOut) { - ops.emitTrace(slice); + emitTraces(slice.camLines); } } else { setTool(op.tool, undefined, op.rate); diff --git a/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 23f44a18..36f32589 100644 --- a/src/kiri/mode/cam/work/prepare.js +++ b/src/kiri/mode/cam/work/prepare.js @@ -973,7 +973,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { // console.log('coming from another widget', { printPoint }); } else if (center) { // we're the first widget output. offset is center - printPoint = origin.clone().move({ x: center.x, y: center.y }); + printPoint = origin.clone().move({ x: center.x, y: center.y, z: 0 }); // console.log('first widget output', { printPoint }); } else { console.log({ missing_center_using_origin: origin }); From 9e142fbe1e25a817fc56fa2174a9956919a154ee Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 11 Feb 2026 10:42:21 -0500 Subject: [PATCH 047/131] add linux electron flags to be more permissive with graphics cards --- app-el.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app-el.js b/app-el.js index d1d349b0..48c893ad 100644 --- a/app-el.js +++ b/app-el.js @@ -14,6 +14,23 @@ const devel = process.argv.slice(2).map(v => v.replaceAll('-', '')).indexOf('dev process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = true; +// Enable GPU/graphics acceleration for Linux (addresses WebGL issues) +// Equivalent to Chrome flags that fixed the rendering in Chrome browser +if (process.platform === 'linux') { + // CRITICAL: Override GPU blocklist - allows hardware acceleration on blocked GPUs + app.commandLine.appendSwitch('ignore-gpu-blocklist'); + + // Enable WebGL draft extensions (improves WebGL compatibility) + app.commandLine.appendSwitch('enable-webgl-draft-extensions'); + + // Force GPU acceleration for 2D/3D rendering + app.commandLine.appendSwitch('enable-gpu-rasterization'); + + // Optional: Try Vulkan if available (Chromium auto-falls back to OpenGL if not) + // Uncomment if you need Vulkan specifically, but most systems work without it: + app.commandLine.appendSwitch('enable-features', 'Vulkan'); +} + server({ port: 5309, apps: basDir, From 02fb3cd30d6ff1fab14001cc5b4d11158d96d22d Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Sat, 14 Feb 2026 19:00:48 -0500 Subject: [PATCH 048/131] merge experimental void app --- .github/workflows/prettier-check.yml | 25 +- .gitignore | 1 + app.js | 30 +- bin/bundle-prod.config.json | 3 +- bin/esbuild.config.mjs | 17 + bin/webpack-three-bundle.js | 4 +- contributing.md | 25 - docs/agents.md | 960 ++++++++ docs/future.md | 108 + docs/release.md | 390 ++++ docs/void/plan-derived.md | 81 + docs/void/plan-geomgraph.md | 185 ++ notes.md | 119 - package.json | 5 +- release.md | 401 ---- sample/init.js | 35 - sample/kiri.js | 14 - sample/readme.md | 27 - sample/work.js | 12 - src/load/stl.js | 116 +- src/main/void.js | 331 +++ src/moto/opfs.js | 2 +- src/moto/orbit.js | 30 +- src/moto/space.js | 724 +++++-- src/moto/trackball.js | 377 ++++ src/void/api.js | 111 + src/void/api/document.js | 616 ++++++ src/void/api/features.js | 238 ++ src/void/api/geometry_store.js | 98 + src/void/api/origin.js | 79 + src/void/api/solids.js | 2651 +++++++++++++++++++++++ src/void/datum.js | 382 ++++ src/void/interact.js | 369 ++++ src/void/interact/planes.js | 1338 ++++++++++++ src/void/interact/points.js | 137 ++ src/void/interact/selection.js | 134 ++ src/void/interact/selection_resolver.js | 185 ++ src/void/interact/targets.js | 149 ++ src/void/overlay.js | 386 ++++ src/void/palette.js | 53 + src/void/plane.js | 558 +++++ src/void/properties.js | 1088 ++++++++++ src/void/sketch/api.js | 36 + src/void/sketch/constants.js | 17 + src/void/sketch/constraints.js | 358 +++ src/void/sketch/constraints_actions.js | 882 ++++++++ src/void/sketch/constraints_fallback.js | 2125 ++++++++++++++++++ src/void/sketch/constraints_tangent.js | 172 ++ src/void/sketch/create.js | 2132 ++++++++++++++++++ src/void/sketch/curve.js | 112 + src/void/sketch/geometry.js | 1033 +++++++++ src/void/sketch/index.js | 484 +++++ src/void/sketch/marquee.js | 275 +++ src/void/sketch/pointer.js | 1226 +++++++++++ src/void/sketch/runtime.js | 918 ++++++++ src/void/sketch/runtime_arc.js | 186 ++ src/void/sketch/runtime_markers.js | 247 +++ src/void/sketch/runtime_profiles.js | 411 ++++ src/void/sketch/runtime_ui.js | 1705 +++++++++++++++ src/void/sketch/tools.js | 543 +++++ src/void/solid/chamfer.js | 710 ++++++ src/void/solid/kernel.js | 158 ++ src/void/solid/provenance.js | 22 + src/void/solid/rebuild.js | 564 +++++ src/void/toolbar.js | 1451 +++++++++++++ src/void/tree.js | 82 + src/void/tree/model.js | 836 +++++++ src/void/tree/render.js | 322 +++ src/void/viewcube.js | 315 +++ src/void/worker/solids_worker.js | 59 + web/boot/service.js | 2 +- web/icon/kirimoto.png | Bin 0 -> 91785 bytes web/icon/kirimoto2.png | Bin 0 -> 97794 bytes web/icon/meshtool.png | Bin 0 -> 285038 bytes web/icon/voidform.png | Bin 0 -> 93059 bytes web/kiri/index.html | 4 +- web/kiri/manifest.json | 3 +- web/mesh/index.html | 2 +- web/void/index.html | 29 + web/void/style.css | 1235 +++++++++++ 80 files changed, 30437 insertions(+), 813 deletions(-) delete mode 100644 contributing.md create mode 100644 docs/agents.md create mode 100644 docs/future.md create mode 100644 docs/release.md create mode 100644 docs/void/plan-derived.md create mode 100644 docs/void/plan-geomgraph.md delete mode 100644 notes.md delete mode 100644 release.md delete mode 100644 sample/init.js delete mode 100644 sample/kiri.js delete mode 100644 sample/readme.md delete mode 100644 sample/work.js create mode 100644 src/main/void.js create mode 100644 src/moto/trackball.js create mode 100644 src/void/api.js create mode 100644 src/void/api/document.js create mode 100644 src/void/api/features.js create mode 100644 src/void/api/geometry_store.js create mode 100644 src/void/api/origin.js create mode 100644 src/void/api/solids.js create mode 100644 src/void/datum.js create mode 100644 src/void/interact.js create mode 100644 src/void/interact/planes.js create mode 100644 src/void/interact/points.js create mode 100644 src/void/interact/selection.js create mode 100644 src/void/interact/selection_resolver.js create mode 100644 src/void/interact/targets.js create mode 100644 src/void/overlay.js create mode 100644 src/void/palette.js create mode 100644 src/void/plane.js create mode 100644 src/void/properties.js create mode 100644 src/void/sketch/api.js create mode 100644 src/void/sketch/constants.js create mode 100644 src/void/sketch/constraints.js create mode 100644 src/void/sketch/constraints_actions.js create mode 100644 src/void/sketch/constraints_fallback.js create mode 100644 src/void/sketch/constraints_tangent.js create mode 100644 src/void/sketch/create.js create mode 100644 src/void/sketch/curve.js create mode 100644 src/void/sketch/geometry.js create mode 100644 src/void/sketch/index.js create mode 100644 src/void/sketch/marquee.js create mode 100644 src/void/sketch/pointer.js create mode 100644 src/void/sketch/runtime.js create mode 100644 src/void/sketch/runtime_arc.js create mode 100644 src/void/sketch/runtime_markers.js create mode 100644 src/void/sketch/runtime_profiles.js create mode 100644 src/void/sketch/runtime_ui.js create mode 100644 src/void/sketch/tools.js create mode 100644 src/void/solid/chamfer.js create mode 100644 src/void/solid/kernel.js create mode 100644 src/void/solid/provenance.js create mode 100644 src/void/solid/rebuild.js create mode 100644 src/void/toolbar.js create mode 100644 src/void/tree.js create mode 100644 src/void/tree/model.js create mode 100644 src/void/tree/render.js create mode 100644 src/void/viewcube.js create mode 100644 src/void/worker/solids_worker.js create mode 100644 web/icon/kirimoto.png create mode 100644 web/icon/kirimoto2.png create mode 100644 web/icon/meshtool.png create mode 100644 web/icon/voidform.png create mode 100644 web/void/index.html create mode 100644 web/void/style.css diff --git a/.github/workflows/prettier-check.yml b/.github/workflows/prettier-check.yml index 0625c749..2eaf0734 100644 --- a/.github/workflows/prettier-check.yml +++ b/.github/workflows/prettier-check.yml @@ -1,4 +1,4 @@ -name: 'Docs Fomatting Check' +name: 'Docs Formatting Check' on: push: paths: @@ -12,12 +12,19 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Setup bun - uses: oven-sh/setup-bun@v2 + - name: Setup Node + uses: actions/setup-node@v4 with: - bun-version: latest - - name: Install dependencies - run: bun install - - name: check format for docs - run: - bun docs-check + node-version: '20' + - name: Check docs formatting (retry once on transient network failure) + shell: bash + run: | + for attempt in 1 2; do + npx --yes prettier@3.5.3 --config ./conf/prettier.config.js ./docs --check && exit 0 + if [ "$attempt" -lt 2 ]; then + echo "Prettier check failed (attempt $attempt). Retrying..." + sleep 5 + fi + done + echo "Prettier check failed after retries." + exit 1 diff --git a/.gitignore b/.gitignore index 2c523bd1..10826c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ package-lock.json src/ext/jszip-esm.js src/ext/quickjs.js src/ext/three.js +src/void/solver tmp tmp_*/ web/boot/bundle* diff --git a/app.js b/app.js index fb6f1459..4d0a2831 100644 --- a/app.js +++ b/app.js @@ -134,10 +134,12 @@ function init(mod) { "/boot" : redir((pre??"") + "/boot/", 301), "/kiri" : redir((pre??"") + "/kiri/", 301), "/mesh" : redir((pre??"") + "/mesh/", 301), - "/meta" : redir((pre??"") + "/meta/", 301), + "/void" : redir((pre??"") + "/void/", 301), + "/form" : redir((pre??"") + "/form/", 301), "/kiri/index.html" : redir((pre??"") + "/kiri/", 301), "/mesh/index.html" : redir((pre??"") + "/mesh/", 301), - "/meta/index.html" : redir((pre??"") + "/meta/", 301) + "/void/index.html" : redir((pre??"") + "/void/", 301), + "/form/index.html" : redir((pre??"") + "/form/", 301) })); mod.add(handleVersion); mod.add(fixedmap("/api/", api)); @@ -153,12 +155,15 @@ function init(mod) { mod.static("/lib/", "alt"); mod.static("/lib/", "src"); mod.static("/obj/", "web/obj"); - mod.static("/font/", "web/font"); + mod.static("/boot/", "web/boot"); mod.static("/fon2/", "web/fon2"); + mod.static("/font/", "web/font"); + mod.static("/form/", "web/void"); + mod.static("/icon/", "web/icon"); + mod.static("/kiri/", "web/kiri"); mod.static("/mesh/", "web/mesh"); mod.static("/moto/", "web/moto"); - mod.static("/kiri/", "web/kiri"); - mod.static("/boot/", "web/boot"); + mod.static("/void/", "web/void"); // module loader function load_modules(root, force) { @@ -186,10 +191,10 @@ function init(mod) { }); } - // load development and 3rd party modules + // load development and app modules (onshape, thingiverse) load_modules('mod'); - // load optional local modules + // load optional local modules (bambu) load_modules('mods'); // run load functions injected by modules @@ -329,8 +334,10 @@ function initModule(mod, file, dir) { const path = mod.dir + '/' + dir + '/' + file; try { const body = fs.readFileSync(path); - if (debug && !single) logger.log({ inject: code, file, opt }); - if (opt.first) { + if (debug && !single) { + logger.log({ inject: code, file, opt }); + } + if (opt.first && append[code]) { append[code] = body.toString() + '\n' + append[code]; } else { append[code] += body.toString() + '\n'; @@ -369,8 +376,11 @@ function handleSetup(req, res, next) { } const productionMap = { - '/lib/mesh/work.js' : '/lib/pack/mesh-work.js', + '/lib/main/void.js' : '/lib/pack/void-main.js', + '/lib/main/planegcs.wasm' : '/lib/void/solver/planegcs_dist/planegcs.wasm', + '/lib/worker/solids_worker.js' : '/lib/pack/void-work-solid.js', '/lib/main/mesh.js' : '/lib/pack/mesh-main.js', + '/lib/mesh/work.js' : '/lib/pack/mesh-work.js', '/lib/main/kiri.js' : '/lib/pack/kiri-main.js', '/lib/kiri/run/engine.js' : '/lib/pack/kiri-eng.js', '/lib/kiri/run/minion.js' : '/lib/pack/kiri-pool.js', diff --git a/bin/bundle-prod.config.json b/bin/bundle-prod.config.json index 74282072..fe5b39f4 100644 --- a/bin/bundle-prod.config.json +++ b/bin/bundle-prod.config.json @@ -16,7 +16,8 @@ { "src": "src/pack/kiri-pool.js", "dst": "lib/kiri/run/minion.js" }, { "src": "src/gpu/raster-worker.js", "dst": "lib/gpu/raster-worker.js" }, { "src": "src/ext/tween.js", "dst": "lib/ext/tween.js" }, - { "src": "src/moto/license.js", "dst": "lib/moto/license.js" } + { "src": "src/moto/license.js", "dst": "lib/moto/license.js" }, + { "src": "src/void/solver/planegcs_dist/planegcs.wasm", "dst": "lib/main/planegcs.wasm" } ], "excludes": [ "src/pack", diff --git a/bin/esbuild.config.mjs b/bin/esbuild.config.mjs index 15073505..11b24998 100644 --- a/bin/esbuild.config.mjs +++ b/bin/esbuild.config.mjs @@ -9,6 +9,9 @@ const isProd = mode === 'prod'; console.log(`Building in ${mode} mode...`); +const VOID_OUTFILE = 'src/pack/void-main.js'; +const VOID_EXTRAS = [ ]; + const MESH_OUTFILE = 'src/pack/mesh-main.js'; const MESH_EXTRAS = [ ]; @@ -100,6 +103,20 @@ async function buildApp() { // Concatenate kiri devices generateDevices(); + // Bundle void main app + await build(Object.assign({}, rec, { + entryPoints: [ 'src/main/void.js' ], + outfile: VOID_OUTFILE, + })); + + appendExtraModules(VOID_EXTRAS, VOID_OUTFILE, isProd); + + // Bundle void worker + await build(Object.assign({}, rec, { + entryPoints: [ 'src/void/worker/solids_worker.js' ], + outfile: 'src/pack/void-work-solid.js', + })); + // Bundle mesh main app await build(Object.assign({}, rec, { entryPoints: [ 'src/main/mesh.js' ], diff --git a/bin/webpack-three-bundle.js b/bin/webpack-three-bundle.js index 2ff68516..0bd4c2bb 100644 --- a/bin/webpack-three-bundle.js +++ b/bin/webpack-three-bundle.js @@ -7,6 +7,7 @@ import { LineMaterial } from '../node_modules/three/examples/jsm/lines/LineMater import { LineGeometry } from '../node_modules/three/examples/jsm/lines/LineGeometry.js'; import { LineSegments2 } from '../node_modules/three/examples/jsm/lines/LineSegments2.js'; import { LineSegmentsGeometry } from '../node_modules/three/examples/jsm/lines/LineSegmentsGeometry.js'; +import { TrackballControls } from '../node_modules/three/examples/jsm/controls/TrackballControls.js'; import * as MeshBVHLib from '../node_modules/three-mesh-bvh/build/index.module.js'; @@ -19,5 +20,6 @@ export { LineGeometry, LineSegments2, LineSegmentsGeometry, + TrackballControls, MeshBVHLib -}; \ No newline at end of file +}; diff --git a/contributing.md b/contributing.md deleted file mode 100644 index 90f450fa..00000000 --- a/contributing.md +++ /dev/null @@ -1,25 +0,0 @@ - -# Contributing - -This is a community driven project, and we welcome any contributions you'd like to make. You can connect with us on [discord](https://discord.gg/suyCCgr). - - -## Running Locally - -- Ensure you have [node](https://nodejs.org/en/download/), or an equivalent installed -- Clone the [repository](https://github.com/GridSpace/grid-apps) from https://github.com/GridSpace/grid-apps -- `npm run setup` -- `npm run dev` -- Open browser [http://localhost:8080/kiri](http://localhost:8080/kiri) - - -## How to add a new machine - -- Make sure you have your tested machine selected -- Open the developer console -- Run the following code: `kiri.api.conf.get().device` -- Right click on the object and select `Copy object` -- Make a new file in the `src/kiri/dev/` directory, with the name of your machine, no spaces or special characters, and a `.json` extension. -- Paste the copied object into the new file, and save it. -- Publish your changes to a git repo -- Submit a [pull request](https://github.com/GridSpace/grid-apps/compare) diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 00000000..bf8a0d7e --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,960 @@ +# AI Agent TL;DR - gs-apps + +Quick reference for AI agents working on this project. + +## What Is This? + +**gs-apps** is a monorepo containing three Grid.Space web applications: + +| App | Purpose | Status | Entry Point | +| ------------- | ----------------------------- | ---------- | ------------------ | +| **kiri:moto** | Multi-axis CNC/FDM/SLA slicer | Production | `src/main/kiri.js` | +| **mesh:tool** | 3D mesh editor & repair | Active dev | `src/main/mesh.js` | +| **void:form** | Parametric CAD modeler | Phase 1 | `src/main/void.js` | + +All three share common infrastructure in `src/moto/`, `src/geo/`, `src/load/`, and `src/ext/`. + +--- + +## 1. KIRI:MOTO - CNC/FDM/SLA Slicer + +### Purpose + +Multi-mode manufacturing tool for slicing 3D models for CNC milling, 3D printing, laser cutting, SLA, wire EDM, and waterjet. + +### Architecture + +``` +src/ +├── main/kiri.js # Bootstrap entry point (2.3KB) +├── kiri/ +│ ├── app/ # Application layer (45 modules) +│ │ ├── api.js # Main API surface (~10KB) +│ │ ├── platform.js # Platform/printer setup (~40KB) +│ │ ├── inputs.js # UI input handling (~30KB) +│ │ ├── paint.js # Viewport rendering (~23KB) +│ │ ├── widget.js # Core slicing widget (~12KB) +│ │ ├── devices.js # Machine definitions +│ │ └── conf/ # Device/process configs +│ ├── core/ # Engine core (7 modules) +│ │ ├── codec.js # Data encoding/decoding +│ │ ├── print.js # Print/slice object (~30KB) +│ │ ├── slice.js # Slicing logic +│ │ └── widget.js # Widget manipulation (~30KB) +│ ├── mode/ # Machine implementations (7 types) +│ │ ├── cam/ # CNC/CAM milling +│ │ ├── fdm/ # 3D printing (FDM/FFF) +│ │ ├── laser/ # Laser cutting/engraving +│ │ ├── sla/ # Resin printing (SLA) +│ │ ├── drag/ # Drag operations +│ │ ├── wedm/ # Wire EDM cutting +│ │ └── wjet/ # Water jet cutting +│ └── run/ # Worker/threading (5 modules) +│ ├── worker.js # Worker orchestration (~25KB) +│ ├── engine.js # Engine execution (~6KB) +│ └── minion.js # Worker pool (~10KB) +``` + +### Key Features + +- **Multi-threaded slicing**: Web Worker pool (up to 4 minions) +- **Multiple modes**: CAM, FDM, LASER, SLA, WEDM, WJET +- **Device profiles**: JSON-based machine configs (`src/cli/`) +- **Widget-based**: Objects as "widgets" for slicing operations +- **Tabs interface**: Multi-document workspace + +### Routes + +- `/kiri/` - Main slicer interface +- `/lib/pack/kiri-main.js` - Main bundle (~28KB minified) +- `/lib/pack/kiri-work.js` - Worker bundle +- `/lib/pack/kiri-eng.js` - Engine bundle + +### Documentation + +- Full docs: `/Users/stewart/Code/gs-apps/docs/kiri-moto/` +- API reference: `/Users/stewart/Code/gs-apps/docs/kiri-moto/apis.md` + +### Database (IndexedDB) + +- Device profiles, process settings, print history +- Workspace restoration + +--- + +## 2. MESH:TOOL - 3D Mesh Editor + +### Purpose + +Direct 3D mesh editing, boolean operations, mesh repair, face/edge selection, and 2D sketch system. + +### Architecture + +``` +src/ +├── main/mesh.js # Bootstrap entry point (29KB) +└── mesh/ + ├── api.js # Main API surface (~1,730 lines) + ├── build.js # UI builder (~42KB) + ├── model.js # Mesh model class (~26KB) + ├── group.js # Group/assembly (~4KB) + ├── tool.js # Tool operations (~35KB) + ├── work.js # Worker communication (~19KB) + ├── sketch.js # 2D sketch mode (~22KB) + ├── handles.js # Manipulation handles (~9KB) + ├── edges.js # Edge visualization (~6KB) + ├── history.js # Undo/redo system + └── util.js # Utilities (~9KB) +``` + +### Key Features + +- **Mode-based UI**: Object, Tool, Face, Surface, Edge, Sketch modes +- **Boolean operations**: Union, intersect, difference (Manifold WASM) +- **Mesh repair**: Heal, clean, triangulate +- **Face/edge selection**: Direct geometry manipulation +- **2D sketching**: Sketch on 3D planes +- **Group management**: Assemblies and hierarchy +- **Undo/redo**: Full history system + +### UI Components + +- Feature tree (left panel) +- Mode buttons (object/tool/face/surface/edge/sketch) +- Object properties panel +- Wireframe/normals visualization + +### Routes + +- `/mesh/` - Main mesh editor +- `/lib/pack/mesh-main.js` - Main bundle +- `/lib/pack/mesh-work.js` - Worker bundle + +### Database (IndexedDB) + +- `admin` store - Metadata, preferences, cache +- `space` store - Models, groups, sketches + +### Documentation + +- `/Users/stewart/Code/gs-apps/docs/mesh-tool.md` + +--- + +## 3. VOID:FORM - Parametric CAD + +### Purpose + +Onshape-inspired parametric CAD with constraint-based sketching, feature history, and BREP operations. + +### Architecture + +``` +src/ +├── main/void.js # Bootstrap entry point (210 lines) +└── void/ + ├── api.js # API composition root + ├── api/ + │ ├── document.js # Document persistence + revisions/undo/redo + │ ├── features.js # Feature list mutations + │ ├── origin.js # Origin point visibility/state + │ ├── sketch.js # Sketch feature creation scaffold + │ ├── sketch_runtime.js # Sketch runtime orchestrator/state + │ ├── sketch_runtime_arc.js # Arc/line endpoint + arc sampling helpers + │ ├── sketch_runtime_markers.js # Sketch point/arc-center marker builders + │ ├── sketch_runtime_profiles.js # Closed-profile detection + fill loops + │ └── sketch_runtime_ui.js # Sketch runtime style/preview/glyph UI helpers + ├── toolbar.js # Top toolbar UI + ├── tree.js # Tree composition root + ├── tree/ + │ ├── model.js # Tree data/section logic + │ └── render.js # Tree DOM builders + ├── overlay.js # 2D/3D tracking overlay + ├── datum.js # Datum planes (XY, XZ, YZ) + ├── plane.js # Plane primitive class + ├── interact.js # Interaction composition root + event wiring + ├── interact/ + │ ├── sketch.js # Sketch interaction orchestrator (event flow + mutations) + │ ├── sketch_constraints_actions.js # Constraint apply/toggle/delete actions + │ ├── sketch_marquee.js # Marquee selection + geometry hit rules + │ ├── sketch_pointer.js # Pointer/hover/drag gesture handlers + │ ├── sketch_tools.js # Sketch tool mode + keybinding behavior + │ ├── sketch_geometry.js # Sketch hit-test/projection/drag geometry helpers + │ ├── sketch_constants.js # Shared sketch interaction constants + │ ├── planes.js # Plane hover/select/resize + view-normal + │ ├── points.js # Point hover/select hit-testing + │ ├── selection.js # Shared selection state transitions + │ └── targets.js # Sketch target/frame resolution + ├── sketch_constraints.js # Constraint orchestration (planegcs + post-solve hooks) + ├── sketch_constraints_fallback.js # Legacy/incremental fallback solver + ├── sketch_constraints_tangent.js # Tangent constraint solver helpers + └── viewcube.js # ViewCube navigation widget (NEW) +``` + +### Key Features + +- **Feature tree scaffold**: Sidebar structure is present; full history dependency/update graph is not wired yet +- **Datum planes**: XY, XZ, YZ reference planes +- **Constraint sketching**: integrated (`@salusoft89/planegcs` + fallback solver path) +- **Manifold BREP**: planned feature path (extrude/cut/revolve), early stubs today +- **Onshape camera**: Left=select, Middle=pan/zoom, Right=rotate +- **ViewCube**: 3D navigation widget (top-right corner) +- **2D overlay**: SVG overlay for 3D point tracking + +### Status + +**Very early development (Phase 1 foundation, early feature workflow in place)** + +- 3D viewport with Onshape camera controls +- Datum planes with interaction +- Feature tree with default geometry visibility controls +- ViewCube navigation widget +- 2D/3D overlay system +- Document persistence + revision history with undo/redo +- Sketch feature creation scaffold (target plane/face -> sketch feature entry) + +**Current implementation notes (important for agents)** + +- Direct-call architecture in `void:form` (no broker event bus in current runtime path) +- `toolbar` wires real actions for docs, camera modes, undo/redo, and sketch creation +- `toolbar` now includes a `Preferences` dialog (`⚙`) with persisted runtime tuning: + - solid edge loop-promotion threshold (segment count) + - solid edge hover/select `Line2` widths + - fit padding (perspective + orthographic) +- `tree.render()` is still caller-driven for feature mutations; refresh explicitly after non-tree-originated changes +- `src/main/void.js` currently enables overlay test primitives with a hardcoded `if (true)` block (debug scaffolding) +- `Origin` in void is an overlay point (not `space.platform` origin) +- IndexedDB revision store name is `versions` (older notes may still mention `features`) +- Feature tree now includes early history controls: + - per-feature `suppress/unsuppress` + - feature reorder (up/down) + - timeline slider (`0..N`) controlling active rebuild prefix + - all above are revisioned + undo/redoable + - feature creation now inserts at the active timeline marker (`index + 1`) instead of always appending to the end +- Sketch runtime currently renders from the active rebuild set (`features.listBuilt()`), not raw full feature list +- Open TODO: stabilise dual-tangent sketch behavior (`line` tangent to two circles/arcs with endpoint-on-arc constraints) +- Min/max distance constraints are now wired (`circle/arc` vs `point/line/circle-arc`) but still need stability tuning under drag: + - current known issue: circle-in-box (`min` to two orthogonal lines) can feel jerky while drag-resizing radius + - current implementation favors deterministic branching for line targets; revisit with solver-side branch lock per drag gesture if needed +- Known regression history: commit `5093eec4` introduced an overly permissive derived-edge proximity gate (`segLen * 0.35`) in `resolveDerivedEdgeCandidate`; this causes incorrect face/edge picks in sketch derive hover. Keep tight gate (`2.5`) unless replaced with a screen-space metric. +- Geometry graph refactor plan is tracked in `docs/void/plan-geomgraph.md` (surfaces + boundaries as canonical entities; solids as derived artifacts). +- Derived sketch entity rearchitecture plan is tracked in `docs/void/plan-derived.md` (immutable, rebuild-driven references to upstream geometry). +- Terminology (use consistently in code/docs/issues): + - `segment`: one boundary edge between two 3D points + - `chain`: ordered open polyline of connected segments + - `loop`: ordered closed polyline of connected segments + - `surface`: bounded face patch on a solid (planar or curved) + - `region`: selectable enclosed 2D sketch profile area +- TODO (open): de-dup overlapping boundary projections/derives in sketch `Use (u)` flow. + - Symptom: side faces on cubes/cylinders/arc-cutouts can project/derive overlapping duplicate lines. + - Requirement: de-dup identical segments/chains by geometric equivalence (endpoint tolerance + chain shape/length), not by source face id. +- Phase 0 scaffolding status: + - new `GeometryStore` API is wired as the single active path (no rollout flags) +- Phase 1 in-progress status: + - surface/profile/edge hover ranking logic has been extracted into `src/void/interact/selection_resolver.js` + - `src/void/interact/planes.js#getPrimarySurfaceHitFromIntersections()` is now a thin delegate to the resolver + - current behavior is parity-focused (same thresholds and tie-break order), giving a stable seam for future boundary/surface entity routing + - resolver now accepts explicit `mode` + `intents` context (`SELECTION_MODES`, `SELECTION_INTENTS`) while preserving current behavior + - resolver candidates now carry passive canonical entity descriptors: + - `profile -> region` + - `solid-face -> surface` + - `solid-edge -> boundary-segment` + - resolver now sources canonical face/edge entity ids from solids runtime mappings when available: + - face key -> `surface:*` (stable) + - edge key -> `segment:*` (stable) + - loop edge key -> `boundary:*` (stable) + - canonical ids are now primary for selection entity payloads + - solids runtime now publishes a passive geometry snapshot into `document.geometry_store` on sync: + - surfaces, boundaries, segments, points, regions, topology maps + - still read-only; selection and ops remain on legacy paths for parity + - sketch profile/area selection now toggles multi by default (no cmd/meta required); clear remains on `space`/`esc` + - derive (`u`) path now carries canonical entity metadata for segment-backed sources: + - `source.entity.kind = boundary-segment` + - `source.entity.id = segment:faceedge::` + - plus `source.face_key` and `source.boundary_segment_id` for migration bridging + - extrude profile targets now use `region_id` (canonical key) only in selection + rebuild paths + - chamfer edge refs now carry canonical boundary metadata: + - `boundary_segment_id` and `entity: { kind: 'boundary-segment', id: 'segment:...' }` + - chamfer selection sync/remove resolves through canonical boundary refs + - chamfer apply consumes canonical refs (legacy parse path removed) + - boolean/solid-op editing paths removed `input.solids` compatibility branches (use `targets/tools` only) + - TODO (tracked): during sketch editing, allow `Use (u)` derive from other visible sketch entities + +**Phase 2: Sketch System (Current Workstream)** + +- planegcs constraint solver integration is active +- sketch runtime supports point/line/arc/circle/rectangle workflows +- sketch mirror mode is now Onshape-style: + - select exactly one line as mirror axis, then press `M` (or use Constraints -> Mirror) + - while mirror mode is active, clicking sketch entities mirrors them immediately and keeps the axis highlighted +- sketch circular pattern mode (new, WIP): + - enter from `Pattern -> Circular` with exactly one selected center point (point/origin/arc-center) + - while active, clicking sketch entities creates linked circular copies around that center + - pattern constraint glyph stays visible, supports drag offset, and double-click edits copy count + - deleting the pattern glyph removes the driving pattern constraint and leaves copied geometry unbound + - known regression (open): after certain circular-pattern drag operations, some entities become effectively locked/non-movable + - observed after moving patterned elements with additional constraints in sketch + - likely in drag ownership propagation / fallback solver interaction for `circular_pattern` + - status: unresolved, needs focused repro + solver trace +- sketch grid pattern mode (new, WIP): + - entered via `Pattern -> Grid`, requires exactly one selected sketch point as anchor + - creates two construction guide lines (U/V) from anchor with default `horizontal`/`vertical` constraints + - renders two always-visible count glyphs (`Hn`, `Vn`) near guide endpoints (double-click to edit counts) + - copies are regenerated from source using guide-line vectors (guide constraints can be removed for skewed grids) + - known issues (open): + - dragging the grid anchor can invert U/V construction line direction unexpectedly + - patterned circle dimension behavior is inconsistent between source and clone circles (dimension propagation/ownership) + - mirror axis is highlighted purple while mode is active + - each subsequently selected sketch entity is mirrored immediately across that axis + - `Esc` or `Space` exits mirror mode +- constraints currently wired: coincident, point-on-line, fixed, horizontal, vertical, perpendicular, equal, collinear, tangent, arc-center coincident, midpoint, min-distance, max-distance +- deferred: Onshape-like under/fully constrained coloring for sketch entities needs a custom per-entity DoF analysis layer on top of planegcs (not directly exposed as per-entity status by solver) +- horizontal/vertical can target line entities or a selected point pair +- rectangle tools are implemented as constrained line sets: + - corner rectangle + - center rectangle + +**Phase 3: Feature History Scaffold (in progress)** + +- `extrude` can now be created as a history feature from a selected sketch (tree + document/history plumbing) +- 3D solid generation/rebuild is active via Manifold replay (extrude + boolean paths) +- timeline/reorder/suppress semantics are active at the feature-history layer before full BREP ops +- chamfer scaffolding (phase-1 UI only) is active: + - solid-mode edge hover/select now parallels face hover/select + - `Chamfer` feature can be created from selected edges (toolbar button + properties dialog with edge list) + - geometry mutation/rebuild for chamfer edges is not yet applied (selection/dialog/history plumbing only) + +**Solid Pipeline (new scaffold)** + +- `void` now has a dedicated solid path (separate from `kiri/mesh` CSG wrappers): + - `src/void/api/solids.js` (rebuild scheduling + orchestration) + - `src/void/solid/kernel.js` (direct Manifold JS initialization/extrude entrypoint) + - `src/void/solid/rebuild.js` (feature replay -> generated solids artifacts) + - `src/void/solid/provenance.js` (seed provenance model for feature/profile->body mapping) + - `src/void/worker/solids_worker.js` (phase-1 compute worker for rebuild replay) +- Solids tree should read generated artifacts (`doc.generated.solids`) rather than mirroring feature rows. +- Phase-1 worker behavior (current): + - main thread builds a compact rebuild snapshot (`builtFeatures`, sketch planes, profile loops) + - worker runs feature replay + manifold ops off-main-thread + - mesh payload returns as transferable typed arrays (zero-copy `ArrayBuffer` transfer) + - if worker fails, runtime falls back to existing main-thread rebuild path +- Path forward: + - phase-2: incremental suffix replay + cancellation preemption + - phase-3: cached per-feature artifacts keyed by input hash + - phase-4: worker pool for independent heavy ops (exports/tessellation), keeping deterministic rebuild order + +**Sketch MVP Contract (checkpointed, 2026-02-06)** + +- Primitive rollout: + - v1: `point` + `line` + - `arc` implemented + - `circle` implemented (internal circle-mode arc representation) + - Rectangle is not a primitive; model as constrained lines (corner/center patterns) +- Input behavior: + - Click+drag creation for points and lines + - No snapping/inference in v1; rely on explicit constraints +- View behavior: + - No auto camera orientation on sketch edit entry (user uses `n` manually) + - Non-edit sketch display remains gray when visible, hidden when invisible + - While editing a sketch, disable hover-highlight behavior for that sketch +- Coordinate model: + - Store sketch geometry in sketch-local 2D coordinates + - Plane/frame transform maps sketch-local geometry into 3D scene + - This is required for future derived geometry from non-datum faces/parts + - When rendering world-space derived previews inside sketch runtime, convert world coords to parent-local before drawing (avoid double-transform rotation/offset artifacts) + - For solid feature ops that derive cutters from selected edges (ex: chamfer), preserve mesh-topology edges (`indices` adjacency) instead of position-welding vertices for adjacency lookup. Position welding can pair non-adjacent triangles and rotate/offset generated cutters. + - Chamfer cutter prism winding matters: keep end-cap triangle winding outward/consistent with side faces. Reversed cap winding can make Manifold subtraction fail (`NotManifold`) even when cutter placement is correct. +- Constraint rollout (checkpoint): + - Solver-backed enforcement is active (planegcs + fallback) + - Implemented: + - Lines: `horizontal`, `vertical`, `perpendicular` + - Points: `coincident`, `fixed` + - Arc/Circle: `arc_center_coincident` + - Next target set: + - `equal` (line length), `collinear`, `tangent` +- Dimensions: + - Support both driven and derived dimensions (for later variable system) +- Selection roadmap: + - v1: click selection + - later: rectangle selection parity with Onshape semantics: + - right-drag = must fully enclose + - left-drag = crossing/touch selects + - TODO later: bring rectangle/marquee selection parity to non-sketch (global 3D) mode +- Construction geometry: + - Required early + - Toggle selected entity construction state with `q` + - Construction lines render dashed +- Undo/redo granularity: + - One undo unit per mutation (entity create/complete move/change, dimension change) + - Not per low-level pointer gesture frame + +**Sketch Point Rendering (current)** + +- Sketch point and arc-center markers are now shader-based (`THREE.Points` + fragment rings) in WebGL: + - camera-facing, circular, pixel-sized (zoom invariant) + - avoids DOM overlay jitter at high entity counts +- Legacy `_markerParts` compatibility shims are retained so existing hover/select styling code paths continue to work. +- Centralized JS color tuning now starts in `src/void/palette.js` (current coverage: sketch + viewcube, expanding incrementally). +- Sketch non-construction lines/arcs now use `Line2/LineMaterial` for visible hover/select thickness control (`lineWidths` in palette). +- Arc/circle sketch dimensions now use **diameter semantics** (stored/edited/measured as diameter; solver applies radius = diameter / 2). Dimension decoration renders: + - inside circle/arc: full diameter line with arrow end caps + - outside circle/arc: leader line with arrow pointing to the circle +- Known runtime refresh issue (open, under validation): + - if pointer remains over tree/panels long enough, viewport updates can appear stalled (sketch hover/render). Keep `space` activity/refresh alive for UI-target mousemove paths. + +### Routes + +- `/void/` - Primary URL +- `/form/` - Alias (same app) + +### Database (IndexedDB) + +- `admin` store - Metadata, camera position +- `documents` store - Document data +- `versions` store - Revision history (snapshots/deltas) + +### Documentation + +- `/Users/stewart/Code/gs-apps/VOID-FORM.md` - Full implementation notes + +### Dependencies (Unique to void:form) + +- **@salusoft89/planegcs** ^1.1.7 - 2D constraint solver (active) + +--- + +## Shared Infrastructure + +All three apps build on common modules, but usage patterns differ by app: + +### Core Systems (moto/) + +#### 1. Event System (`broker.js` - 156 lines) + +Used heavily by `kiri:moto` and `mesh:tool`. `void:form` currently does not use broker in its runtime path. + +```javascript +import { broker } from '../moto/broker.js'; + +// Publish +broker.publish('feature.selected', { id: 'plane-1' }); + +// Subscribe +broker.subscribe('feature.selected', (data) => { ... }); + +// Typed send interface +broker.send.feature_selected({ id: 'plane-1' }); +``` + +#### 2. 3D Viewport (`space.js` - 55KB) + +Three.js wrapper with camera, scene, and interaction: + +```javascript +import { space } from '../moto/space.js'; + +// Initialize viewport +space.init(container, onMove, useKeys); + +// Scene hierarchy +SCENE (Three.js Scene) +└── WORLD (THREE.Group, rotated -π/2 on X-axis) + └── Your objects here + +// Use space.world.add(), NOT space.scene.add() +space.world.add(group); + +// Camera controls (Onshape-style for void, configurable for others) +space.view.top() // Top view +space.view.front() // Front view +space.view.right() // Right view +space.view.left() // Left view +space.view.back() // Back view +space.view.bottom() // Bottom view +space.view.fit() // Fit all to view + +// Camera state +space.view.save() // Returns { left, up, panX, panY, panZ, scale } +space.view.load(state) // Restore saved state +space.view.getFocus() // Get orbit target +space.view.setFocus(vec3) // Set orbit target + +// Mouse bindings (configurable) +RIGHT = Orbit // Rotate around target +MIDDLE = Pan // Pan view +WHEEL = Zoom // Zoom in/out + +// Internals access +const { camera, renderer, raycaster, platform, container } = space.internals(); + +// Listen for camera changes +space.view.ctrl.addEventListener('change', callback); + +// After-render callbacks (for ViewCube, etc.) +space.afterRender((renderer) => { + // Custom render pass + viewcube.render(renderer); +}); + +// Tracking plane for drag operations (void:form) +space.tracking.setMode('camera-aligned'); // 'platform', 'camera-aligned', 'world-xy' +space.tracking.setDistance(1000); // Distance from camera +space.tracking.getMode(); // Get current mode +space.tracking.getPlane(); // Get THREE.Mesh for advanced use +``` + +#### 3. Camera Controls (`orbit.js` - 25KB) + +Orbit control class for camera manipulation: + +- Spherical coordinates (theta/phi) +- Pan, zoom, rotate operations +- Tweening for smooth animations +- Touch support + +#### 4. Web UI Helpers (`webui.js` - 4KB) + +```javascript +import { $, $C, h } from '../moto/webui.js'; + +$('element-id') // Get element by ID +$C('ClassName') // Get elements by class +h.div([...]) // Create DOM elements +``` + +#### 5. Worker System (`client.js`, `worker.js`) + +Web Worker abstraction with promise-based API: + +```javascript +import { client } from '../moto/client.js'; + +const worker = client.new('worker-url.js'); +worker.send('method', data).then(result => { ... }); +``` + +### Geometry & Math (geo/) + +Shared by all apps for 2D/3D operations: + +- `base.js` - Core math utilities (22KB) +- `polygon.js` - 2D polygon operations (48KB) +- `polygons.js` - Multi-polygon operations (39KB) +- `point.js` - Point data structure (30KB) +- `paths.js` - Path operations (27KB) +- `slicer.js` - Slicing algorithms (31KB) +- `line.js`, `bounds.js`, `csg.js`, etc. + +### File Loading (load/) + +Format detection and parsing: + +- `file.js` - Auto-detect file type +- `stl.js` - STL (binary & ASCII) +- `obj.js` - Wavefront OBJ +- `3mf.js` - 3MF (Microsoft 3D) +- `step.js` - STEP (CAD format) +- `svg.js` - SVG (2D vector) +- `gbr.js` - Gerber (PCB format) +- `png.js` - PNG (height map) + +### External Libraries (ext/) + +Pre-integrated WASM and libraries: + +- `three.js` - Three.js v0.182.0 (2.4MB) +- `manifold.js` - 3D boolean operations (WASM) +- `quickjs.js` - JavaScript VM (2.4MB WASM) +- `jszip.js` - ZIP file handling +- `jspoly.js` - Polygon library (240KB) +- `clip2.js` - Polygon clipping (203KB) +- `pngjs.js` - PNG reading +- `earcut.js` - Polygon triangulation +- `tween.js` - Animation tweening +- `md5.js` - MD5 hashing + +### Data Storage (data/) + +IndexedDB wrapper: + +```javascript +import { open as dataOpen } from '../data/index.js'; + +const stores = dataOpen('dbname', { + stores: ['admin', 'documents'], + version: 1 +}).init(); + +const db = { + admin: stores.promise('admin'), + documents: stores.promise('documents') +}; + +db.admin.put('key', value); +db.admin.get('key').then(value => { ... }); +``` + +--- + +## Common Architectural Patterns + +### 1. Three.js Native Objects + +All 3D primitives are native Three.js objects: + +```javascript +import { THREE } from "../ext/three.js"; + +const { Group, Mesh, LineSegments, BoxGeometry, MeshBasicMaterial } = THREE; + +// Create as Group with children +const group = new Group(); +group.add(mesh); +group.add(outline); + +// Add userData for back-references +group.userData.featureType = "plane"; +group.userData.plane = this; + +// Set renderOrder to control draw order (avoid z-fighting) +mesh.renderOrder = 1; +outline.renderOrder = 2; + +// Transparent objects MUST have depthWrite: false +const material = new MeshBasicMaterial({ + transparent: true, + opacity: 0.5, + depthWrite: false, // CRITICAL for transparency +}); +``` + +### 2. Event-Driven Communication + +`kiri:moto` and `mesh:tool` use broker for loose coupling. `void:form` currently uses direct module calls/shared API state. + +```javascript +// Subscribe to events +broker.subscribe("model.updated", (data) => { + updateUI(data); +}); + +// Publish events +broker.publish("model.updated", { model }); + +// Or use typed interface +broker.send.model_updated({ model }); +``` + +```javascript +// void:form pattern (current) +api.document.create(); +api.features.add(feature); +tree.render(); +datum.updateLabels(overlay); +``` + +### 2.1. Void Interaction Contract (Current) + +`void:form` interaction is currently plane-centric and depends on `userData` back-references: + +- Raycast targets are returned from `interact.getInteractiveObjects()` +- Selection/hover resolve via `intersection.object.userData.plane` +- Drag-resize logic is implemented for plane corner handles (`handleType = 'plane-resize'`) +- `space.mouse.*Select()` callbacks are two-phase: first call with no event returns raycast targets, second call handles resolved intersections +- For resize start, `interact.downSelect` should prioritize handle hits from full intersections (`ints`) so selected handles remain draggable when occluded by plane meshes +- Non-plane feature types should extend `src/void/interact/planes.js` + `src/void/interact/targets.js`; `registerPlane()` alone is not sufficient for custom interactions +- Plane labels should be bound to plane changes (size/position/rotation/label), not only camera movement + +### 3. Mouse Interaction Pattern + +Standard pattern across all apps: + +```javascript +space.mouse.downSelect((intersection, event, allIntersections) => { + if (!event) { + // Return objects for raycasting + return [mesh1, mesh2, mesh3]; + } + // Handle click + if (intersection) { + const obj = intersection.object.userData.myObject; + // ... do something + } +}); + +space.mouse.onHover( + (intersection, event, allIntersections) => { + if (!event) return getInteractiveObjects(); + // Handle hover + }, + () => { + // Handle hover exit + } +); + +space.mouse.onDrag((delta) => { + // Handle drag (delta = {x, y} in pixels) +}); +``` + +### 4. Worker/Threading Pattern + +- **Kiri**: Multi-threaded minion pool for slicing (up to 4 workers) +- **Mesh**: Single worker for heavy 3D operations +- **Void**: Single worker for solid rebuild replay (active), constraint solve still on main thread + +### 5. API Surface Pattern + +Each app exports main `api` object: + +```javascript +// Kiri API - ~45 subsystems +api.widgets, api.function, api.mode, api.work, api.device, ... + +// Mesh API - ~18 subsystems +api.selection, api.group, api.model, api.sketch, api.tool, ... + +// Void API - ~6 subsystems (expanding) +api.document, api.features, api.sketch, api.origin, api.selection, api.datum, ... +``` + +### 6. Database Pattern + +IndexedDB with named stores, per-app schema: + +```javascript +dataOpen("appname", { stores: ["admin", "data"], version: 1 }); +api.db.admin.put(key, value); +api.db.data.get(id); +``` + +--- + +## Key Differences Between Apps + +| Aspect | Kiri:Moto | Mesh:Tool | Void:Form | +| --------------- | ---------------------------- | ------------------------------------ | ----------------------------------------------- | +| **Purpose** | Slicing for manufacturing | Mesh editing & repair | Parametric CAD design | +| **Data Model** | Widget-based slicing | Triangle mesh + sketches | Early document/features scaffold + datum planes | +| **UI Pattern** | Tabs + device/process panels | Tree + mode buttons | Toolbar + feature tree scaffold | +| **3D System** | space.js + platform | space.js + platform | space.js + datum planes | +| **Calculation** | Web Workers (minion pool) | Web Worker | Single rebuild worker (active) | +| **Modes** | CAM/FDM/LASER/SLA/WEDM/WJET | Object/Tool/Face/Surface/Edge/Sketch | Sketch mode (phase 2) | +| **Mouse** | Configurable bindings | Standard bindings | Onshape-style bindings | +| **Database** | Profiles, settings, history | Models, groups, sketches | Documents + versions revision history | +| **Status** | Production mature | Actively developed | Very early prototype / Phase 1 foundation | +| **API Size** | ~10KB, 45 subsystems | ~1,730 lines, 18 subsystems | Split modules (document/features/origin/sketch) | + +--- + +## Common Tasks + +### Adding a New Feature Type (void:form) + +1. Create class in `src/void/yourfeature.js` similar to `Plane` +2. Return `THREE.Group` with children (mesh, outline, handles) +3. Set `userData.featureType = 'yourtype'` and `userData.yourfeature = this` +4. For plane-like behavior, register with `interact.registerPlane()`; for non-plane behavior, extend `src/void/interact/planes.js` hit-testing and/or `src/void/interact/targets.js` +5. If the feature has labels/anchors, expose change notifications so overlays update on geometry/transform edits +6. Update `api.document/features` and refresh dependent UI directly (no broker path today) + +### Adding a Tool Operation (mesh:tool) + +1. Add function to `src/mesh/tool.js` +2. Register in `api.tool.yourOperation()` +3. Send to worker if heavy operation (`api.work.send()`) +4. Update UI via broker events +5. Add history entry for undo/redo + +### Adding a Slicing Mode (kiri:moto) + +1. Create mode directory in `src/kiri/mode/yourmode/` +2. Implement slice, setup, export functions +3. Register mode in `api.mode` +4. Add device profiles in `src/cli/` +5. Update worker bundles + +### Working with Transparent Objects + +To avoid z-fighting with transparent planes/faces: + +- Set `renderOrder` (higher = rendered later) +- Use `depthWrite: false` on transparent materials +- Consider separate render passes for complex transparency +- void:form ViewCube uses separate render pass to avoid z-fighting + +### Viewport Rendering (Multiple Passes) + +For widgets needing separate rendering (ViewCube pattern): + +```javascript +space.afterRender((renderer) => { + // Save current viewport + const currentViewport = new THREE.Vector4(); + renderer.getViewport(currentViewport); + + // Set custom viewport (e.g., top-right corner) + renderer.setViewport(x, y, width, height); + renderer.setScissor(x, y, width, height); + renderer.setScissorTest(true); + renderer.autoClear = false; + + // Render your scene + renderer.render(myScene, myCamera); + + // Restore + renderer.setViewport(currentViewport); + renderer.setScissorTest(false); +}); +``` + +ViewCube caveat: + +- `ViewCube` renders in a separate pass via `space.afterRender()` +- Preserve and restore renderer viewport/scissor/autoclear state when adding more overlays/widgets + +--- + +## Critical Rules + +1. **ALWAYS** read files before editing them +2. **NEVER** use `SCENE.add()` - use `space.world.add()` instead +3. **NEVER** forget `depthWrite: false` on transparent materials +4. **ALWAYS** dispose of Three.js geometry/materials when removing objects +5. **ALWAYS** use `userData` for back-references on Three.js objects +6. **PREFER** repo-consistent tooling and keep edits minimal/reviewable +7. **ALWAYS** test z-fighting issues with transparent overlapping geometry +8. **NEVER** modify shared moto/ infrastructure without considering all three apps +9. **USE BROKER WHEN THE APP ALREADY FOLLOWS THAT PATTERN** (`kiri:moto`, `mesh:tool`); `void:form` currently uses direct module calls +10. **NEVER** block the main thread - use workers for heavy computation +11. **RESPECT SPACE MOUSE CALLBACK SHAPE**: target-discovery and event handling are separate phases; use full intersection lists when interaction priority matters + +--- + +## Important File Paths + +### Entry Points + +- `/Users/stewart/Code/gs-apps/src/main/kiri.js` - Kiri:Moto bootstrap +- `/Users/stewart/Code/gs-apps/src/main/mesh.js` - Mesh:Tool bootstrap +- `/Users/stewart/Code/gs-apps/src/main/void.js` - Void:Form bootstrap + +### Core APIs + +- `/Users/stewart/Code/gs-apps/src/kiri/app/api.js` - Kiri API (~10KB) +- `/Users/stewart/Code/gs-apps/src/mesh/api.js` - Mesh API (~1,730 lines) +- `/Users/stewart/Code/gs-apps/src/void/api.js` - Void API composition root +- `/Users/stewart/Code/gs-apps/src/void/api/document.js` - Void document + revisions/undo/redo +- `/Users/stewart/Code/gs-apps/src/void/interact.js` - Void interaction composition root + +### Shared Infrastructure + +- `/Users/stewart/Code/gs-apps/src/moto/space.js` - 3D viewport (55KB) +- `/Users/stewart/Code/gs-apps/src/moto/broker.js` - Event system (156 lines) +- `/Users/stewart/Code/gs-apps/src/moto/orbit.js` - Camera controls (25KB) +- `/Users/stewart/Code/gs-apps/src/moto/webui.js` - DOM helpers (4KB) + +### Geometry & Loading + +- `/Users/stewart/Code/gs-apps/src/geo/` - Math & geometry (12 modules) +- `/Users/stewart/Code/gs-apps/src/load/` - File format loaders (9 formats) +- `/Users/stewart/Code/gs-apps/src/ext/` - External libraries (Three.js, Manifold, etc.) + +### Documentation + +- `/Users/stewart/Code/gs-apps/docs/kiri-moto/` - Kiri:Moto docs (extensive) +- `/Users/stewart/Code/gs-apps/docs/mesh-tool.md` - Mesh:Tool docs +- `/Users/stewart/Code/gs-apps/VOID-FORM.md` - Void:Form implementation notes + +### Configuration + +- `/Users/stewart/Code/gs-apps/app.js` - Express server (routes at lines 135-166) +- `/Users/stewart/Code/gs-apps/package.json` - Dependencies + +--- + +## Routes + +### Development URLs (http://localhost:8080) + +- `/kiri/` - Kiri:Moto slicer +- `/mesh/` - Mesh:Tool editor +- `/void/` - Void:Form CAD (primary) +- `/form/` - Void:Form CAD (alias) + +### Static Assets + +- `/lib/pack/kiri-main.js` - Kiri main bundle (~28KB) +- `/lib/pack/kiri-work.js` - Kiri worker bundle +- `/lib/pack/kiri-eng.js` - Kiri engine bundle +- `/lib/pack/mesh-main.js` - Mesh main bundle +- `/lib/pack/mesh-work.js` - Mesh worker bundle +- `/lib/pack/void-main.js` - Void main bundle + +--- + +## Commands + +```bash +npm install # Install dependencies +npm run dev # Start dev server (port 8080) +npm run build # Build for production +``` + +--- + +## Dependencies + +### Shared (all apps) + +- **three** ^0.182.0 - 3D rendering +- **manifold-3d** ^3.3.2 - BREP operations +- **jszip** - ZIP file handling + +### Void-specific + +- **@salusoft89/planegcs** ^1.1.7 - 2D constraint solver + +--- + +## Git Status + +- Current branch: `rel-4.6-void` +- Main branch: `master` (use for PRs) +- Recent work: ViewCube widget, datum planes, plane primitives + +--- + +## Next Steps + +### Kiri:Moto + +- Mature product, maintenance mode +- Device profile updates +- Mode-specific improvements + +### Mesh:Tool + +- Active development +- Face/edge selection enhancements +- Boolean operation improvements +- Sketch system refinements + +### Void:Form + +**Phase 2: Sketch System** (Next) + +1. planegcs constraint solver integration +2. 2D sketch canvas overlay +3. Geometric primitives (line, circle, arc) +4. Constraints (distance, angle, parallel, perpendicular) + +**Phase 3: Features** + +1. Extrude feature using Manifold +2. Feature history tree with parametric updates +3. Cut, revolve, sweep operations + +--- + +**Last Updated:** 2026-02-03 (ViewCube integration, comprehensive coverage) diff --git a/docs/future.md b/docs/future.md new file mode 100644 index 00000000..8297ada4 --- /dev/null +++ b/docs/future.md @@ -0,0 +1,108 @@ +# Grid:Apps Future Notes + +## `C` cosmetic, `F` functional, `P` performance, `B` bug fix + +- `F` option to hide platform when viewing from below + +# Kiri:Moto + +- `B` origin (and bed size) bug (Onshape?) when switching device modes +- `B` can't drag slider bar on ipad / ios -- touch pad scrolling dodgy +- `B` prevent or ask for really large models when scaling (crash ui) + +- `P` refactor vertex replacement and widget update matrix tracking +- `P` duplicate objects should share same slice data unless rotated or scaled +- `P` move all persisted / workspace settings/data to IndexedDB (LS limitations) +- `P` faster ray intersect https://github.com/gkjohnson/three-mesh-bvh/ +- `P` try material clipping planes for slice range selection +- `P` switch png lib to https://github.com/photopea/UPNG.js + +- `F` edit in Mesh:Tool +- `F` custom device vars for profiles / ranges / gcode +- `F` show slider range values in workspace units (on hover?) +- `F` allow select of a range by typing in values in slices or workspace units +- `F` complete and expose grouping feature +- `F` add svgnest-like arrange algorithm +- `F` date column and sorting in recent files list +- `F` highlight part outside workspace bounds (like can neg Z shader) + +# FDM + +- `B` fix support projection/end with grouped parts +- `B` multi-extruder rendering of raft fails to offset the rest of the print +- `B` multi-extruder purge blocks fail to generate properly for rafts + +- `F` new parameters for bridging speed / bridge fan control +- `F` convert ranges to z offsets while continuing to show layer # +- `F` support pillar top/bottom should conform to part +- `F` more explicit line width control with ranges and min/max adaptive +- `F` test outlining solid projected areas (internally) +- `F` gradient infill https://www.youtube.com/watch?v=hq53gsYREHU&feature=emb_logo +- `F` segment large flat areas on first layer to mitigate peeling +- `F` option to support interior bridges when 0% infill +- `F` calculate filament use per extruder per print +- `F` expand internal supporting flats / solids before projection (threshold) + +- `P` auto purge pillars when quick layers are detected for extra cooling +- `P` solid fill the tops of supports for down facing flats + +# FDM - BELT + +- `B` auto bed resizing leaves the origin in the wrong place at first +- `B` re-add progress calls for all work units + +- `F` test and enable arcs in belt more +- `F` slightly angle supports to lean into the Z of the part +- `F` anchors should be generated anywhere needed in the print, not just head + +# CAM + +- `B` feed rate for next tool set before tool change (push/pop feed rates?) +- `B` tabs do not properly track widget mirror events +- `B` contour does not honor clip to stock + +- `F` allow import, rotation, scaling of stock +- `F` get gcode coordinates off a part with point/click or hover? +- `F` include tools in default devices (Carvera) +- `F` add `{progress}` substitution and maybe `{time-remaining}` if can be calc'd +- `F` import and follow 2D paths (conformed like pocket contours) +- `F` add `plunge max` to contouring that can override z feed limit +- `F` add lead-in milling (requires adding clamp / no go areas) +- `F` add linear clearing strategy +- `F` add adaptive clearing strategy +- `F` change color of line selection in trace op when not a closed poly +- `F` limit cut depth to flute length of selected tool (or warn) +- `F` validate muti-part layout and spacing exceeds largest outside tool diameter +- `F` trapezoidal tabs in Z +- `F` ease-in and ease-out especially on tab cut-out start/stop +- `F` maintain several part orientations + op chains in a single profile + +- `P` outer outside corners as arc moves +- `P` improve parser - do not require spaced tokens and support implied G0 / G1 +- `P` log Z interpolation for contour XYZ moves + +# Laser + +- `F` add PLT / HP-GL output format (https://en.wikipedia.org/wiki/HP-GL) + +# Mesh:Tool + +- add undo/redo +- add TextGeometry +- https://threejs.org/docs/#examples/en/geometries/TextGeometry +- https://dustinpfister.github.io/2023/07/05/threejs-text-geometry/ +- font to path with https://github.com/paulzi/svg-text-to-path +- click to repair normals based on a known good (selected) face +- send to Kiri:Moto workspace (or update model vertices in place) +- better z snap using just vertexes from face intersected +- add section view. local clip. raycast skip points above plane +- add decimate op = face reduction +- add flatten/crush op: for z bottoms (or surfaces?) +- allow setting model/group origin for scale/rotate +- fix mirror to work with groups (just models currently) +- add analyze results dialog +- remove/hide auto-repair function + +# Other + +- preferred icons https://icons.getbootstrap.com/ diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 00000000..d31b2139 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,390 @@ +# Release Notes + +Full docs @ https://docs.grid.space/projects/kiri-moto + +# Release 4.4.0 + +## General + +- add version numbering utility script +- split out help/info menu and language menus +- move install/uninstall/quit menu to center app menu +- add help menu visual callout +- improve language translation coverage +- improve build and serve workflow for alternate builds +- update service worker configuration +- add install/uninstall options in setup menu + +## CAM + +- implement tab hopping - toolpath goes up and over tab instead of interrupting cut (fixes #207) +- add GPU accelerated contouring for faster operations +- add radial GPU raster support (experimental) +- add new register mode that marks bottom cutout tabs +- add curves-only support to GPU contour +- add zsafe and move to safe z at start of new operations +- fix 'safe' moves during operation changes to be moves, not cuts +- fix tolerance/resolution for GPU raster mesh calculations +- fix NullPointerException with tabs in contour +- improve merge/co-planar point handling +- add slice layer naming using operation notes (fixes #447) +- fix level step down units (fixes #446) + +## FDM + +- document remain_time macro variable (refs #339) +- re-add base flow rate multiplier for belt mode (fixes #444) +- fix belt fan control ordering (fixes #438) +- exclude belt from bridge fan layer +- add centering feature for fill areas (fixes #448) + +## Mesh:Tool + +- add STEP export capability +- add STEP import with face generation +- improve face generation with inner holes +- add mesh scripting tool and persistence +- add plane and plane.loft operations +- add global edge map and edge reuse for better topology +- add devel device export option + +## Electron + +- clean up electron build process +- merge electron mod into core +- add service routes when available +- set proper content-type for appended code bodies + +## Dependencies + +- update @gridspace/raster-path to latest version with GPU acceleration +- update @gridspace/app-server for proper fallthrough handling +- switch from npmjs.org to git repos for @gridspace packages +- update WebGPU implementation + +## Bug Fixes + +- fix Bambu Lab local URL and module load order +- fix progress reporting for raster operations +- fix GPU lathe progress and contour API usage +- fix tool offsets when using GPU acceleration +- fix indexed rotations epsilon value for accurate zflat calculation +- fix traceload debug option +- sanitize topo3 resolution to avoid GPU floating-point errors + +# Release 4.3 + +## General + +- add lathe mode for CAM operations +- add volumetric flow calculations for FDM +- improve bundler with better dependency tracking +- migrate to ESM module format across codebase +- improve arc support in gcode generation + +## FDM + +- add scarf seams for better surface finish +- add hole compensation feature +- add spiral layer start option +- overhaul thin wall detection and handling +- improve volumetric flow rate controls +- add retraction tuning for better print quality + +## CAM + +- add lathe operation support with threading +- improve arc support for smoother toolpaths +- add drill from stock top option +- improve tab positioning and generation +- enhance surface selection and filtering +- add 4th axis lathe debug and testing mode +- improve tool path optimization + +## Devices + +- add new machine profiles +- improve device profile management + +# Release 4.2 + +## CAM + +- add arc support to gcode output for smoother paths +- add drill from stock top feature +- improve contour operations +- fix animation issues with shared tool numbers +- enhance trace operations with arc support +- improve pocket smoothing algorithms + +## FDM + +- improve support generation +- add new retraction options +- enhance layer time controls + +## General + +- improve gcode arc import and export +- fix file loading edge cases +- update device profiles + +# Release 4.1 + +## CAM + +- improve drill operations and positioning +- fix multi-part drilling bugs +- enhance trace line selection +- add dogbone support to trace ops +- improve pocket operation reliability +- fix trace selection with flip operations + +## FDM + +- add RatRig machine profiles +- improve Bambu Lab device control integration +- enhance support placement algorithms +- fix belt mode support generation + +## General + +- improve file import handling +- fix workspace restore for complex projects +- enhance mobile touch interactions +- update machine profiles for popular devices + +# Release 4.0 (2024-01-21) + +- major UI refactor +- add profile cloud sync +- add electron desktop binary builds +- add timelines for all cnc op chains +- add dragknife, wire-edm, waterjet device types +- add gerber import file support +- add SVG import dialog, more options +- add arbitrary belt slice angle +- add mesh sketching, new ui, menus, gears, patterns +- fix 3MF imports lacking mesh translation +- improve cam animation +- clean up and normalize dark mode +- optimize docker image size +- new machine profiles +- dozens of bug fixes + +# Release 3.9 (2023-03-19) + +- more graceful handling of security contexts blocking SharedArrayBuffer +- FDM refactor the 'detect' support feature for auto-placing manual supports +- FDM fix supports in belt mode +- CAM fix issue #230 shared tool numbers cause animation errors +- CAM fix surface / trace selection copy on hover/pop/new op +- CAM decrease cutting speed when entire tool is engaged in roughing +- CAM add dogbones support to traces ops +- CAM add scripted contour filtering (to be extended to other ops) +- CAM add calculation of taper length from angle +- CAM add open poly-line offsetting with trace op +- CAM clearly delineate ops reachable or not on timeline +- CAM add 4th axis lathe operation for debug and testing (can be optimized) +- CAM add rough all stock to aid lathe mode +- CAM stock is now always on, whether offset or absolute +- CAM add lathe worker parallelization (2x - 6x speedup) +- CAM fix pocket/trace selection with flip op + +* CAM fix lathe yellow path in light mode + +# Release 3.8 (2023-01-21) + +- update server-side sample module code +- various fixes and improvements to CAM indexing +- various fixes and improvements to gcode variables +- improve gcode arc import decoding +- removed auto-decimation on object import +- FDM slicing memory reduction from team lychee +- FDM add new parameters, range overrides +- FDM fix raft line fill strategy +- CAM improve trace line selection with zoom adaptive thresholds +- CAM option to control first output point order +- CAM move to save Z between ops, refresh spindle speed +- CAM add origin offset optional parameters +- CAM add control to omit initial tool change +- CAM fix vertical face selection and step over defaults +- CAM fix multi-part object import / grouping +- CAM fix tracing nested polyline offset ordering +- CAM refactor of contouring yields 2x - 20x speedup +- CAM update traces to async slicing, fix use with flip +- CAM add threading support for all slicing ops +- CAM add omit pocket option to outline op +- CAM add trace support for taper tip diameter +- CAM add trace offset override parameter +- CAM add leave stock parameter to contour +- CAM add contour output density control (reduction) +- CAM improve parsing and visualization of large files + +# Release 3.7 (2022-11-12) + +- add CAM axes scaling gcode header directive +- add CAM 4th axis indexing support, timeline op, updated visuals +- update most CAM ops to work in 4th axis indexing mode +- align FDM top/bottom layer options with convention +- change Kiri:Moto SVG import to default to boolean repair +- add Mesh:Tool SVG import options for extrusion depth and boolean repair +- replace jscad/modeling with Manifold project for faster mesh boolean +- various CAM gcode, preview, animation fixes (3 axis) +- various mobile touch, file load fixes +- add FDM slice support growth option to help merging pillars +- add CAM tools export / import parity with devices and settings + +# Release 3.6 (2022-10-22) + +## Kiri:Moto + +- add new Carvera machine target in CAM mode with laser support +- add laser output operators and device settings in CAM mode +- add fullscreen option. button next to user profile +- mobile pinch zoom and layer slider usability improvements +- update CLI to work in CAM mode and add working samples +- improved FDM preview rendering speed and reduced memory usage +- threaded task and message passing performance improvements +- refactor FDM supports and synthetic widgets to use more common code +- allow FDM mixing of automatic and manual / detected supports +- improve CAM animation speeds using shared array buffers +- improve CAM render quality using solids instead of lines +- add CAM leveling part offset parameters for XY and Z +- add CAM pocket smoothing and contouring which is closer to true 3 axis +- add CAM 3D engraving and marking with the pocket contouring operation +- fix CAM invalidation of tabs on scale and traces on scale or rotate +- fix belt fan override for base extrusions touching belt +- fix belt X axis label order + +# Release 3.5 (2022-10-07) + +## Kiri:Moto + +- add optional service workers and manifest to support full PWA + install +- add support to run as Progressive Web Apps for installation and offline use +- add assembly import when KM used inside of onshape +- add configurable flatness for contour clipping +- add faster render mode for FDM slices +- add axis label remapping in FDM +- add new path rendering engine +- add bridging option in CAM contouring +- add option to force z max routing in CAM +- add option to ignore z bottom in CAM contouring +- add CAM pocket option to ignore interior features (outline only) +- add CAM Z bottom visualization, make it relative to stock instead of part +- add CAM Z bottom inversion option to flip operator +- add CAM custom gcode operator (can be used for pausing, too) +- add CAM z extend option on registration op independent of "Z Thru" global +- add CAM pocket surface selection filter by angle +- add optional CAM operation notes (helps with many similar ops) +- add option to limit CAM trace ops to Z bottom limit (when in use) +- extend url loading of workspaces to all formats +- alert when healing is enabled and non-manifold geometries are detected +- fix thin output start and end point tracking which broke retraction +- fix for importing with some obj formatting +- fix profile seeding for newer device record formats +- fix workspace import / restore for some file formats +- fix potential crash into stock during moves when parts are z bottom anchored + +# Release 3.4 (2022-05-14) + +## Kiri:Moto + +- added batch processing to object adds/removes to speedup complex workspace restore +- substitute some prusa slicer [variables] with KM `{variables}` on import +- fix CNC output order for tool changes an spindle speed updates +- add CNC pocket operation using surface selection +- fix dog-bones on outlines cut by tabs +- skip pockets that resolve to null +- fix CNC contour path collision +- 10x speedup for true shadow generation +- add FDM gcode feature macros for transitions +- add FDM option to alternate shell winding direction +- add FDM print time estimate fudge factor for devices +- add `clear top` option to CNC outline operation +- add FDM layer retraction as a range option + +## Mesh:Tool (1.2.0) + +- auto-fog in wireframe view to aid close mesh inspections +- significant speed-up for large surface selections +- add boolean operations for subtract and intersect +- multi-body identification and isolation +- quick add primitives: cube, cylinder +- control wireframe transparency +- parameterize png image import +- code added to show camera focal point +- better Z split snapping using vertex closest to mouse + +# Release 3.3 (2022-04-01) + +## Kiri:Moto + +- reorganization of code to use updated dependency loader (gapp) +- refactor main into supporting classes (part of a larger ui re-org) +- group "main" entry points under "kiri-run" +- extract and group utilities from print and other modules +- add thin wall pull-down & allow for newer strategies +- extract preview render engine from FDM +- allow loading of workspaces from url on page load +- properly import profiles attached to devices +- improved routing on "fast" layers & layers with multiple islands +- start/stop minions depending on whether threading enabled +- abstract file loading (onshape import, mesh replace, etc) +- enable/disable ray intersect path on feature state change +- new and updated device profiles: Prusa MK2S/MK3S+, Ender 3 +- trigger solid layer when transitions lead to 50% projected areas +- limit non-manifold solution search depth +- refactor slicers to use single improved slice core (cnc deferred) +- add parameterized solid projection expansion (infill -> solid expand) +- add parameterized control of bridge/flat and infill print speeds +- add api control over threading workloads and use of wasm +- updates to raft generation: add border, connect infill lines +- fix phantom support generation off part or under bed +- template vars: nozzles used and layers until next use (IDEX) +- fdm export control of preamble comments position (for ultimaker) + +## Mesh:Tool (1.1.0) + +- add surface selection mode +- add preferences for normal length and color +- add preferences for face selection and surface matching (radians/radius) +- add svg and image import conversion (created shared load. libs) +- replace triangulation algorithm that was causing some union failures +- add pinned log busy spinner +- add version chooser +- add welcome menu + +# Release 3.2 (2022-02-12) + +https://forum.grid.space/t/kiri-moto-version-3-2/580 + +## General + +- Better memory management +- Rendering speedups +- 3MF instancing support +- SVG import improvements +- Enable/Disable individual models + +## FDM + +- Improved non-manifold handling +- Gcode macro if/then/else code flow +- Updated CLI utility +- Draft Shields + +## Belt + +- Height-based spacing +- Random X layout + +## CNC + +- Drill marking option +- Numerous bug fixes + +## Onshape + +- Improved session management diff --git a/docs/void/plan-derived.md b/docs/void/plan-derived.md new file mode 100644 index 00000000..76bc495e --- /dev/null +++ b/docs/void/plan-derived.md @@ -0,0 +1,81 @@ +# Void Derived Sketch Entities Plan + +## Goal + +Make derived sketch entities deterministic, immutable references that rebuild from upstream geometry, like solids rebuild from sketches. + +## Core Model + +- Derived entities are read-only projections of upstream geometry. +- Upstream geometry is the source of truth. +- Derived entities store source links and projection metadata. + +## Source Links + +Each derived entity should keep: + +- `source_kind` (`sketch-entity`, `solid-boundary`, `solid-face-loop`, etc.) +- `source_feature_id` +- `source_object_id` (entity/boundary/loop id) +- `target_sketch_id` +- projection mode/settings (including tessellation preference version) + +## Immutability Rules + +- Allowed: hover, select, delete/unlink. +- Disallowed: drag/move/reshape directly. +- Solver should not treat derived entities as DOF-driving geometry. + +## Rebuild Rules + +- On upstream change, mark dependent sketches dirty. +- Rebuild derived entities in dependency order. +- Regeneration is deterministic from source links + target sketch plane. + +## Projection Policy + +- For arcs/circles from non-coplanar sources, derive as projected polyline chains using sketch tessellation prefs. +- Do not force preserving source parametric arc/circle form across projection angles. +- For coplanar sources, preserving native type can be added later as an optimization. + +## Interaction Rules + +- Derived geometry has distinct visual style/state. +- Attempts to edit derived geometry should be blocked with clear UI feedback. +- Deleting derived geometry removes the link and generated entities. + +## Failure Handling + +- No silent degenerate fallbacks. +- If source cannot be resolved, mark stale/error and surface user-visible status. +- Keep structured diagnostic logging for source resolution failures. + +## Pipeline Integration + +- Reuse the existing feature rebuild pipeline model used by solids. +- Derived-geometry regeneration should happen as part of sketch feature rebuild. +- Dependents downstream of updated sketches should rebuild from regenerated derived geometry. + +## Implementation Phases + +1. Add explicit derived-link schema and immutable behavior gates. +2. Route derived entities through rebuild pass (not interactive mutation path). +3. Remove fallback editing/solver paths for derived entities. +4. Add stale/error UI and repair actions (rebind, delete link). + +## Status + +- Current state: planned, not implemented end-to-end. +- Existing behavior: mixed interactive derive paths with mutable outcomes and fallback logic. +- Known pain points: unstable derive outcomes, degenerates, and inconsistent rebuild coupling. + +## Next Milestone + +1. Introduce derived-link schema in sketch entity storage. +2. Enforce immutability in interaction layer (block drag/edit, allow select/delete). +3. Rebuild derived entities from links during sketch rebuild. +4. Verify with regression scenario: + - Sketch A -> Extrude A + - Sketch B derived from A/solid boundaries + - Edit Sketch A + - Confirm Sketch B derived entities and downstream solids update deterministically. diff --git a/docs/void/plan-geomgraph.md b/docs/void/plan-geomgraph.md new file mode 100644 index 00000000..13d7e91b --- /dev/null +++ b/docs/void/plan-geomgraph.md @@ -0,0 +1,185 @@ +# Void Geometry Graph Plan (Surfaces + Boundaries) + +## Progress Checkpoint (2026-02-13) + +1. Completed: +2. `GeometryStore` is persisted in document state and populated from solids runtime snapshots. +3. Selection pipeline was split to `interact/selection_resolver.js` and now emits typed candidates (`profile/solid-face/solid-edge`) with canonical entity descriptors. +4. Extrude targets carry `region_id`; chamfer edge refs carry `boundary_segment_id` + entity metadata. +5. New canonical mapping bridge is active in solids selection: +6. face key -> canonical `surface` id +7. edge key -> canonical `boundary-segment` id +8. loop edge key -> canonical `boundary` id +9. Resolver now consumes these mappings (`resolveCanonicalFaceEntity`, `resolveCanonicalEdgeEntity`) as primary IDs. + +## Resume Pointers + +1. Canonical mapping source: +2. `src/void/api/solids.js`: +3. `buildGeometryStoreSnapshot()` map population +4. `resolveCanonicalFaceEntity()` +5. `resolveCanonicalEdgeEntity()` +6. Canonical mapping consumer: +7. `src/void/interact/selection_resolver.js` +8. `resolvePrimarySurfaceHit()` entity assignment for face/edge candidates. +9. Canonical hard cutover completed: +10. Extrude profile refs resolve via `region_id` only. +11. Chamfer refs resolve via canonical `segment:*` / `boundary:*` ids only. +12. `input.solids` compatibility branches removed from solid-op edit paths. +13. Boundary-hover correction applied: +14. Face-edge resolution now prioritizes hovered-face boundary lookup before global solid edge intersections. +15. Boundary extraction uses the same crease threshold as rendered edges (`SOLID_CREASE_ANGLE_DEG`) to reduce partial/extra loop mismatches. +16. `getFaceEdgeHit()` now prefers closed-loop boundaries over open seam chains when both are near cursor. +17. Follow-up TODO: +18. During sketch editing, `Use (u)` should derive from other visible sketch entities (not only solids). + +## Target Architecture + +1. Adopt a single geometric graph centered on `surfaces` and `boundaries`, with solids as derived artifacts only. +2. Treat every selectable thing as one of: +3. `SurfaceRegion` (planar/non-planar bounded patch). +4. `Boundary` (closed loop on a surface). +5. `BoundarySegment` (line/arc/spline edge piece inside a boundary). +6. `BoundaryPoint` (segment endpoint, midpoint, center, intersection, projected point). + +## Core Data Model + +1. Add `GeometryStore` (document-persisted, versioned): +2. `surface_id`, `type` (`planar|curved`), `frame` (for planar), `source` provenance. +3. `boundary_id`, `surface_id`, ordered `segment_ids`, orientation, closure, area sign, nesting depth. +4. `segment_id`, `kind` (`line|arc|circle|polyline|nurbs`), param data, `owner_boundary_id`. +5. `point_id`, world/local coords, role (`endpoint|midpoint|center|derived|intersection`), references. +6. `region_id`, `surface_id`, boundary rings (`outer + holes`), selectable extrusion/chamfer profile token. +7. Add stable identity layer: +8. Every entity gets deterministic `geom_sig` + persistent `id`. +9. Derived entities keep `origin_ref` and dependency chain for rebind/rebuild. +10. Add `TopologyIndex` (runtime cache): +11. Maps solid mesh triangles/edges to source `surface_id` and `boundary_segment_id`. +12. Supports nearest-hit resolution with tolerance and tie-break rules. + +## Selection and Hover System + +1. Replace mode-specific picking with one `SelectionResolver` pipeline. +2. Input: ray hits + current mode + intent mask. +3. Output: ranked candidates of `point/segment/boundary/surface/region`. +4. Ranking rules: +5. Point proximity always wins near threshold. +6. Segment wins next when distance-to-curve below threshold. +7. Boundary/region wins when inside region and not near point/segment. +8. Surface wins otherwise. +9. Add per-mode intent masks: +10. Sketch mode: `point,segment,boundary,surface(region projection)`. +11. Extrude mode: `region` only. +12. Chamfer mode: `segment` only (plus face-to-edge expansion helper). +13. Boolean mode: `surface/body` selection groups. +14. Add consistent multi-select semantics: +15. Plain click toggles in active picker. +16. `space/esc` clear picker-local selection. +17. No cross-picker stealing while dialog active. + +## Geometry Build Pipeline + +1. Introduce staged rebuild in worker: +2. Stage A: Feature evaluation -> sketch geometry on target surfaces. +3. Stage B: Boundary graph build per surface (loop extraction, splitting, nesting). +4. Stage C: Region synthesis (`outer/holes`) with fill-rule consistency. +5. Stage D: Solid operations (extrude/boolean/chamfer) from region/surface references. +6. Stage E: Topology annotation back into `GeometryStore` for interaction. +7. Cache keys: +8. `feature_sig`, `surface_sig`, `boundary_sig`, `region_sig`. +9. Recompute only invalidated downstream stages. + +## Sketch Integration + +1. Sketches bind to `surface_id` + local frame, not transient face index. +2. `Use (u)` creates `derived segment/point` referencing source `segment_id/point_id`. +3. Derived geometry stores transform relation to host surface frame. +4. Midpoints become first-class `point_id` with constraint targetability. +5. Closed-area detection uses `boundary/region` graph directly (no separate ad-hoc fill path). + +## Extrude Integration + +1. Extrude input stores selected `region_id`s, not ad-hoc loops. +2. Region hover/selection always from boundary graph. +3. Live preview reads from current region snapshots. +4. Targets/tools in add/subtract reference resulting body ids, but source remains region-driven. + +## Chamfer Integration + +1. Chamfer input stores `boundary_segment_id`s (or section ids for partial edges). +2. Face click expands to boundary segments by adjacency policy. +3. No selection against regenerated transient mesh during edit. +4. Preview and final solve read same boundary refs to avoid drift. + +## Projection / Derive Reliability + +1. Stop pre-projecting everything. +2. Resolve hovered source entity first. +3. Project only selected/hovered entity into current sketch frame. +4. Keep source and projected visuals separate but linked by shared entity id. + +## Planar/Non-Planar Classification + +1. Surface classification at creation: +2. Planar stores orthonormal frame and scalar offset. +3. Curved stores param evaluator and principal directions where available. +4. Boundary segments on curved surfaces can still be selected/extruded/chamfered as references. +5. Sketch-on-face initially allowed only on planar surfaces; curved support can be staged later. + +## Document Persistence + +1. Persist `GeometryStore` plus feature list and timeline. +2. Persist only canonical geometry entities, not transient mesh selections. +3. Add schema version bump and hard reset path (allowed in this project stage). +4. Undo/redo stores deltas against `GeometryStore` entities for atomic operations. + +## Migration Strategy + +1. Phase 0: Add new store in parallel, keep existing runtime behavior. +2. Phase 1: Route hover/selection to `SelectionResolver` while existing builders stay. +3. Phase 2: Route sketch profiles/areas to `boundary/region`. +4. Phase 3: Route extrude inputs to `region_id`. +5. Phase 4: Route chamfer inputs to `segment_id`. +6. Phase 5: Remove legacy face/edge ad-hoc paths. +7. Forward-only: remove rollout toggles and legacy branching once parity is reached. + +## Performance and Worker Plan + +1. Keep all heavy geometry graph and topology steps in worker. +2. Main thread receives compact immutable snapshots and draw buffers. +3. Use incremental invalidation by dependency DAG from edited feature forward. +4. Add rebuild budget logging per stage to catch regressions early. + +## Testing Plan + +1. Unit tests: +2. Boundary extraction and nesting. +3. Region fill-rule behavior (self-intersecting + nested bulls-eye cases). +4. Selection ranking with tolerance. +5. Projection correctness for points/segments/surfaces. +6. Integration tests: +7. Sketch-on-face propagation after upstream edits. +8. Extrude profile row hover maps to produced solids. +9. Chamfer edit stability with multi-select. +10. Undo/redo atomicity in dialogs. +11. Fuzz tests: +12. Random sketch mutations + constraints + rebuild consistency checks. +13. Determinism check: same doc state yields same entity ids/topology signatures. + +## Deliverables Sequence + +1. `GeometryStore` + ids + schema. +2. `SelectionResolver` + mode masks. +3. `BoundaryGraphBuilder` + `RegionBuilder`. +4. Extrude refactor to `region_id`. +5. Chamfer refactor to `segment_id`. +6. Projection/use refactor to source-first selection. +7. Legacy path removal and cleanup docs. + +## Acceptance Criteria + +1. Hover/select behavior is identical and predictable across sketch/solid/chamfer modes. +2. Derived sketches follow upstream changes without requiring manual edit-open. +3. Extrude/chamfer operate on stable references, not transient mesh hits. +4. Nested/self-intersecting regions behave correctly for selection and extrusion. +5. No mode where selection silently switches entity class unexpectedly. diff --git a/notes.md b/notes.md deleted file mode 100644 index 3da60892..00000000 --- a/notes.md +++ /dev/null @@ -1,119 +0,0 @@ -# Grid:Apps Future Notes - -## `C` cosmetic, `F` functional, `P` performance, `B` bug fix - -* `F` option to hide platform when viewing from below - -# Kiri:Moto - -* `B` origin (and bed size) bug (Onshape?) when switching device modes -* `B` can't drag slider bar on ipad / ios -- touch pad scrolling dodgy -* `B` prevent or ask for really large models when scaling (crash ui) - -* `P` refactor vertex replacement and widget update matrix tracking -* `P` duplicate objects should share same slice data unless rotated or scaled -* `P` move all persisted / workspace settings/data to IndexedDB (LS limitations) -* `P` faster ray intersect https://github.com/gkjohnson/three-mesh-bvh/ -* `P` try material clipping planes for slice range selection -* `P` switch png lib to https://github.com/photopea/UPNG.js - -* `F` edit in Mesh:Tool -* `F` custom device vars for profiles / ranges / gcode -* `F` show slider range values in workspace units (on hover?) -* `F` allow select of a range by typing in values in slices or workspace units -* `F` complete and expose grouping feature -* `F` add svgnest-like arrange algorithm -* `F` date column and sorting in recent files list -* `F` highlight part outside workspace bounds (like can neg Z shader) - -# FDM - -* `B` fix support projection/end with grouped parts -* `B` multi-extruder rendering of raft fails to offset the rest of the print -* `B` multi-extruder purge blocks fail to generate properly for rafts - -* `F` new parameters for bridging speed / bridge fan control -* `F` convert ranges to z offsets while continuing to show layer # -* `F` support pillar top/bottom should conform to part -* `F` more explicit line width control with ranges and min/max adaptive -* `F` test outlining solid projected areas (internally) -* `F` gradient infill https://www.youtube.com/watch?v=hq53gsYREHU&feature=emb_logo -* `F` segment large flat areas on first layer to mitigate peeling -* `F` option to support interior bridges when 0% infill -* `F` calculate filament use per extruder per print -* `F` expand internal supporting flats / solids before projection (threshold) - -* `P` auto purge pillars when quick layers are detected for extra cooling -* `P` solid fill the tops of supports for down facing flats - -# FDM - BELT - -* `B` auto bed resizing leaves the origin in the wrong place at first -* `B` re-add progress calls for all work units - -* `F` test and enable arcs in belt more -* `F` slightly angle supports to lean into the Z of the part -* `F` anchors should be generated anywhere needed in the print, not just head - -# CAM - -* `B` rapid moves should be max of terrain zmax and last cut layer height (roughing) -* `B` feed rate for next tool set before tool change (push/pop feed rates?) -* `B` tabs do not properly track widget mirror events -* `B` contour does not honor clip to stock - -* `F` add lathe step down to eliminate the need for roughing -* `F` allow import, rotation, scaling of stock -* `F` get gcode coordinates off a part with point/click or hover? -* `F` include tools in default devices (Carvera) -* `F` add `match faces` option in `outline` operation -* `F` add {progress} substitution and maybe {time-remaining} if can be calc'd -* `F` import and follow 2D paths (conformed like pocket contours) -* `F` add `plunge max` to contouring that can override z feed limit -* `F` add lead-in milling (requires adding clamp / no go areas) -* `F` add linear clearing strategy -* `F` add adaptive clearing strategy -* `F` add support for tapered ball mills -* `F` change color of line selection in trace op when not a closed poly -* `F` limit cut depth to flute length of selected tool (or warn) -* `F` validate muti-part layout and spacing exceeds largest outside tool diameter -* `F` trapezoidal tabs in Z -* `F` ease-in and ease-out especially on tab cut-out start/stop -* `F` maintain several part orientations + op chains in a single profile - -* `P` outer outside corners as arc moves -* `P` improve parser - do not require spaced tokens and support implied G0 / G1 -* `P` log Z interpolation for contour XYZ moves -* `P` option to start with the smallest poly by area on layer change -* `P` redo all path route / planning in prepare to account for terrain before camOut - -# Laser - -* `F` add PLT / HP-GL output format (https://en.wikipedia.org/wiki/HP-GL) - -# OctoPrint plugin - -* subfolder parameter for dropped files -* auto-kick check box in preferences - -# Mesh:Tool - -* add undo/redo -* add TextGeometry -* https://threejs.org/docs/#examples/en/geometries/TextGeometry -* https://dustinpfister.github.io/2023/07/05/threejs-text-geometry/ -* font to path with https://github.com/paulzi/svg-text-to-path -* click to repair normals based on a known good (selected) face -* send to Kiri:Moto workspace (or update model vertices in place) -* better z snap using just vertexes from face intersected -* add section view. local clip. raycast skip points above plane -* add decimate op = face reduction -* add flatten/crush op: for z bottoms (or surfaces?) -* allow setting model/group origin for scale/rotate -* fix mirror to work with groups (just models currently) -* add analyze results dialog -* remove/hide auto-repair function - -# Other - -* preferred icons https://icons.getbootstrap.com/ diff --git a/package.json b/package.json index c119275e..d6fde6a0 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace", "@gridspace/net-level-client": "^0.2.3", "@gridspace/raster-path": "^1.1.1", + "@salusoft89/planegcs": "^1.1.7", "@tracespace/parser": "^5.0.0-next.0", "@tweenjs/tween.js": "^16.6.0", "aedes": "^0.51.3", @@ -103,6 +104,7 @@ "clean": "rm -rf alt build data dist src/pack src.old tmp web.old .bcache", "dev": "npm run pack-dev && gs-app-server --debug --single", "docs-build": "docusaurus build --config conf/docusaurus.config.js", + "docs-clean": "prettier --config ./conf/prettier.config.js ./docs --write", "docs-check": "prettier --config ./conf/prettier.config.js ./docs --check", "docs-dev": "docusaurus start --config conf/docusaurus.config.js", "docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004", @@ -121,7 +123,8 @@ "start-ddb": "npm run prebuild && electron . --devel --debugg", "start-dev": "npm run prebuild prod && electron . --devel", "start": "npm run prebuild && electron .", - "webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs", + "webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs && npm run webpack-pgcs", + "webpack-pgcs": "mkdir -p src/void/solver/planegcs_dist src/void/solver/sketch && cp node_modules/@salusoft89/planegcs/dist/index.js src/void/solver/planegcs.js && cp -R node_modules/@salusoft89/planegcs/dist/planegcs_dist/. src/void/solver/planegcs_dist/ && cp -R node_modules/@salusoft89/planegcs/dist/sketch/. src/void/solver/sketch/", "webpack-qjs": "npx webpack --config bin/webpack-quickjs-esm.js", "webpack-src": "node bin/esbuild.config.mjs", "webpack-three": "npx webpack --config bin/webpack-three-esm.js", diff --git a/release.md b/release.md deleted file mode 100644 index d758c39d..00000000 --- a/release.md +++ /dev/null @@ -1,401 +0,0 @@ -# Release Notes - -Full docs @ https://docs.grid.space/projects/kiri-moto - -# Release 4.4.0 - -## General - -* add version numbering utility script -* split out help/info menu and language menus -* move install/uninstall/quit menu to center app menu -* add help menu visual callout -* improve language translation coverage -* improve build and serve workflow for alternate builds -* update service worker configuration -* add install/uninstall options in setup menu - -## CAM - -* implement tab hopping - toolpath goes up and over tab instead of interrupting cut (fixes #207) -* add GPU accelerated contouring for faster operations -* add radial GPU raster support (experimental) -* add new register mode that marks bottom cutout tabs -* add curves-only support to GPU contour -* add zsafe and move to safe z at start of new operations -* fix 'safe' moves during operation changes to be moves, not cuts -* fix tolerance/resolution for GPU raster mesh calculations -* fix NullPointerException with tabs in contour -* improve merge/co-planar point handling -* add slice layer naming using operation notes (fixes #447) -* fix level step down units (fixes #446) - -## FDM - -* document remain_time macro variable (refs #339) -* re-add base flow rate multiplier for belt mode (fixes #444) -* fix belt fan control ordering (fixes #438) -* exclude belt from bridge fan layer -* add centering feature for fill areas (fixes #448) - -## Mesh:Tool - -* add STEP export capability -* add STEP import with face generation -* improve face generation with inner holes -* add mesh scripting tool and persistence -* add plane and plane.loft operations -* add global edge map and edge reuse for better topology -* add devel device export option - -## Electron - -* clean up electron build process -* merge electron mod into core -* add service routes when available -* set proper content-type for appended code bodies - -## Dependencies - -* update @gridspace/raster-path to latest version with GPU acceleration -* update @gridspace/app-server for proper fallthrough handling -* switch from npmjs.org to git repos for @gridspace packages -* update WebGPU implementation - -## Bug Fixes - -* fix Bambu Lab local URL and module load order -* fix progress reporting for raster operations -* fix GPU lathe progress and contour API usage -* fix tool offsets when using GPU acceleration -* fix indexed rotations epsilon value for accurate zflat calculation -* fix traceload debug option -* sanitize topo3 resolution to avoid GPU floating-point errors - - -# Release 4.3 - -## General - -* add lathe mode for CAM operations -* add volumetric flow calculations for FDM -* improve bundler with better dependency tracking -* migrate to ESM module format across codebase -* improve arc support in gcode generation - -## FDM - -* add scarf seams for better surface finish -* add hole compensation feature -* add spiral layer start option -* overhaul thin wall detection and handling -* improve volumetric flow rate controls -* add retraction tuning for better print quality - -## CAM - -* add lathe operation support with threading -* improve arc support for smoother toolpaths -* add drill from stock top option -* improve tab positioning and generation -* enhance surface selection and filtering -* add 4th axis lathe debug and testing mode -* improve tool path optimization - -## Devices - -* add new machine profiles -* improve device profile management - - -# Release 4.2 - -## CAM - -* add arc support to gcode output for smoother paths -* add drill from stock top feature -* improve contour operations -* fix animation issues with shared tool numbers -* enhance trace operations with arc support -* improve pocket smoothing algorithms - -## FDM - -* improve support generation -* add new retraction options -* enhance layer time controls - -## General - -* improve gcode arc import and export -* fix file loading edge cases -* update device profiles - - -# Release 4.1 - -## CAM - -* improve drill operations and positioning -* fix multi-part drilling bugs -* enhance trace line selection -* add dogbone support to trace ops -* improve pocket operation reliability -* fix trace selection with flip operations - -## FDM - -* add RatRig machine profiles -* improve Bambu Lab device control integration -* enhance support placement algorithms -* fix belt mode support generation - -## General - -* improve file import handling -* fix workspace restore for complex projects -* enhance mobile touch interactions -* update machine profiles for popular devices - - -# Release 4.0 (2024-01-21) - -* major UI refactor -* add profile cloud sync -* add electron desktop binary builds -* add timelines for all cnc op chains -* add dragknife, wire-edm, waterjet device types -* add gerber import file support -* add SVG import dialog, more options -* add arbitrary belt slice angle -* add mesh sketching, new ui, menus, gears, patterns -* fix 3MF imports lacking mesh translation -* improve cam animation -* clean up and normalize dark mode -* optimize docker image size -* new machine profiles -* dozens of bug fixes - - -# Release 3.9 (2023-03-19) - -* more graceful handling of security contexts blocking SharedArrayBuffer -* FDM refactor the 'detect' support feature for auto-placing manual supports -* FDM fix supports in belt mode -* CAM fix issue #230 shared tool numbers cause animation errors -* CAM fix surface / trace selection copy on hover/pop/new op -* CAM decrease cutting speed when entire tool is engaged in roughing -* CAM add dogbones support to traces ops -* CAM add scripted contour filtering (to be extended to other ops) -* CAM add calculation of taper length from angle -* CAM add open poly-line offsetting with trace op -* CAM clearly delineate ops reachable or not on timeline -* CAM add 4th axis lathe operation for debug and testing (can be optimized) -* CAM add rough all stock to aid lathe mode -* CAM stock is now always on, whether offset or absolute -* CAM add lathe worker parallelization (2x - 6x speedup) -* CAM fix pocket/trace selection with flip op -- CAM fix lathe yellow path in light mode - - -# Release 3.8 (2023-01-21) - -* update server-side sample module code -* various fixes and improvements to CAM indexing -* various fixes and improvements to gcode variables -* improve gcode arc import decoding -* removed auto-decimation on object import -* FDM slicing memory reduction from team lychee -* FDM add new parameters, range overrides -* FDM fix raft line fill strategy -* CAM improve trace line selection with zoom adaptive thresholds -* CAM option to control first output point order -* CAM move to save Z between ops, refresh spindle speed -* CAM add origin offset optional parameters -* CAM add control to omit initial tool change -* CAM fix vertical face selection and step over defaults -* CAM fix multi-part object import / grouping -* CAM fix tracing nested polyline offset ordering -* CAM refactor of contouring yields 2x - 20x speedup -* CAM update traces to async slicing, fix use with flip -* CAM add threading support for all slicing ops -* CAM add omit pocket option to outline op -* CAM add trace support for taper tip diameter -* CAM add trace offset override parameter -* CAM add leave stock parameter to contour -* CAM add contour output density control (reduction) -* CAM improve parsing and visualization of large files - - -# Release 3.7 (2022-11-12) - -* add CAM axes scaling gcode header directive -* add CAM 4th axis indexing support, timeline op, updated visuals -* update most CAM ops to work in 4th axis indexing mode -* align FDM top/bottom layer options with convention -* change Kiri:Moto SVG import to default to boolean repair -* add Mesh:Tool SVG import options for extrusion depth and boolean repair -* replace jscad/modeling with Manifold project for faster mesh boolean -* various CAM gcode, preview, animation fixes (3 axis) -* various mobile touch, file load fixes -* add FDM slice support growth option to help merging pillars -* add CAM tools export / import parity with devices and settings - - -# Release 3.6 (2022-10-22) - -## Kiri:Moto - -* add new Carvera machine target in CAM mode with laser support -* add laser output operators and device settings in CAM mode -* add fullscreen option. button next to user profile -* mobile pinch zoom and layer slider usability improvements -* update CLI to work in CAM mode and add working samples -* improved FDM preview rendering speed and reduced memory usage -* threaded task and message passing performance improvements -* refactor FDM supports and synthetic widgets to use more common code -* allow FDM mixing of automatic and manual / detected supports -* improve CAM animation speeds using shared array buffers -* improve CAM render quality using solids instead of lines -* add CAM leveling part offset parameters for XY and Z -* add CAM pocket smoothing and contouring which is closer to true 3 axis -* add CAM 3D engraving and marking with the pocket contouring operation -* fix CAM invalidation of tabs on scale and traces on scale or rotate -* fix belt fan override for base extrusions touching belt -* fix belt X axis label order - - -# Release 3.5 (2022-10-07) - -## Kiri:Moto - -* add optional service workers and manifest to support full PWA + install -* add support to run as Progressive Web Apps for installation and offline use -* add assembly import when KM used inside of onshape -* add configurable flatness for contour clipping -* add faster render mode for FDM slices -* add axis label remapping in FDM -* add new path rendering engine -* add bridging option in CAM contouring -* add option to force z max routing in CAM -* add option to ignore z bottom in CAM contouring -* add CAM pocket option to ignore interior features (outline only) -* add CAM Z bottom visualization, make it relative to stock instead of part -* add CAM Z bottom inversion option to flip operator -* add CAM custom gcode operator (can be used for pausing, too) -* add CAM z extend option on registration op independent of "Z Thru" global -* add CAM pocket surface selection filter by angle -* add optional CAM operation notes (helps with many similar ops) -* add option to limit CAM trace ops to Z bottom limit (when in use) -* extend url loading of workspaces to all formats -* alert when healing is enabled and non-manifold geometries are detected -* fix thin output start and end point tracking which broke retraction -* fix for importing with some obj formatting -* fix profile seeding for newer device record formats -* fix workspace import / restore for some file formats -* fix potential crash into stock during moves when parts are z bottom anchored - - -# Release 3.4 (2022-05-14) - -## Kiri:Moto - -* added batch processing to object adds/removes to speedup complex workspace restore -* substitute some prusa slicer [variables] with KM {variables} on import -* fix CNC output order for tool changes an spindle speed updates -* add CNC pocket operation using surface selection -* fix dog-bones on outlines cut by tabs -* skip pockets that resolve to null -* fix CNC contour path collision -* 10x speedup for true shadow generation -* add FDM gcode feature macros for transitions -* add FDM option to alternate shell winding direction -* add FDM print time estimate fudge factor for devices -* add `clear top` option to CNC outline operation -* add FDM layer retraction as a range option - -## Mesh:Tool (1.2.0) - -* auto-fog in wireframe view to aid close mesh inspections -* significant speed-up for large surface selections -* add boolean operations for subtract and intersect -* multi-body identification and isolation -* quick add primitives: cube, cylinder -* control wireframe transparency -* parameterize png image import -* code added to show camera focal point -* better Z split snapping using vertex closest to mouse - - -# Release 3.3 (2022-04-01) - -## Kiri:Moto - -* reorganization of code to use updated dependency loader (gapp) -* refactor main into supporting classes (part of a larger ui re-org) -* group "main" entry points under "kiri-run" -* extract and group utilities from print and other modules -* add thin wall pull-down & allow for newer strategies -* extract preview render engine from FDM -* allow loading of workspaces from url on page load -* properly import profiles attached to devices -* improved routing on "fast" layers & layers with multiple islands -* start/stop minions depending on whether threading enabled -* abstract file loading (onshape import, mesh replace, etc) -* enable/disable ray intersect path on feature state change -* new and updated device profiles: Prusa MK2S/MK3S+, Ender 3 -* trigger solid layer when transitions lead to 50% projected areas -* limit non-manifold solution search depth -* refactor slicers to use single improved slice core (cnc deferred) -* add parameterized solid projection expansion (infill -> solid expand) -* add parameterized control of bridge/flat and infill print speeds -* add api control over threading workloads and use of wasm -* updates to raft generation: add border, connect infill lines -* fix phantom support generation off part or under bed -* template vars: nozzles used and layers until next use (IDEX) -* fdm export control of preamble comments position (for ultimaker) - -## Mesh:Tool (1.1.0) - -* add surface selection mode -* add preferences for normal length and color -* add preferences for face selection and surface matching (radians/radius) -* add svg and image import conversion (created shared load. libs) -* replace triangulation algorithm that was causing some union failures -* add pinned log busy spinner -* add version chooser -* add welcome menu - - -# Release 3.2 (2022-02-12) - -https://forum.grid.space/t/kiri-moto-version-3-2/580 - -## General - -* Better memory management -* Rendering speedups -* 3MF instancing support -* SVG import improvements -* Enable/Disable individual models - -## FDM - -* Improved non-manifold handling -* Gcode macro if/then/else code flow -* Updated CLI utility -* Draft Shields - -## Belt - -* Height-based spacing -* Random X layout - -## CNC - -* Drill marking option -* Numerous bug fixes - -## Onshape - -* Improved session management diff --git a/sample/init.js b/sample/init.js deleted file mode 100644 index bd18abbd..00000000 --- a/sample/init.js +++ /dev/null @@ -1,35 +0,0 @@ -// this is called from the `app.js` function `initModule()` around line `200` -// from there you can get the structure passed in the "server" object -// which includes and `api` and other helper functions - -module.exports = function(server) { - -server.util.log("--- sample server-side module installed ---"); - -// insert script after all others in kiri main code -server.inject("kiri", "kiri.js", {end: true}); - -// insert script after all others in kiri worker code -server.inject("kiri_work", "work.js", {end: true}); - -server.onload(() => { - server.util.log("--- called after all modules loaded ---"); -}); - -// adding URL endpoints on the server -server.path.full({ - // register the endpoint "/postit" - "/postit": (req, res, next) => { - let chunks = []; - req.on('data', data => { - chunks.push(data.toString()); - }); - req.on('end', () => { - let data = chunks.join(''); - server.util.log({server_received: data}); - res.end(`received ${data.length} bytes`); - }); - } -}); - -}; diff --git a/sample/kiri.js b/sample/kiri.js deleted file mode 100644 index 25c54222..00000000 --- a/sample/kiri.js +++ /dev/null @@ -1,14 +0,0 @@ -console.log('--- kiri main module start ---'); - -// the kiri api starts around line `260` of `src/kiri/main.js` -kiri.load(function(api) { - console.log('--- kiri main module started ---'); - - // send data to our "/postit" endpoint - fetch("/postit", { - method: "POST", - body: "this is a test of the POST url endpoint" - }).then(res => res.text()).then(text => { - console.log({server_said: text}); - }); -}); diff --git a/sample/readme.md b/sample/readme.md deleted file mode 100644 index 9001b01c..00000000 --- a/sample/readme.md +++ /dev/null @@ -1,27 +0,0 @@ -# Application modules - -This allows for the loading of server-side modules which can, in turn, -inject browser modules into applications like Kiri:Moto. - -To enable the sample module: - -* create a `mod` directory at the root of grid-apps -* copy the sample directory into the mod directory: `mod/sample` -* (re)start `gs-app-server` -* you will see log lines at start-up similar to this - -``` -220128.120432 '[head]' { module: './mod/sample/init.js' } -220128.120432 '[head]' '--- sample server-side module loaded ---' -``` - -* reloading Kiri:Moto from localhost, you will see these javascript -* console log messages indicaing the new module code is running - -``` ---- kiri main module start --- ---- kiri main module started --- -{server_said: 'received 39 bytes'} ---- kiri worker module start --- ---- kiri worker module started --- -``` diff --git a/sample/work.js b/sample/work.js deleted file mode 100644 index bc0d559f..00000000 --- a/sample/work.js +++ /dev/null @@ -1,12 +0,0 @@ -console.log('--- kiri worker module start ---'); - -kiri.load(function(api) { - console.log('--- kiri worker module started ---'); - - // augment worker code here. for example, - // to ovveride fdm extrusion calculations - // kiri.driver.FDM.extrudeMM = function(dist, perMM, factor) { - // return dist * perMM * factor; - // }; - -}); diff --git a/src/load/stl.js b/src/load/stl.js index 5639cd7c..c3bb2e91 100644 --- a/src/load/stl.js +++ b/src/load/stl.js @@ -13,14 +13,26 @@ const { Vector3, computeFaceNormal } = THREE; * @returns {Uint8Array} binary STL data */ export function encode(recs, header = '') { - // Calculate total vertices - let vtot = 0; - for (let rec of recs) { - vtot += (rec.varr.length / 3); + // Calculate total triangles with strict validation. + let triCount = 0; + for (let rec of recs || []) { + const varr = rec?.varr; + const len = Number(varr?.length || 0); + if (!Number.isFinite(len) || len <= 0) continue; + if (len % 9 !== 0) { + // Ignore malformed record instead of corrupting output sizing. + continue; + } + triCount += Math.floor(len / 9); + } + + const byteLen = 84 + triCount * 50; + if (!Number.isFinite(byteLen) || byteLen <= 84 || byteLen > 0x7fffffff) { + throw new RangeError(`invalid STL byte length: ${byteLen}`); } // Create STL buffer: 80 byte header + 4 byte count + (50 bytes per triangle) - let stl = new Uint8Array(80 + 4 + vtot/3 * 50); + let stl = new Uint8Array(byteLen); let dat = new DataView(stl.buffer); let pos = 84; @@ -32,11 +44,12 @@ export function encode(recs, header = '') { } // Write triangle count at byte 80 - dat.setInt32(80, vtot/3, true); + dat.setUint32(80, triCount, true); // Write triangles for (let rec of recs) { let { varr } = rec; + if (!varr || (varr.length % 9) !== 0) continue; for (let i = 0, l = varr.length; i < l;) { // Read three vertices let p0 = new Vector3(varr[i++], varr[i++], varr[i++]); @@ -72,6 +85,97 @@ export function encode(recs, header = '') { return stl; } +/** + * Encode STL as chunked Blob to avoid large contiguous TypedArray allocation. + * Useful when JS heap is fragmented or under pressure. + * @param {Array} recs + * @param {String} header + * @param {Number} trisPerChunk + * @returns {Blob} + */ +export function encodeBlob(recs, header = '', trisPerChunk = 4096) { + let triCount = 0; + for (let rec of recs || []) { + const len = Number(rec?.varr?.length || 0); + if (!Number.isFinite(len) || len <= 0 || (len % 9) !== 0) continue; + triCount += Math.floor(len / 9); + } + const parts = []; + const head = new Uint8Array(84); + const headView = new DataView(head.buffer); + if (header) { + header.substring(0, 80).split('').forEach((c, i) => { + headView.setUint8(i, c.charCodeAt(0)); + }); + } + headView.setUint32(80, triCount, true); + parts.push(head); + + const maxTris = Math.max(1, Math.floor(Number(trisPerChunk) || 4096)); + const p0 = new Vector3(); + const p1 = new Vector3(); + const p2 = new Vector3(); + for (let rec of recs || []) { + const varr = rec?.varr; + if (!varr || (varr.length % 9) !== 0) continue; + let i = 0; + while (i < varr.length) { + const tris = Math.min(maxTris, Math.floor((varr.length - i) / 9)); + const buf = new Uint8Array(tris * 50); + const dat = new DataView(buf.buffer); + let pos = 0; + for (let t = 0; t < tris; t++) { + p0.set(varr[i++], varr[i++], varr[i++]); + p1.set(varr[i++], varr[i++], varr[i++]); + p2.set(varr[i++], varr[i++], varr[i++]); + const norm = computeFaceNormal(p0, p1, p2); + dat.setFloat32(pos + 0, norm.x, true); + dat.setFloat32(pos + 4, norm.y, true); + dat.setFloat32(pos + 8, norm.z, true); + dat.setFloat32(pos + 12, p0.x, true); + dat.setFloat32(pos + 16, p0.y, true); + dat.setFloat32(pos + 20, p0.z, true); + dat.setFloat32(pos + 24, p1.x, true); + dat.setFloat32(pos + 28, p1.y, true); + dat.setFloat32(pos + 32, p1.z, true); + dat.setFloat32(pos + 36, p2.x, true); + dat.setFloat32(pos + 40, p2.y, true); + dat.setFloat32(pos + 44, p2.z, true); + dat.setUint16(pos + 48, 0, true); + pos += 50; + } + parts.push(buf); + } + } + return new Blob(parts, { type: 'application/sla' }); +} + +export function encodeASCII(recs, name = 'solid') { + const out = [`solid ${String(name || 'solid').replace(/\s+/g, '_')}`]; + const p0 = new Vector3(); + const p1 = new Vector3(); + const p2 = new Vector3(); + for (const rec of recs || []) { + const varr = rec?.varr; + if (!varr || (varr.length % 9) !== 0) continue; + for (let i = 0; i < varr.length;) { + p0.set(varr[i++], varr[i++], varr[i++]); + p1.set(varr[i++], varr[i++], varr[i++]); + p2.set(varr[i++], varr[i++], varr[i++]); + const n = computeFaceNormal(p0, p1, p2); + out.push(`facet normal ${n.x} ${n.y} ${n.z}`); + out.push(' outer loop'); + out.push(` vertex ${p0.x} ${p0.y} ${p0.z}`); + out.push(` vertex ${p1.x} ${p1.y} ${p1.z}`); + out.push(` vertex ${p2.x} ${p2.y} ${p2.z}`); + out.push(' endloop'); + out.push('endfacet'); + } + } + out.push(`endsolid ${String(name || 'solid').replace(/\s+/g, '_')}`); + return out.join('\n'); +} + export class STL { constructor() { this.vertices = null; diff --git a/src/main/void.js b/src/main/void.js new file mode 100644 index 00000000..c552c2ee --- /dev/null +++ b/src/main/void.js @@ -0,0 +1,331 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import '../add/array.js'; +import '../add/class.js'; +import '../add/three.js'; + +import { $ } from '../moto/webui.js'; +import { api } from '../void/api.js'; +import { space } from '../moto/space.js'; +import { open as dataOpen } from '../data/index.js'; +import { toolbar } from '../void/toolbar.js'; +import { tree } from '../void/tree.js'; +import { overlay } from '../void/overlay.js'; +import { datum } from '../void/datum.js'; +import { interact } from '../void/interact.js'; +import { properties } from '../void/properties.js'; +import { ViewCube } from '../void/viewcube.js'; +import { initSketchConstraintsSolver } from '../void/sketch/constraints.js'; + +const version = '0.1.0'; +const dbindex = ["admin", "documents", "versions"]; +const VOID_HOME_LEFT = Math.PI / 4; +// Match view direction along the test line vector (1,1,1) toward origin. +const VOID_HOME_UP = Math.acos(1 / Math.sqrt(3)); +const LEFT_PANEL_WIDTH_KEY = 'left_panel_width'; +const LEFT_PANEL_MIN_PX = 190; +const VIEWPORT_MIN_PX = 320; + +function setupLeftPanelResize(db) { + const content = $('content'); + const left = $('left-panel'); + const container = $('container'); + if (!content || !left || !container) return; + + let handle = $('left-panel-resizer'); + if (!handle) { + handle = document.createElement('div'); + handle.id = 'left-panel-resizer'; + handle.title = 'Resize tree panel'; + content.insertBefore(handle, container); + } + + const clampWidth = width => { + const maxByLayout = Math.max(LEFT_PANEL_MIN_PX, (content.clientWidth || 1200) - VIEWPORT_MIN_PX); + const max = Math.min(640, maxByLayout); + return Math.max(LEFT_PANEL_MIN_PX, Math.min(max, Number(width) || LEFT_PANEL_MIN_PX)); + }; + + const applyWidth = width => { + const w = clampWidth(width); + left.style.flex = `0 0 ${w}px`; + left.style.width = `${w}px`; + return w; + }; + + const persistWidth = width => { + const w = applyWidth(width); + try { localStorage.setItem(LEFT_PANEL_WIDTH_KEY, String(w)); } catch {} + db?.admin?.put?.(LEFT_PANEL_WIDTH_KEY, w); + }; + + const restoreLocal = () => { + try { + const raw = localStorage.getItem(LEFT_PANEL_WIDTH_KEY); + if (raw !== null) { + const parsed = Number(raw); + if (Number.isFinite(parsed)) applyWidth(parsed); + } + } catch {} + }; + + restoreLocal(); + db?.admin?.get?.(LEFT_PANEL_WIDTH_KEY).then(width => { + if (Number.isFinite(width)) applyWidth(width); + }); + + let dragging = false; + let startX = 0; + let startW = 0; + + const onMove = event => { + if (!dragging) return; + const dx = (event?.clientX || 0) - startX; + const w = applyWidth(startW + dx); + space.update(); + try { localStorage.setItem(LEFT_PANEL_WIDTH_KEY, String(w)); } catch {} + }; + + const onUp = event => { + if (!dragging) return; + dragging = false; + document.body.classList.remove('left-panel-resizing'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + const dx = (event?.clientX || 0) - startX; + persistWidth(startW + dx); + space.update(); + }; + + handle.onmousedown = event => { + if (event.button !== 0) return; + dragging = true; + startX = event.clientX || 0; + startW = left.getBoundingClientRect().width; + document.body.classList.add('left-panel-resizing'); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + event.preventDefault(); + event.stopPropagation(); + }; + + window.addEventListener('resize', () => { + const curr = left.getBoundingClientRect().width; + applyWidth(curr); + }); +} + +// Main initialization function +async function init() { + console.log({ void_form_init: version }); + + // Initialize IndexedDB + let stores = dataOpen('void', { stores: dbindex, version: 2 }).init(); + let db = api.db = { + admin: stores.promise('admin'), + documents: stores.promise('documents'), + versions: stores.promise('versions') + }; + + // Mark init time and use count + db.admin.put("init", Date.now()); + db.admin.get("uses").then(v => db.admin.put("uses", (v || 0) + 1)); + + // Initialize API + api.init(); + await initSketchConstraintsSolver(); + await api.solids.init(); + + // Setup 3D workspace + space.setAntiAlias(true); + // Void owns its own keymap (Onshape-style); disable space.js defaults. + space.useDefaultKeys(false); + // Default void to orthographic (CAD-like), while saved camera projection + // restoration below can still override per-document/session. + space.init($('container'), delta => {}, true); + api.sketchRuntime.init(space.world); + api.solids.attach(space.world); + + // Initialize 2D overlay system + overlay.init(); + + // Initialize datum planes + const datumGroup = datum.init({ size: 200, visible: true }); + space.world.add(datumGroup); + + // Add datum labels to overlay + datum.updateLabels(overlay); + + // Hook overlay to update datum labels on camera movement + overlay.onUpdate = () => { + datum.updateLabels(overlay); + }; + + // Initialize interaction system (hover, select, drag) + interact.init(); + api.document.bindRuntimeObservers(); + + // Initialize ViewCube navigation widget + const viewcube = new ViewCube({ + size: 80, // Size in pixels + padding: 20, // Padding from corner + cubeSize: 1.5 // 3D cube size + }); + + // Register viewcube to render after main scene + space.afterRender((renderer) => { + viewcube.render(renderer); + }); + + // Configure sky and platform + space.sky.set({ + grid: false, + color: 0x101010 + }); + + space.view.setCtrl('void'); + // Rebind overlay camera/control hooks after Orbit -> Trackball swap. + overlay.onProjectionChanged(); + space.view.setFitVisibleOnly(true); + space.view.setHome(VOID_HOME_LEFT, VOID_HOME_UP); + + space.platform.set({ + visible: false, + size: { width: 1000, depth: 1000, height: 0 }, + zoom: { reverse: true, speed: 1 }, + grid: { + disabled: true, + } + }); + + // Enable camera-aligned tracking plane for drag operations + space.tracking.setMode('camera-aligned'); + space.tracking.setDistance(10000); // Far behind camera to catch all rays + + // Save camera position on movement + space.platform.onMove(() => { + db.admin.put('camera', { + place: space.view.save(), + focus: space.view.getFocus(), + projection: space.view.getProjection() + }); + }, 100); + + // Restore saved camera position + db.admin.get('camera').then(cam => { + if (cam && cam.place) { + if (cam.projection && cam.projection !== space.view.getProjection()) { + space.view.setProjection(cam.projection); + space.view.setCtrl('void'); + overlay.onProjectionChanged(); + toolbar.updateProjectionLabel(); + } + space.view.load(cam.place); + if (cam.focus) { + space.view.setFocus(cam.focus); + } + } else { + // Use void-specific default home view when no saved camera exists. + space.view.home(); + } + }); + + // Build UI components + toolbar.build(); + toolbar.updateProjectionLabel(); + properties.init(); + tree.build(); + setupLeftPanelResize(db); + + // Document history hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y + window.addEventListener('keydown', async event => { + const isMeta = event.metaKey || event.ctrlKey; + if (!isMeta) return; + + const activeTag = document.activeElement?.tagName; + const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable; + if (editing) return; + + const key = event.key.toLowerCase(); + let handled = false; + + if (key === 'z' && event.shiftKey) { + handled = await api.document.redo(); + } else if (key === 'z') { + handled = await api.document.undo(); + } else if (key === 'y') { + handled = await api.document.redo(); + } + + if (handled) { + event.preventDefault(); + toolbar.updateDocumentTitle(); + tree.render(); + } + }); + + // Ensure scene redraw when app state changes from non-canvas UI interactions + // (tree toggles, toolbar actions, property edits, etc.). + window.addEventListener('void-state-change', () => { + space.update(); + }); + + // Keep rendering responsive for keyboard-driven interactions even when + // the pointer is not over the canvas and idle-throttling is active. + window.addEventListener('keydown', event => { + const activeTag = document.activeElement?.tagName; + const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable; + if (!editing) { + space.update(); + } + }); + + // Restore last active document, or seed a new blank one. + await api.document.restoreOrCreate(); + api.geometryStore?.seedFromDocument?.(api.document.current); + api.sketchRuntime.sync(); + await api.solids.rebuild('startup'); + api.geometryStore?.seedFromDocument?.(api.document.current); + toolbar.updateDocumentTitle(); + tree.render(); + + // TEST: Add example overlay elements + // These demonstrate the 2D overlay tracking 3D points + if (true) { // Set to false to disable test overlays + const { THREE } = window; + + // Show overlay + overlay.show(); + + // Add test points at origin and along axes + overlay.add('origin-point', 'point', { + pos3d: new THREE.Vector3(0, 0, 0), + radius: 4.8, + color: 'rgba(140, 140, 140, 0.45)', + stroke: '#5a9fd4', + strokeWidth: 2 + }); + api.origin.syncOverlayPoint(); + + console.log({ test_overlays_added: 1 }); + } + + // Hide loading curtain + const curtain = $('curtain'); + if (curtain) { + curtain.style.opacity = '0'; + curtain.style.transition = 'opacity 0.3s'; + setTimeout(() => { + curtain.style.display = 'none'; + }, 300); + } + + console.log({ void_form_ready: true }); +} + +// Wait for DOM ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/src/moto/opfs.js b/src/moto/opfs.js index 5b23196b..e107ca48 100644 --- a/src/moto/opfs.js +++ b/src/moto/opfs.js @@ -1,4 +1,4 @@ -const root = await navigator.storage.getDirectory(); +const root = await navigator.storage?.getDirectory(); function resolvePath(path) { if (!path) { diff --git a/src/moto/orbit.js b/src/moto/orbit.js index 9e848dc5..689e4b70 100644 --- a/src/moto/orbit.js +++ b/src/moto/orbit.js @@ -85,6 +85,13 @@ class Orbit extends EventDispatcher { PAN: MOUSE.MIDDLE }; + // Onshape + this.mouseVoid = { + ORBIT: MOUSE.RIGHT, + // ZOOM: MOUSE.LEFT, + PAN: MOUSE.MIDDLE + }; + this.mouseButtons = this.mouseDefault; this.setMouse = function(bindings) { @@ -116,6 +123,7 @@ class Orbit extends EventDispatcher { pan = new Vector3(), lastPosition = new Vector3(), lastQuaternion = new Quaternion(), + lastZoom = object.zoom !== undefined ? object.zoom : 1, // so camera.up is the orbit axis quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)), quatInverse = quat.clone().invert(), @@ -325,11 +333,13 @@ class Orbit extends EventDispatcher { // min(camera displacement, camera rotation in radians)^2 > EPS // using small-angle approximation cos(x/2) = 1 - x^2 / 8 if (lastPosition.distanceToSquared(this.object.position) > EPS - || 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS) { + || 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS + || Math.abs(lastZoom - this.object.zoom) > EPS) { this.dispatchEvent(changeEvent); lastPosition.copy(this.object.position); lastQuaternion.copy(this.object.quaternion); + lastZoom = this.object.zoom; if (notify) notify(position, true); } else { if (notify) notify(position, false); @@ -681,7 +691,23 @@ class Orbit extends EventDispatcher { this.onMouseUp = onMouseUp; - domEl.addEventListener('contextmenu', function (event) { event.preventDefault() }, false); + this.dispose = function() { + domEl.removeEventListener('contextmenu', onContextMenu, false); + domEl.removeEventListener('mousedown', onMouseDown, false); + domEl.removeEventListener('wheel', onMouseWheel, false); + domEl.removeEventListener('mousewheel', onMouseWheel, false); + domEl.removeEventListener('DOMMouseScroll', onMouseWheel, false); + domEl.removeEventListener('touchstart', touchstart, false); + domEl.removeEventListener('touchend', touchend, false); + domEl.removeEventListener('touchmove', touchmove, false); + + document.removeEventListener('mousemove', onMouseMove, false); + document.removeEventListener('mouseup', onMouseUp, false); + window.removeEventListener('keydown', onKeyDown, false); + }; + + function onContextMenu(event) { event.preventDefault() } + domEl.addEventListener('contextmenu', onContextMenu, false); domEl.addEventListener('mousedown', onMouseDown, false); domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox) domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari diff --git a/src/moto/space.js b/src/moto/space.js index b74074f0..d193d062 100644 --- a/src/moto/space.js +++ b/src/moto/space.js @@ -2,6 +2,7 @@ import { THREE } from '../ext/three.js'; import { Orbit } from './orbit.js'; +import { Trackball } from './trackball.js'; import { Text3D } from './text3d.js'; import '../ext/tween.js'; @@ -36,8 +37,14 @@ let WIN = self.window || {}, refreshRequested = false, selectRecurse = false, defaultKeys = true, + fitVisibleOnly = false, + fitPaddingPerspective = 0.5, + fitPaddingOrthographic = 0.9, initialized = false, alignedTracking = false, + trackingMode = 'platform', // 'platform', 'camera-aligned', 'world-xy' + trackingDistance = 1000, // Distance from camera for camera-aligned mode + afterRenderCallbacks = [], skyAmbient, skyGridColor = 0xcccccc, skyGridMaterial = undefined, @@ -66,6 +73,7 @@ let WIN = self.window || {}, mouseUp, mouseDown, mouseHover, + mouseHoverNull, mouseDrag, grid = { origin: origin, @@ -135,7 +143,8 @@ let WIN = self.window || {}, antiAlias = WIN.devicePixelRatio <= 1, lastAction = Date.now(), renderTime = 0, - fps = 0; + fps = 0, + controlMode = 'default'; if (DOC) { if (typeof DOC.hidden !== "undefined") { @@ -158,6 +167,27 @@ function updateLastAction() { lastAction = Date.now(); } +function isTrackballMode() { + return controlMode === 'void'; +} + +function createViewControl(cam, dom, notify, slider) { + return isTrackballMode() + ? new Trackball(cam, dom, notify, slider) + : new Orbit(cam, dom, notify, slider); +} + +function applyControlBindings() { + if (!viewControl?.setMouse) return; + if (controlMode === 'onshape') { + viewControl.setMouse(viewControl.mouseOnshape); + } else if (controlMode === 'void') { + viewControl.setMouse(viewControl.mouseVoid); + } else { + viewControl.setMouse(viewControl.mouseDefault); + } +} + function delayed(key, time, fn) { clearTimeout(timers[key]); timers[key] = setTimeout(fn, time); @@ -167,6 +197,15 @@ function valueOr(val, def) { return val !== undefined ? val : def; } +function isEffectivelyVisible(obj) { + let node = obj; + while (node) { + if (!node.visible) return false; + node = node.parent; + } + return true; +} + WORLD.contains = (obj) => { return WORLD.children.contains(obj); }; @@ -182,7 +221,7 @@ function tweenit() { tweenit(); -function tweenCamPan(x,y,z,left,up) { +function tweenCamPan(x,y,z,left,up,time,upVec) { updateLastAction(); let pos = viewControl.getPosition(); pos.panX = x; @@ -190,17 +229,54 @@ function tweenCamPan(x,y,z,left,up) { pos.panZ = z; if (left !== undefined) pos.left = left; if (up !== undefined) pos.up = up; + if (time !== undefined) pos.time = time; + if (upVec) pos.upVec = upVec; tweenCam(pos); } function tweenCam(pos) { + let hasScale = pos.scale !== undefined; + let hasUpVec = pos.upVec !== undefined; + let prevScale = 1; + let tweenDuration = pos.time ?? tweenTime; let tf = function () { - viewControl.setPosition(this); + if (hasUpVec && camera) { + const upNow = new THREE.Vector3(this.upX, this.upY, this.upZ); + if (upNow.lengthSq() > 1e-12) { + camera.up.copy(upNow.normalize()); + } + } + const next = { + left: this.left, + up: this.up, + panX: this.panX, + panY: this.panY, + panZ: this.panZ + }; + if (hasScale) { + const scaleStep = this.scale / prevScale; + if (isFinite(scaleStep) && scaleStep > 0) { + next.scale = scaleStep; + prevScale = this.scale; + } + } + viewControl.setPosition(next); updateLastAction(); refresh(); }; let from = Object.clone(viewControl.getPosition()); let to = Object.clone(pos); + if (hasScale) { + from.scale = 1; + } + if (hasUpVec && camera) { + from.upX = camera.up.x; + from.upY = camera.up.y; + from.upZ = camera.up.z; + to.upX = pos.upVec.x; + to.upY = pos.upVec.y; + to.upZ = pos.upVec.z; + } let dist = Math.abs(from.left - to.left); if (dist > Math.PI) { if (from.left < to.left) { @@ -210,10 +286,29 @@ function tweenCam(pos) { } } new TWEEN.Tween(from). - to(to, tweenTime). + to(to, tweenDuration). onUpdate(tf). onComplete(() => { - viewControl.setPosition(pos); + if (hasUpVec && camera) { + const upFinal = new THREE.Vector3(pos.upVec.x, pos.upVec.y, pos.upVec.z); + if (upFinal.lengthSq() > 1e-12) { + camera.up.copy(upFinal.normalize()); + } + } + const finalPos = { + left: pos.left, + up: pos.up, + panX: pos.panX, + panY: pos.panY, + panZ: pos.panZ + }; + if (hasScale) { + const finalScaleStep = pos.scale / prevScale; + if (isFinite(finalScaleStep) && finalScaleStep > 0) { + finalPos.scale = finalScaleStep; + } + } + viewControl.setPosition(finalPos); updateLastAction(); refresh(); let { then } = pos; @@ -224,16 +319,176 @@ function tweenCam(pos) { start(); } +function snapUpForViewDirection(dir, currentUp) { + const viewDir = dir.clone().normalize(); + const upRef = (currentUp || new THREE.Vector3(0, 1, 0)).clone(); + let projectedUp = upRef.projectOnPlane(viewDir); + if (projectedUp.lengthSq() < 1e-8) { + projectedUp = new THREE.Vector3(0, 1, 0).projectOnPlane(viewDir); + } + if (projectedUp.lengthSq() < 1e-8) { + projectedUp = new THREE.Vector3(0, 0, 1).projectOnPlane(viewDir); + } + if (projectedUp.lengthSq() < 1e-8) { + projectedUp = new THREE.Vector3(1, 0, 0).projectOnPlane(viewDir); + } + if (projectedUp.lengthSq() < 1e-8) { + return new THREE.Vector3(0, 1, 0); + } + projectedUp.normalize(); + + const axes = [ + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(-1, 0, 0), + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(0, 0, -1) + ]; + + let best = null; + let bestScore = -Infinity; + for (const axis of axes) { + const p = axis.clone().projectOnPlane(viewDir); + if (p.lengthSq() < 1e-8) continue; + p.normalize(); + const score = p.dot(projectedUp); + if (score > bestScore) { + bestScore = score; + best = p; + } + } + return best; +} + +function viewDirectionFromAngles(left, upAngle) { + return new THREE.Vector3( + Math.sin(upAngle) * Math.sin(left), + Math.cos(upAngle), + Math.sin(upAngle) * Math.cos(left) + ).normalize(); +} + +function tweenPreset(left, upAngle, then) { + // Keep legacy behavior for orbit-based modes (kiri/mesh): do not tween camera.up. + if (controlMode !== 'void') { + tweenCam({ left, up: upAngle, panX, panY, panZ, then }); + return; + } + const upVec = camera + ? snapUpForViewDirection(viewDirectionFromAngles(left, upAngle), camera.up) + : null; + tweenCam({ left, up: upAngle, panX, panY, panZ, upVec: upVec || undefined, then }); +} + +function fitPreset(left, upAngle, then) { + const upVec = camera + ? snapUpForViewDirection(viewDirectionFromAngles(left, upAngle), camera.up) + : null; + Space.view.fit(then, { left, up: upAngle, upVec: upVec || undefined, tween: true }); +} + +function runPreset(left, upAngle, then) { + // Only void uses preset+fit behavior. Kiri/mesh keep legacy fixed-distance presets. + if (controlMode === 'void') { + fitPreset(left, upAngle, then); + } else { + tweenPreset(left, upAngle, then); + } +} + /** ****************************************************************** * Utility Functions ******************************************************************* */ -function width() { return WIN.innerWidth } +function width() { return container ? container.clientWidth : WIN.innerWidth } -function height() { return WIN.innerHeight } +function height() { return container ? container.clientHeight : WIN.innerHeight } function aspect() { return width() / height() } +/** + * Convert mouse event to normalized device coordinates (-1 to +1) + * relative to the container element. Accounts for container position offset. + */ +function eventToNDC(event) { + const canvas = renderer?.domElement || null; + const rect = canvas?.getBoundingClientRect?.(); + if (!rect?.width || !rect?.height) { + if (!container) { + // Fallback for no container (shouldn't happen after init) + return { + x: (event.clientX / WIN.innerWidth) * 2 - 1, + y: -(event.clientY / WIN.innerHeight) * 2 + 1 + }; + } + const crect = container.getBoundingClientRect(); + const x = event.clientX - crect.left; + const y = event.clientY - crect.top; + return { + x: (x / crect.width) * 2 - 1, + y: -(y / crect.height) * 2 + 1 + }; + } + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + return { + x: (x / rect.width) * 2 - 1, + y: -(y / rect.height) * 2 + 1 + }; +} + +/** + * Update tracking plane orientation based on tracking mode + */ +function updateTrackingPlane() { + if (!trackPlane || !camera || !viewControl) { + return; + } + + // Don't update during drag operations + if (mouseDragPoint) { + return; + } + + switch (trackingMode) { + case 'camera-aligned': + // Orient plane perpendicular to camera view + const cameraDir = new THREE.Vector3(); + camera.getWorldDirection(cameraDir); + + // Position plane at fixed distance behind camera target + const target = viewControl.getTarget(); + trackPlane.position.copy(target).addScaledVector(cameraDir, trackingDistance); + + // Orient perpendicular to camera (copy camera rotation) + trackPlane.quaternion.copy(camera.quaternion); + + // Enable aligned tracking mode + alignedTracking = true; + trackPlane.visible = false; // Hidden by default, shown during drag + break; + + case 'world-xy': + // Fixed horizontal plane at Z=0 (original behavior) + trackPlane.position.set(0, 0, 0); + trackPlane.rotation.set(0, 0, 0); + + // Enable aligned tracking mode + alignedTracking = true; + trackPlane.visible = false; // Hidden by default, shown during drag + break; + + case 'platform': + default: + // Use platform for tracking (not trackPlane) + alignedTracking = false; + trackPlane.visible = false; + break; + } +} + function addEventListener(el, key, fn) { el.addEventListener(key, fn); } @@ -862,6 +1117,9 @@ function intersect(objects, recurse) { ******************************************************************* */ function onMouseDown(event) { + if (isVoidUiEventTarget(event?.target)) { + return; + } updateLastAction(); if (event.target === renderer.domElement) { DOC.activeElement.blur(); @@ -872,18 +1130,19 @@ function onMouseDown(event) { if (mouseDownSelect) { selection = mouseDownSelect(undefined, event); } - if (selection && selection.length > 0) { + // Always raycast, even if no selection (to detect trackPlane on empty clicks) + if (selection || alignedTracking) { // selection = selection.map(o => o.isGroup ? o.children : o).flat(); - // console.log({ selection }); trackTo.visible = true; - let int = intersect(selection.slice().append(trackTo), false); + let raycastArray = selection && selection.length > 0 ? selection.slice().append(trackTo) : [trackTo]; + let int = intersect(raycastArray, false); trackTo.visible = isVis; if (int.length > 0) { let trackInt, selectInt; for (let i=0; i 0) { - int = intersect(selection, selectRecurse); - if (int.length > 0) mouseHover(int[0], event, int); - } - if ((!int || int.length == 0) && platformHover) { - vis = platform.visible; - platform.visible = true; - int = intersect([platform], false); - platform.visible = vis; - if (int && int.length > 0) platformHover(int[0].point); + if (!event.buttons) { + let selection = mouseHover ? mouseHover() : null; + if (selection && selection.length > 0) { + int = intersect(selection, selectRecurse); + if (int.length > 0) mouseHover(int[0], event, int); + else if (mouseHoverNull) mouseHoverNull(); + } + if ((!int || int.length == 0) && platformHover) { + vis = platform.visible; + platform.visible = true; + int = intersect([platform], false); + platform.visible = vis; + if (int && int.length > 0) platformHover(int[0].point); + } + } else if (mouseHoverNull) { + mouseHoverNull(); } } else if (mouseDragPoint && mouseDrag && (dragTrack = mouseDrag())) { event.preventDefault(); @@ -1019,9 +1286,13 @@ function onMouseMove(event) { requestRefresh(); } } - mouse = { - x: (event.clientX / width()) * 2 - 1, - y: -(event.clientY / height()) * 2 + 1}; + mouse = eventToNDC(event); +} + +function isVoidUiEventTarget(target) { + return !!target?.closest?.( + '.props-panel, #left-panel, #top-bar, .doc-dialog, .doc-dialog-backdrop, .toolbar-menu, .toolbar-menu-pop, .toolbar-menu-panel, .sketch-constraint-layer' + ); } /** ****************************************************************** @@ -1047,6 +1318,40 @@ function updateFocus() { } } +function onViewControlMove(position, moved) { + if (platform) { + platform.visible = hidePlatformBelow ? + initialized && position.y >= 0 && showPlatform : showPlatform; + volume.visible = volumeOn && platform.visible; + } + if (grid.view) { + grid.view.visible = hideGridBelow ? platform.visible : showGrid; + } + if (cameraLight) { + cameraLight.position.copy(camera.position); + } + if (moved && platformOnMove) { + clearTimeout(platformMoveTimer); + platformMoveTimer = setTimeout(platformOnMove, 500); + Space.scene.updateFog(); + } + updateTrackingPlane(); + updateLastAction(); + updateFocus(); +} + +function onViewControlZoom(val) { + if (camera && grid?.origin?.scale) { + grid.origin.scale(); + } + if (camera && viewControl) { + const dist = camera.position.distanceTo(viewControl.target); + raycaster.params.Line.threshold = Math.min(1, dist / 100); + } + updateLastAction(); + if (sliderCallback) sliderCallback(val); +} + function setSky(opt = {}) { let { grid, color, gridColor } = opt; if (grid) Space.sky.showGrid(grid); @@ -1061,7 +1366,8 @@ function setSky(opt = {}) { function setPlatform(opt = {}) { let platform = Space.platform; - let { color, round, size, grid, opacity } = opt; + let { hiding } = opt; + let { color, round, size, grid, opacity, zoom } = opt; let { visible, volume, zOffset, origin, light } = opt; if (light) { lightInfo.intensity = light; @@ -1077,12 +1383,14 @@ function setPlatform(opt = {}) { platform.setSize(width, depth, height, maxz); } if (grid) { - let { zOffset } = grid; + let { below, disabled, zOffset } = grid; let { major = 25, minor = 5 } = grid; let { colorX, colorY, colorMajor, colorMinor } = grid; platform.setGrid(major, minor); platform.setGridColor({ colorX, colorY, colorMajor, colorMinor }); if (zOffset !== undefined) platform.setGridZOff(zOffset); + if (disabled) platform.showGrid(false); + if (below) platform.showGridBelow(true); } if (origin) { let { x, y, z, show } = origin; @@ -1100,12 +1408,24 @@ function setPlatform(opt = {}) { if (visible !== undefined) { platform.setVisible(visible); } + if (zoom !== undefined) { + Space.view.setZoom(zoom.reverse, zoom.speed); + } + if (hiding !== undefined) { + platform.setHiding(hiding); + } } let Space = { refresh: refresh, update: requestRefresh, + afterRender(callback) { + if (callback && typeof callback === 'function') { + afterRenderCallbacks.push(callback); + } + }, + setAntiAlias(b) { antiAlias = b ? true : false }, raycast: intersect, @@ -1224,7 +1544,7 @@ let Space = { }, preset: { - top: {left: home, up: 0, panX, panY, panZ}, + top: {left: 0, up: 0, panX, panY, panZ}, back: {left: PI, up: PI2, panX, panY, panZ}, home: {left: home, up, panX, panY, panZ}, front: {left: 0, up: PI2, panX, panY, panZ}, @@ -1233,25 +1553,29 @@ let Space = { }, view: { - top: (then) => { tweenCam({left: home, up: 0, panX, panY, panZ, then}) }, - back: (then) => { tweenCam({left: PI, up: PI2, panX, panY, panZ, then}) }, - home: (then) => { tweenCam({left: home, up, panX, panY, panZ, then}) }, - front: (then) => { tweenCam({left: 0, up: PI2, panX, panY, panZ, then}) }, - right: (then) => { tweenCam({left: PI2, up: PI2, panX, panY, panZ, then}) }, - left: (then) => { tweenCam({left: -PI2, up: PI2, panX, panY, panZ, then}) }, + top: (then) => { runPreset(0, 0, then) }, + bottom: (then) => { runPreset(0, PI, then) }, + back: (then) => { runPreset(PI, PI2, then) }, + home: (then) => { runPreset(home, up, then) }, + front: (then) => { runPreset(0, PI2, then) }, + right: (then) => { runPreset(PI2, PI2, then) }, + left: (then) => { runPreset(-PI2, PI2, then) }, reset: () => { viewControl.reset(); requestRefresh() }, load: (cam) => { viewControl.setPosition(cam); requestRefresh() }, save: () => { return viewControl.getPosition(true) }, - panTo: (x,y,z,l,u) => { tweenCamPan(x,y,z,l,u) }, + panTo: (x,y,z,l,u,t,upVec) => { tweenCamPan(x,y,z,l,u,t,upVec) }, setZoom: (r,v) => { viewControl.setZoom(r,v) }, fit: (then, opts = {}) => { // Calculate bounding box of all objects in the workspace const box = new THREE.Box3(); let hasObjects = false; + const visibleOnly = opts.visibleOnly !== undefined ? !!opts.visibleOnly : fitVisibleOnly; // Recursively expand box for all visible objects with geometry WORLD.traverse(obj => { - if (obj.visible && obj.geometry) { + if (!obj.geometry) return; + if (visibleOnly && !isEffectivelyVisible(obj)) return; + if (obj.visible) { box.expandByObject(obj); hasObjects = true; } @@ -1277,10 +1601,15 @@ let Space = { // Use the maximum dimension for distance calculation const maxDim = Math.max(size.x, size.y, size.z); + // Get target view angles (may be overridden by caller) + const pos = viewControl.getPosition(); + const left = opts.left !== undefined ? opts.left : pos.left; + const upAngle = opts.up !== undefined ? opts.up : pos.up; // Calculate desired camera distance based on bounding box - const padding = opts.padding || 0.75; + const padding = opts.padding || (camera.isOrthographicCamera ? fitPaddingOrthographic : fitPaddingPerspective); let desiredDistance; + let orthoScaleSaveTarget = null; if (camera.isPerspectiveCamera) { // For perspective, calculate distance to fit object in view @@ -1288,55 +1617,166 @@ let Space = { // Use maxDim directly (not half) for more conservative framing desiredDistance = maxDim / Math.tan(fov / 2) * padding; } else { - // For orthographic, calculate equivalent distance - // The ortho frustum size is proportional to distance * tan(fov/2) - // We want similar framing to perspective mode - const fov = perspective * (Math.PI / 180); - desiredDistance = maxDim / Math.tan(fov / 2) * padding; - } + // For orthographic, fit based on camera-plane extents (not perspective distance). + // This avoids chronic over-zoom-out in ortho mode. + const min = box.min; + const max = box.max; + const corners = [ + new THREE.Vector3(min.x, min.y, min.z), + new THREE.Vector3(min.x, min.y, max.z), + new THREE.Vector3(min.x, max.y, min.z), + new THREE.Vector3(min.x, max.y, max.z), + new THREE.Vector3(max.x, min.y, min.z), + new THREE.Vector3(max.x, min.y, max.z), + new THREE.Vector3(max.x, max.y, min.z), + new THREE.Vector3(max.x, max.y, max.z) + ]; - // Get current view angles or use defaults - const pos = viewControl.getPosition(); - const left = opts.left !== undefined ? opts.left : pos.left; - const upAngle = opts.up !== undefined ? opts.up : pos.up; + const requestedView = opts.left !== undefined || opts.up !== undefined || !!opts.upVec; + let camMinX = Infinity; + let camMaxX = -Infinity; + let camMinY = Infinity; + let camMaxY = -Infinity; + if (requestedView) { + const viewDir = viewDirectionFromAngles(left, upAngle); + let upVec = null; + if (opts.upVec) { + upVec = new THREE.Vector3(opts.upVec.x || 0, opts.upVec.y || 0, opts.upVec.z || 0); + if (upVec.lengthSq() > 1e-12) upVec.normalize(); + } + if (!upVec) { + upVec = snapUpForViewDirection(viewDir, camera.up) || camera.up.clone(); + } + upVec = upVec.projectOnPlane(viewDir); + if (upVec.lengthSq() < 1e-8) { + upVec = new THREE.Vector3(0, 1, 0).projectOnPlane(viewDir); + } + if (upVec.lengthSq() < 1e-8) { + upVec = new THREE.Vector3(1, 0, 0).projectOnPlane(viewDir); + } + upVec.normalize(); + const rightVec = upVec.clone().cross(viewDir).normalize(); + for (const corner of corners) { + const x = corner.dot(rightVec); + const y = corner.dot(upVec); + if (x < camMinX) camMinX = x; + if (x > camMaxX) camMaxX = x; + if (y < camMinY) camMinY = y; + if (y > camMaxY) camMaxY = y; + } + } else { + camera.updateMatrixWorld(true); + const inv = camera.matrixWorldInverse; + for (const corner of corners) { + corner.applyMatrix4(inv); + if (corner.x < camMinX) camMinX = corner.x; + if (corner.x > camMaxX) camMaxX = corner.x; + if (corner.y < camMinY) camMinY = corner.y; + if (corner.y > camMaxY) camMaxY = corner.y; + } + } + const spanX = Math.max(1e-6, camMaxX - camMinX); + const spanY = Math.max(1e-6, camMaxY - camMinY); + const frustumW = Math.max(1e-6, Math.abs(camera.right - camera.left)); + const frustumH = Math.max(1e-6, Math.abs(camera.top - camera.bottom)); + const fitX = spanX / frustumW; + const fitY = spanY / frustumH; + // Keep historical fit padding semantics: lower padding => more margin. + orthoScaleSaveTarget = Math.max(fitX, fitY) / Math.max(1e-6, padding); + } // Map scene coordinates to pan coordinates // The target position in orbit control is in scene space const newPanX = center.x; const newPanY = center.y; const newPanZ = center.z; + const currentScaleSave = viewControl.getPosition(true).scale || 1; + const currentDistToCenter = camera.position.distanceTo(center); - // First, tween to center the view on the object - viewControl.setPosition({ + const fitPos = { left, up: upAngle, panX: newPanX, panY: newPanY, panZ: newPanZ, - }); + }; + let fitScaleRatio = 1; if (camera.isPerspectiveCamera) { - const currentDist = camera.position.distanceTo(viewControl.getTarget()); - const scale = desiredDistance / currentDist; - viewControl.setPosition({ scale }); + fitScaleRatio = desiredDistance / currentDistToCenter; } else { - // For orthographic, set zoom directly based on viewing size - // The camera frustum height is determined by the orthographic bounds - // We want the object to fit within the view with padding - const currentDist = camera.position.distanceTo(viewControl.getTarget()); - const targetScaleSave = desiredDistance / currentDist; - // Reset scale accumulation and set absolute zoom - viewControl.setPosition({ scale: targetScaleSave / viewControl.getPosition(true).scale }); + // For orthographic, set the absolute target zoom scale directly. + const targetScaleSave = Number.isFinite(orthoScaleSaveTarget) ? orthoScaleSaveTarget : currentScaleSave; + fitScaleRatio = targetScaleSave / currentScaleSave; } - viewControl.update(); - if (typeof(then) === 'function') then(); + // Guard against degenerate center/camera overlap. + if (!isFinite(fitScaleRatio) || fitScaleRatio <= 0) { + fitScaleRatio = 1; + } + + if (opts.tween !== false) { + if (opts.upVec) { + fitPos.upVec = opts.upVec; + } + fitPos.scale = fitScaleRatio; + fitPos.time = opts.time ?? 350; + fitPos.then = then; + tweenCam(fitPos); + } else { + if (opts.upVec && camera) { + const upNow = new THREE.Vector3(opts.upVec.x || 0, opts.upVec.y || 0, opts.upVec.z || 0); + if (upNow.lengthSq() > 1e-12) { + camera.up.copy(upNow.normalize()); + } + } + viewControl.setPosition(fitPos); + viewControl.setPosition({ scale: fitScaleRatio }); + viewControl.update(); + if (typeof(then) === 'function') then(); + } }, setCtrl: (name) => { - if (name === 'onshape') { - viewControl.setMouse(viewControl.mouseOnshape); - } else { - viewControl.setMouse(viewControl.mouseDefault); + const nextMode = name || 'default'; + const wantsTrackball = nextMode === 'void'; + const hasTrackball = !!viewControl?.isTrackballAdapter; + controlMode = nextMode; + + if (viewControl && wantsTrackball !== hasTrackball) { + const position = viewControl.getPosition(true); + const target = viewControl.getTarget().clone(); + const minDistance = viewControl.minDistance; + const maxDistance = viewControl.maxDistance; + const noKeys = viewControl.noKeys; + const enabled = viewControl.enabled; + const reverseZoom = viewControl.reverseZoom; + const zoomSpeed = viewControl.zoomSpeed; + + if (viewControl.dispose) { + viewControl.dispose(); + } + + viewControl = createViewControl(camera, container, onViewControlMove, onViewControlZoom); + viewControl.noKeys = noKeys; + viewControl.minDistance = minDistance; + viewControl.maxDistance = maxDistance; + viewControl.enabled = enabled; + viewControl.reverseZoom = reverseZoom; + viewControl.zoomSpeed = zoomSpeed; + viewControl.setTarget(target); + viewControl.setPosition(position); + } + applyControlBindings(); + }, + setFitVisibleOnly: (enabled) => { + fitVisibleOnly = !!enabled; + }, + setFitPadding: (next = {}) => { + if (Number.isFinite(next.perspective) && next.perspective > 0) { + fitPaddingPerspective = next.perspective; + } + if (Number.isFinite(next.orthographic) && next.orthographic > 0) { + fitPaddingOrthographic = next.orthographic; } }, getFPS () { return fps }, @@ -1352,8 +1792,8 @@ let Space = { updateFocus(); }, setHome(r,u) { - home = r || 0; - up = u || PI4; + home = r ?? 0; + up = u ?? PI4; }, spin(then, count) { Space.view.front(() => { @@ -1387,9 +1827,51 @@ let Space = { downSelect: (f) => { mouseDownSelect = f }, upSelect: (f) => { mouseUpSelect = f }, onDrag: (f) => { mouseDrag = f }, - onHover: (f) => { mouseHover = f } + onHover: (f,n) => { mouseHover = f, mouseHoverNull = n } }, + tracking: { + /** + * Set tracking plane mode + * @param {string} mode - 'platform', 'camera-aligned', or 'world-xy' + */ + setMode(mode) { + if (['platform', 'camera-aligned', 'world-xy'].includes(mode)) { + trackingMode = mode; + updateTrackingPlane(); + requestRefresh(); + } + }, + + /** + * Set distance from camera for camera-aligned mode + * @param {number} distance - Distance in world units + */ + setDistance(distance) { + trackingDistance = distance; + if (trackingMode === 'camera-aligned') { + updateTrackingPlane(); + requestRefresh(); + } + }, + + /** + * Get current tracking mode + */ + getMode() { + return trackingMode; + }, + + /** + * Get tracking plane object (for advanced use) + */ + getPlane() { + return trackPlane; + } + }, + + isFocused: inputHasFocus, + tween: { setTime: (t) => { tweenTime = t || 500 }, setDelay: (d) => { tweenDelay = d || 20 } @@ -1452,7 +1934,7 @@ let Space = { }, internals() { - return { renderer, camera, platform }; + return { renderer, camera, platform, container, raycaster }; }, isOrtho() { @@ -1498,7 +1980,11 @@ let Space = { // Copy camera position newCamera.position.copy(camera.position); - newCamera.up.copy(camera.up); + if (controlMode === 'void') { + newCamera.up.copy(camera.up); + } else { + newCamera.up.set(0, 1, 0); + } newCamera.lookAt(target); // Store old control properties @@ -1515,43 +2001,17 @@ let Space = { cameraType = type; // Recreate viewControl with new camera and proper callbacks - viewControl = new Orbit(camera, container, (position, moved) => { - if (platform) { - platform.visible = hidePlatformBelow ? - initialized && position.y >= 0 && showPlatform : showPlatform; - volume.visible = showVolume && platform.visible; - } - if (grid.view) { - grid.view.visible = hideGridBelow ? platform.visible : showGrid; - } - if (cameraLight) { - cameraLight.position.copy(camera.position); - } - if (moved && platformOnMove) { - clearTimeout(platformMoveTimer); - platformMoveTimer = setTimeout(platformOnMove, 500); - Space.scene.updateFog(); - } - updateLastAction(); - updateFocus(); - }, (val) => { - if (camera && grid?.origin?.scale) { - grid.origin.scale(); - } - if (camera && viewControl) { - // increase intersect line precision on zoom - const dist = camera.position.distanceTo(viewControl.target); - raycaster.params.Line.threshold = Math.min(1, dist / 100); - } - updateLastAction(); - if (sliderCallback) sliderCallback(val); - }); + if (viewControl?.dispose) { + viewControl.dispose(); + } + viewControl = createViewControl(camera, container, onViewControlMove, onViewControlZoom); viewControl.noKeys = noKeys; viewControl.minDistance = minDistance; viewControl.maxDistance = maxDistance; viewControl.enabled = enabled; viewControl.reverseZoom = reverseZoom; viewControl.zoomSpeed = zoomSpeed; + applyControlBindings(); viewControl.setPosition(position); // Dispose old camera @@ -1599,42 +2059,13 @@ let Space = { raycaster = new THREE.Raycaster(); - viewControl = new Orbit(camera, domelement, (position, moved) => { - if (platform) { - platform.visible = hidePlatformBelow ? - initialized && position.y >= 0 && showPlatform : showPlatform; - volume.visible = showVolume && platform.visible; - } - if (grid.view) { - grid.view.visible = hideGridBelow ? platform.visible : showGrid; - } - if (cameraLight) { - cameraLight.position.copy(camera.position); - } - if (moved && platformOnMove) { - clearTimeout(platformMoveTimer); - platformMoveTimer = setTimeout(platformOnMove, 500); - Space.scene.updateFog(); - } - updateLastAction(); - updateFocus(); - }, (val) => { - if (camera && grid?.origin?.scale) { - grid.origin.scale(); - } - if (camera && viewControl) { - // increase intersect line precision on zoom - const dist = camera.position.distanceTo(viewControl.target); - raycaster.params.Line.threshold = Math.min(1, dist / 100); - } - // broker.publish("space.view.zoom", camera); - updateLastAction(); - if (slider) slider(val); - }); + sliderCallback = slider; + viewControl = createViewControl(camera, domelement, onViewControlMove, onViewControlZoom); viewControl.noKeys = true; viewControl.minDistance = 1; viewControl.maxDistance = 1000; + applyControlBindings(); SCENE.add(skyAmbient = new THREE.AmbientLight(0x707070)); @@ -1701,6 +2132,10 @@ let Space = { if (docVisible && !freeze && Date.now() - lastAction < 1500) { renderStart = Date.now(); renderer.render(SCENE, camera); + // call after-render callbacks (e.g., for viewcube) + for (const callback of afterRenderCallbacks) { + callback(renderer); + } // track frame render times renders.push(Date.now() - renderStart); } else { @@ -1721,6 +2156,9 @@ let Space = { }; initialized = true; + + // Initialize tracking plane orientation + updateTrackingPlane(); } }; diff --git a/src/moto/trackball.js b/src/moto/trackball.js new file mode 100644 index 00000000..cd464b0f --- /dev/null +++ b/src/moto/trackball.js @@ -0,0 +1,377 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +"use strict"; + +import { THREE } from '../ext/three.js'; +import { TrackballControls } from '../ext/three.js'; + +const { MOUSE, Vector3 } = THREE; +const BUTTON = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; +const ACTION = { ROTATE: 0, DOLLY: 1, PAN: 2 }; +const EPS = 1e-6; +const VOID_ROTATE_SPEED = 36.0; +const VOID_PAN_SPEED_PERSPECTIVE = 1.0; +const VOID_PAN_SPEED_ORTHO = 2.4; +const VOID_ZOOM_SPEED_PERSPECTIVE_MULT = 1.35; +const VOID_ZOOM_SPEED_ORTHO_MULT = 2.2; + +class Trackball { + constructor(object, domElement, notify, slider) { + this.object = object; + this.domElement = domElement !== undefined ? domElement : document; + + this.control = new TrackballControls(object, this.domElement); + this.control.staticMoving = true; + this.control.dynamicDampingFactor = 0; + this.control.rotateSpeed = VOID_ROTATE_SPEED; + this.control.zoomSpeed = 2.0; + this.control.panSpeed = VOID_PAN_SPEED_PERSPECTIVE; + + this.target = this.control.target; + this.center = this.target; + + this.mouseDefault = { + ORBIT: MOUSE.LEFT, + ZOOM: MOUSE.MIDDLE, + PAN: MOUSE.RIGHT + }; + this.mouseOnshape = { + ORBIT: MOUSE.RIGHT, + ZOOM: MOUSE.LEFT, + PAN: MOUSE.MIDDLE + }; + this.mouseVoid = { + ORBIT: MOUSE.RIGHT, + PAN: MOUSE.MIDDLE + }; + this.mouseButtons = this.mouseDefault; + + this.orbitPivotOnRight = false; + this.continuousRotate = true; + this.reverseZoom = false; + this.zoomSpeed = 1.0; + this._keysDisabled = false; + + this.isTrackballAdapter = true; + + const mapButtons = () => { + const actions = { + LEFT: -1, + MIDDLE: -1, + RIGHT: -1 + }; + const bind = this.mouseButtons || this.mouseDefault; + const buttonToAction = Object.create(null); + if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE; + if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY; + if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN; + actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT; + actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE; + actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT; + this.control.mouseButtons = actions; + }; + + mapButtons(); + + const emitNotify = (moved) => { + if (notify) notify(this.object.position, moved); + }; + this._emitNotify = emitNotify; + this._lastPosition = this.object.position.clone(); + this._lastQuaternion = this.object.quaternion.clone(); + this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1; + + // Keep Space idle-halo semantics identical to Orbit: any control change + // is treated as active camera motion. + this.control.addEventListener('change', () => { + this._emitNotify?.(true); + }); + + // Mirror Orbit semantics: always signal notify from update(), with moved=true/false. + // Patch underlying control.update() so internal handlers also flow through this path. + const rawUpdate = this.control.update.bind(this.control); + this.control.update = (...args) => { + rawUpdate(...args); + const moved = + this._lastPosition.distanceToSquared(this.object.position) > EPS + || 8 * (1 - this._lastQuaternion.dot(this.object.quaternion)) > EPS + || Math.abs((this.object.zoom || 1) - this._lastZoom) > EPS; + this._emitNotify?.(moved); + if (moved) { + this._lastPosition.copy(this.object.position); + this._lastQuaternion.copy(this.object.quaternion); + this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1; + } + }; + + this._animating = false; + this._raf = null; + this._tick = () => { + if (!this._animating) return; + if (this.control.enabled) { + this.control.update(); + } + this._raf = self.requestAnimationFrame(this._tick); + }; + this._startTick = () => { + if (this._animating) return; + this._animating = true; + this._tick(); + }; + this._stopTick = () => { + this._animating = false; + if (this._raf) { + self.cancelAnimationFrame(this._raf); + this._raf = null; + } + }; + + this._onPointerDown = (event) => { + const b = event?.button; + if (b === BUTTON.LEFT || b === BUTTON.MIDDLE || b === BUTTON.RIGHT) { + this._startTick(); + } + }; + this._onPointerUp = () => this._stopTick(); + this._onPointerCancel = () => this._stopTick(); + this._onWheel = () => { + if (!this.control.enabled) return; + // Run after Trackball's wheel handler mutates zoom deltas. + self.requestAnimationFrame(() => { + if (this.control.enabled) { + this.control.update(); + } + }); + }; + if (this.domElement?.addEventListener) { + this.domElement.addEventListener('pointerdown', this._onPointerDown, true); + this.domElement.addEventListener('wheel', this._onWheel, false); + } + if (this.domElement?.ownerDocument?.addEventListener) { + this.domElement.ownerDocument.addEventListener('pointerup', this._onPointerUp, true); + this.domElement.ownerDocument.addEventListener('pointercancel', this._onPointerCancel, true); + } + + Object.defineProperty(this, 'enabled', { + get: () => this.control.enabled, + set: (v) => { this.control.enabled = !!v; } + }); + Object.defineProperty(this, 'noKeys', { + get: () => !!this._keysDisabled, + set: (v) => { + const next = !!v; + if (next === this._keysDisabled) return; + this._keysDisabled = next; + if (typeof window !== 'undefined') { + if (next) { + window.removeEventListener('keydown', this.control._onKeyDown); + window.removeEventListener('keyup', this.control._onKeyUp); + } else { + window.addEventListener('keydown', this.control._onKeyDown); + window.addEventListener('keyup', this.control._onKeyUp); + } + } + } + }); + Object.defineProperty(this, 'minDistance', { + get: () => this.control.minDistance, + set: (v) => { this.control.minDistance = v; } + }); + Object.defineProperty(this, 'maxDistance', { + get: () => this.control.maxDistance, + set: (v) => { this.control.maxDistance = v; } + }); + } + + setMouse(bindings) { + this.mouseButtons = bindings || this.mouseDefault; + const bind = this.mouseButtons || this.mouseDefault; + const actions = { + LEFT: -1, + MIDDLE: -1, + RIGHT: -1 + }; + const buttonToAction = Object.create(null); + if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE; + if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY; + if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN; + actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT; + actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE; + actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT; + this.control.mouseButtons = actions; + } + + setOrbitPivotOnRight(enabled) { + this.orbitPivotOnRight = !!enabled; + } + + setContinuousRotate(enabled) { + this.continuousRotate = !!enabled; + } + + setZoom(reverse, speed) { + this.reverseZoom = !!reverse; + this.zoomSpeed = speed || 1.0; + const mult = this.object.isOrthographicCamera + ? VOID_ZOOM_SPEED_ORTHO_MULT + : VOID_ZOOM_SPEED_PERSPECTIVE_MULT; + this.control.zoomSpeed = this.zoomSpeed * mult; + } + + getTarget() { + return this.control.target; + } + + setTarget(t) { + this.control.target.copy(t); + } + + setPosition(set) { + const t = this.control.target; + if (set.panX !== undefined) t.x = set.panX; + if (set.panY !== undefined) t.y = set.panY; + if (set.panZ !== undefined) t.z = set.panZ; + + const hasCamPos = Number.isFinite(set?.camX) && Number.isFinite(set?.camY) && Number.isFinite(set?.camZ); + if (hasCamPos) { + this.object.position.set(set.camX, set.camY, set.camZ); + if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) { + const up = new Vector3(set.upX, set.upY, set.upZ); + if (up.lengthSq() > 1e-12) this.object.up.copy(up.normalize()); + } + this.object.lookAt(t); + if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) { + if (this.object.isPerspectiveCamera) { + const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale); + this.object.position.copy(t).add(eye); + } else if (this.object.isOrthographicCamera) { + this.object.zoom = this.object.zoom / set.scale; + this.object.updateProjectionMatrix(); + } + } + return; + } + + let off = this.object.position.clone().sub(t); + let radius = off.length(); + if (!isFinite(radius) || radius <= 0) radius = 1; + + const left = set.left !== undefined ? set.left : Math.atan2(off.x, off.z); + const up = set.up !== undefined ? set.up : Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y); + + off = new Vector3( + radius * Math.sin(up) * Math.sin(left), + radius * Math.cos(up), + radius * Math.sin(up) * Math.cos(left) + ); + + // Avoid singular lookAt matrices at poles by ensuring camera.up is not parallel to view direction. + const viewDir = off.clone().normalize(); + let upVec = null; + if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) { + upVec = new Vector3(set.upX, set.upY, set.upZ); + } else { + upVec = this.object.up.clone(); + } + if (upVec.lengthSq() < 1e-12) upVec.set(0, 1, 0); + upVec.projectOnPlane(viewDir); + if (upVec.lengthSq() < 1e-8) upVec = new Vector3(0, 0, 1).projectOnPlane(viewDir); + if (upVec.lengthSq() < 1e-8) upVec = new Vector3(1, 0, 0).projectOnPlane(viewDir); + if (upVec.lengthSq() > 1e-12) this.object.up.copy(upVec.normalize()); + + this.object.position.copy(t).add(off); + this.object.lookAt(t); + + if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) { + if (this.object.isPerspectiveCamera) { + const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale); + this.object.position.copy(t).add(eye); + } else if (this.object.isOrthographicCamera) { + this.object.zoom = this.object.zoom / set.scale; + this.object.updateProjectionMatrix(); + } + } + } + + getPosition(scaled) { + const t = this.control.target; + const off = this.object.position.clone().sub(t); + const left = Math.atan2(off.x, off.z); + const up = Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y); + return { + left, + up, + panX: t.x, + panY: t.y, + panZ: t.z, + camX: this.object.position.x, + camY: this.object.position.y, + camZ: this.object.position.z, + upX: this.object.up.x, + upY: this.object.up.y, + upZ: this.object.up.z, + scale: scaled ? (this.object.isOrthographicCamera ? 1 / this.object.zoom : 1) : 1 + }; + } + + update() { + this.control.panSpeed = this.object.isOrthographicCamera + ? VOID_PAN_SPEED_ORTHO + : VOID_PAN_SPEED_PERSPECTIVE; + const zoomMult = this.object.isOrthographicCamera + ? VOID_ZOOM_SPEED_ORTHO_MULT + : VOID_ZOOM_SPEED_PERSPECTIVE_MULT; + this.control.zoomSpeed = this.zoomSpeed * zoomMult; + this.control.update(); + } + + addEventListener(type, listener) { + this.control.addEventListener(type, listener); + } + + removeEventListener(type, listener) { + this.control.removeEventListener(type, listener); + } + + dispatchEvent(event) { + this.control.dispatchEvent(event); + } + + reset() { + this.control.reset(); + } + + onMouseUp() { + // TrackballControls manages pointer lifecycle internally. + } + + resetInputState() { + // Clear any latched key/mouse state (e.g. if native prompt swallowed keyup/mouseup) + // and ensure key listeners are restored according to noKeys policy. + this.control.state = -1; + this.control.keyState = -1; + if (typeof window !== 'undefined') { + window.removeEventListener('keydown', this.control._onKeyDown); + window.removeEventListener('keyup', this.control._onKeyUp); + if (!this._keysDisabled) { + window.addEventListener('keydown', this.control._onKeyDown); + window.addEventListener('keyup', this.control._onKeyUp); + } + } + } + + dispose() { + this._stopTick(); + if (this.domElement?.removeEventListener) { + this.domElement.removeEventListener('pointerdown', this._onPointerDown, true); + this.domElement.removeEventListener('wheel', this._onWheel, false); + } + if (this.domElement?.ownerDocument?.removeEventListener) { + this.domElement.ownerDocument.removeEventListener('pointerup', this._onPointerUp, true); + this.domElement.ownerDocument.removeEventListener('pointercancel', this._onPointerCancel, true); + } + this.control.dispose(); + } +} + +export { Trackball }; diff --git a/src/void/api.js b/src/void/api.js new file mode 100644 index 00000000..d6291dee --- /dev/null +++ b/src/void/api.js @@ -0,0 +1,111 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { overlay } from './overlay.js'; +import { datum } from './datum.js'; +import { Plane } from './plane.js'; +import { interact } from './interact.js'; +import { createOriginApi } from './api/origin.js'; +import { createFeaturesApi } from './api/features.js'; +import { createSketchApi } from './sketch/api.js'; +import { createSketchRuntimeApi } from './sketch/runtime.js'; +import { createDocumentApi } from './api/document.js'; +import { createSolidsApi } from './api/solids.js'; +import { createGeometryStoreApi } from './api/geometry_store.js'; + +const DOC_SCHEMA_VERSION = 1; +const ADMIN_CURRENT_DOC_KEY = 'current_doc_id'; +const ADMIN_CURRENT_REV_KEY = 'current_rev'; +const UNDOABLE_OP_TYPES = new Set([ + 'snapshot', + 'datum.update', + 'datum.root.update', + 'origin.update', + 'feature.add', + 'feature.remove', + 'feature.move', + 'feature.rename', + 'feature.update', + 'feature.suppress', + 'feature.atomic.edit', + 'timeline.set' +]); + +function shortId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID().replace(/-/g, '').slice(0, 12); + } + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; +} + +function revString(rev) { + const major = String(rev?.major ?? 0).padStart(6, '0'); + const micro = String(rev?.micro ?? 0).padStart(6, '0'); + return `${major}.${micro}`; +} + +// Main API object +const api = { + db: null, + overlay, + datum, + Plane, + interact, + origin: null, + + // Document management + document: null, + + // Feature management + sketch: null, + sketchRuntime: null, + features: null, + solids: null, + geometryStore: null, + + // Selection management + selection: { + items: new Set(), + + clear() { + this.items.clear(); + }, + + add(item) { + this.items.add(item); + }, + + remove(item) { + this.items.delete(item); + }, + + toggle(item) { + if (this.items.has(item)) { + this.remove(item); + } else { + this.add(item); + } + } + }, + + // Initialize API + init() { + console.log({ api_initialized: true }); + } +}; + +api.origin = createOriginApi(() => api); +api.features = createFeaturesApi(() => api); +api.sketch = createSketchApi(() => api, shortId); +api.sketchRuntime = createSketchRuntimeApi(() => api); +api.document = createDocumentApi(() => api, { + DOC_SCHEMA_VERSION, + ADMIN_CURRENT_DOC_KEY, + ADMIN_CURRENT_REV_KEY, + UNDOABLE_OP_TYPES, + idFactory: shortId, + revString +}); +api.solids = createSolidsApi(() => api); +api.geometryStore = createGeometryStoreApi(() => api); + +export { api }; diff --git a/src/void/api/document.js b/src/void/api/document.js new file mode 100644 index 00000000..877e5f70 --- /dev/null +++ b/src/void/api/document.js @@ -0,0 +1,616 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/void/api/features.js b/src/void/api/features.js new file mode 100644 index 00000000..9b97a086 --- /dev/null +++ b/src/void/api/features.js @@ -0,0 +1,238 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/void/api/geometry_store.js b/src/void/api/geometry_store.js new file mode 100644 index 00000000..76543187 --- /dev/null +++ b/src/void/api/geometry_store.js @@ -0,0 +1,98 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/void/api/origin.js b/src/void/api/origin.js new file mode 100644 index 00000000..057978ec --- /dev/null +++ b/src/void/api/origin.js @@ -0,0 +1,79 @@ +/** Copyright Stewart Allen -- 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 }; diff --git a/src/void/api/solids.js b/src/void/api/solids.js new file mode 100644 index 00000000..93722cf2 --- /dev/null +++ b/src/void/api/solids.js @@ -0,0 +1,2651 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE, BufferGeometryUtils } from '../../ext/three.js'; +import { Line2, LineGeometry, LineMaterial } from '../../ext/three.js'; +import { ensureKernel } from '../solid/kernel.js'; +import { rebuildGeneratedSolids } from '../solid/rebuild.js'; +import { space } from '../../moto/space.js'; + +const SOLID_CREASE_ANGLE_DEG = 30; + +function createSolidsApi(getApi) { + function resolveProfileTargetRef(profileTarget = {}) { + const regionId = String(profileTarget?.region_id || ''); + const match = regionId.match(/^profile:([^:]+):([^:]+)$/); + if (!match) return { regionId: null, sketchId: null, profileId: null, key: null }; + const sketchId = match[1]; + const profileId = match[2]; + return { regionId, sketchId, profileId, key: regionId }; + } + + function frameToBasis(frame) { + if (!frame?.origin || !frame?.normal || !frame?.x_axis) return null; + const origin = new THREE.Vector3( + Number(frame.origin.x || 0), + Number(frame.origin.y || 0), + Number(frame.origin.z || 0) + ); + const normal = new THREE.Vector3( + Number(frame.normal.x || 0), + Number(frame.normal.y || 0), + Number(frame.normal.z || 1) + ).normalize(); + let xAxis = new THREE.Vector3( + Number(frame.x_axis.x || 1), + Number(frame.x_axis.y || 0), + Number(frame.x_axis.z || 0) + ); + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + if (xAxis.lengthSq() <= 1e-12) { + xAxis.set(1, 0, 0); + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + } + xAxis.normalize(); + const yAxis = new THREE.Vector3().crossVectors(normal, xAxis).normalize(); + return { origin, normal, xAxis, yAxis }; + } + + function frameLocalToWorld(local, basis) { + if (!local || !basis) return null; + return basis.origin.clone() + .addScaledVector(basis.xAxis, Number(local.x || 0)) + .addScaledVector(basis.yAxis, Number(local.y || 0)); + } + + function profileLoopsFromRuntime(api, profileTarget) { + const { sketchId, profileId } = resolveProfileTargetRef(profileTarget); + if (!sketchId || !profileId) return null; + const rec = api.sketchRuntime?.getRecord?.(sketchId); + const view = rec?.entityViews?.get?.(profileId); + const loops = view?.object?.userData?.sketchProfileLoops || view?.entity?.loops || null; + if (Array.isArray(loops) && loops.length) { + const out = loops.filter(loop => Array.isArray(loop) && loop.length >= 3); + return out.length ? out : null; + } + const loop = view?.object?.userData?.sketchProfileLoop || view?.entity?.loop || null; + if (Array.isArray(loop) && loop.length >= 3) return [loop]; + return null; + } + + function buildRebuildSnapshot(api) { + const builtFeatures = api.features.listBuilt(); + const sketchPlanes = {}; + const profileLoops = {}; + for (const feature of (api.features.list() || [])) { + if (feature?.type === 'sketch' && feature?.id) { + sketchPlanes[feature.id] = feature.plane || {}; + } + } + for (const feature of builtFeatures) { + if (feature?.type !== 'extrude') continue; + const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : []; + for (const profileTarget of profiles) { + const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget); + if (!sketchId || !profileId) continue; + const loops = profileLoopsFromRuntime(api, profileTarget); + if (!loops?.length) continue; + profileLoops[key] = loops; + } + } + return { builtFeatures, sketchPlanes, profileLoops }; + } + + function meshCacheFromWorkerPayload(payloadMeshes = []) { + const map = new Map(); + for (const rec of payloadMeshes || []) { + const id = rec?.id; + if (!id) continue; + const positions = rec.positions instanceof Float32Array + ? rec.positions + : new Float32Array(rec.positions || []); + const indices = rec.indices instanceof Uint32Array + ? rec.indices + : new Uint32Array(rec.indices || []); + if (!positions.length || !indices.length) continue; + map.set(id, { positions, indices }); + } + return map; + } + + function isObjectEffectivelyVisible(obj) { + let node = obj; + while (node) { + if (node.visible === false) return false; + node = node.parent; + } + return true; + } + + function flattenMeshToTriangleVertexArray(meshData) { + const positions = meshData?.positions; + const indices = meshData?.indices; + if (!positions?.length || !indices?.length) return new Float32Array(0); + const out = new Float32Array(indices.length * 3); + let oi = 0; + for (let i = 0; i < indices.length; i++) { + const vi = indices[i] * 3; + out[oi++] = positions[vi]; + out[oi++] = positions[vi + 1]; + out[oi++] = positions[vi + 2]; + } + return out; + } + + function buildSolidGeometry(meshData) { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.Float32BufferAttribute(meshData.positions, 3)); + geometry.setIndex(new THREE.BufferAttribute(meshData.indices, 1)); + const indexed = geometry.clone(); + if (BufferGeometryUtils?.toCreasedNormals) { + // Keep hard CAD-like edges while preserving smooth shading where faces are near-coplanar. + const creased = BufferGeometryUtils.toCreasedNormals(geometry, Math.PI / 3); + geometry.dispose(); + return { render: creased, indexed }; + } + geometry.computeVertexNormals(); + return { render: geometry, indexed }; + } + + function vec3FromPos(posArray, index, out = new THREE.Vector3()) { + const i = index * 3; + out.set(posArray[i], posArray[i + 1], posArray[i + 2]); + return out; + } + +function edgeKey(a, b) { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + + function distancePointToSegmentSquared(p, a, b) { + const ab = new THREE.Vector3().subVectors(b, a); + const ap = new THREE.Vector3().subVectors(p, a); + const abLenSq = ab.lengthSq(); + if (abLenSq <= 1e-18) return p.distanceToSquared(a); + let t = ap.dot(ab) / abLenSq; + t = Math.max(0, Math.min(1, t)); + const proj = a.clone().addScaledVector(ab, t); + return p.distanceToSquared(proj); + } + + function buildSurfaceRegionData(geometry) { + const posAttr = geometry?.getAttribute?.('position'); + const idxAttr = geometry?.getIndex?.(); + if (!posAttr) { + return { triToGroup: new Int32Array(0), groups: new Map() }; + } + const positions = posAttr.array; + const vertCount = Math.floor(positions.length / 3); + const indices = idxAttr?.array || Uint32Array.from(Array.from({ length: vertCount }, (_, i) => i)); + const triCount = Math.floor(indices.length / 3); + if (!triCount) { + return { triToGroup: new Int32Array(0), groups: new Map() }; + } + + const triNormals = new Float32Array(triCount * 3); + const triDs = new Float32Array(triCount); + const triNeighbors = Array.from({ length: triCount }, () => new Set()); + const triToGroup = new Int32Array(triCount).fill(-1); + const edgeMap = new Map(); + const posPointId = new Map(); + const pointIdByIndex = new Map(); + let pointSeq = 0; + const quant = 1e6; + const pointIdForIndex = (vi) => { + const cached = pointIdByIndex.get(vi); + if (cached !== undefined) return cached; + const p = vi * 3; + const kx = Math.round(positions[p] * quant); + const ky = Math.round(positions[p + 1] * quant); + const kz = Math.round(positions[p + 2] * quant); + const key = `${kx},${ky},${kz}`; + let pid = posPointId.get(key); + if (pid === undefined) { + pid = pointSeq++; + posPointId.set(key, pid); + } + pointIdByIndex.set(vi, pid); + return pid; + }; + const tmpA = new THREE.Vector3(); + const tmpB = new THREE.Vector3(); + const tmpC = new THREE.Vector3(); + const tmpAB = new THREE.Vector3(); + const tmpAC = new THREE.Vector3(); + const tmpN = new THREE.Vector3(); + + for (let t = 0; t < triCount; t++) { + const i0 = indices[t * 3]; + const i1 = indices[t * 3 + 1]; + const i2 = indices[t * 3 + 2]; + vec3FromPos(positions, i0, tmpA); + vec3FromPos(positions, i1, tmpB); + vec3FromPos(positions, i2, tmpC); + tmpAB.subVectors(tmpB, tmpA); + tmpAC.subVectors(tmpC, tmpA); + tmpN.crossVectors(tmpAB, tmpAC); + if (tmpN.lengthSq() > 0) tmpN.normalize(); + triNormals[t * 3] = tmpN.x; + triNormals[t * 3 + 1] = tmpN.y; + triNormals[t * 3 + 2] = tmpN.z; + triDs[t] = tmpN.dot(tmpA); + + const p0 = pointIdForIndex(i0); + const p1 = pointIdForIndex(i1); + const p2 = pointIdForIndex(i2); + const edges = [[p0, p1], [p1, p2], [p2, p0]]; + for (const [ea, eb] of edges) { + const ek = edgeKey(ea, eb); + const list = edgeMap.get(ek); + if (list) list.push(t); + else edgeMap.set(ek, [t]); + } + } + + for (const list of edgeMap.values()) { + if (list.length < 2) continue; + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + triNeighbors[list[i]].add(list[j]); + triNeighbors[list[j]].add(list[i]); + } + } + } + + // Must match EdgesGeometry threshold so selectable regions align with drawn boundaries. + const smoothJoinDot = Math.cos(SOLID_CREASE_ANGLE_DEG * Math.PI / 180); + const groups = new Map(); + let groupId = 0; + + for (let t = 0; t < triCount; t++) { + if (triToGroup[t] >= 0) continue; + const queue = [t]; + const tris = []; + triToGroup[t] = groupId; + + while (queue.length) { + const cur = queue.pop(); + tris.push(cur); + for (const nb of triNeighbors[cur]) { + if (triToGroup[nb] >= 0) continue; + const cNx = triNormals[cur * 3]; + const cNy = triNormals[cur * 3 + 1]; + const cNz = triNormals[cur * 3 + 2]; + const nNx = triNormals[nb * 3]; + const nNy = triNormals[nb * 3 + 1]; + const nNz = triNormals[nb * 3 + 2]; + const dot = cNx * nNx + cNy * nNy + cNz * nNz; + if (dot < smoothJoinDot) continue; + triToGroup[nb] = groupId; + queue.push(nb); + } + } + + const groupIndices = []; + const vertexSet = new Set(); + let xAxis = new THREE.Vector3(1, 0, 0); + const avgNormal = new THREE.Vector3(); + for (const tri of tris) { + const i0 = indices[tri * 3]; + const i1 = indices[tri * 3 + 1]; + const i2 = indices[tri * 3 + 2]; + groupIndices.push(i0, i1, i2); + vertexSet.add(i0); + vertexSet.add(i1); + vertexSet.add(i2); + if (xAxis.lengthSq() <= 1e-8) { + vec3FromPos(positions, i0, tmpA); + vec3FromPos(positions, i1, tmpB); + xAxis = tmpB.sub(tmpA); + } + avgNormal.x += triNormals[tri * 3]; + avgNormal.y += triNormals[tri * 3 + 1]; + avgNormal.z += triNormals[tri * 3 + 2]; + } + const center = new THREE.Vector3(); + if (vertexSet.size) { + for (const vi of vertexSet) { + vec3FromPos(positions, vi, tmpA); + center.add(tmpA); + } + center.multiplyScalar(1 / vertexSet.size); + } + const normal = avgNormal.lengthSq() > 1e-12 ? avgNormal.normalize() : new THREE.Vector3(0, 0, 1); + const xDotN = xAxis.dot(normal); + xAxis = xAxis.sub(normal.clone().multiplyScalar(xDotN)); + if (xAxis.lengthSq() <= 1e-8) { + xAxis = Math.abs(normal.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0); + xAxis.sub(normal.clone().multiplyScalar(xAxis.dot(normal))); + } + xAxis.normalize(); + const planeD = normal.dot(center); + let maxPlanarError = 0; + let minDot = 1; + for (const vi of vertexSet) { + vec3FromPos(positions, vi, tmpA); + const dErr = Math.abs(normal.dot(tmpA) - planeD); + if (dErr > maxPlanarError) maxPlanarError = dErr; + } + for (const tri of tris) { + const nx = triNormals[tri * 3]; + const ny = triNormals[tri * 3 + 1]; + const nz = triNormals[tri * 3 + 2]; + const dot = nx * normal.x + ny * normal.y + nz * normal.z; + if (dot < minDot) minDot = dot; + } + const planar = maxPlanarError < 1e-4 && minDot > (1 - 1e-4); + + const faceGeom = new THREE.BufferGeometry(); + faceGeom.setAttribute('position', posAttr.clone()); + faceGeom.setIndex(new THREE.BufferAttribute(Uint32Array.from(groupIndices), 1)); + + groups.set(groupId, { + id: groupId, + geometry: faceGeom, + center, + normal, + xAxis, + planar, + boundarySegmentsLocal: null, + boundaryLoopsLocal: null + }); + groupId++; + } + return { triToGroup, groups }; + } + + function buildBoundarySegmentsFromGeometry(geometry) { + if (!geometry) return []; + // Keep boundary extraction aligned with rendered edge topology. + // Using the same crease threshold avoids partial/extra loop artifacts. + const edgesGeom = new THREE.EdgesGeometry(geometry, SOLID_CREASE_ANGLE_DEG); + const pos = edgesGeom.getAttribute?.('position'); + if (!pos) return []; + const out = []; + for (let i = 0; i + 1 < pos.count; i += 2) { + const a = new THREE.Vector3().fromBufferAttribute(pos, i); + const b = new THREE.Vector3().fromBufferAttribute(pos, i + 1); + out.push({ a, b, mid: a.clone().add(b).multiplyScalar(0.5) }); + } + edgesGeom.dispose?.(); + return out; + } + + function buildBoundaryLoopsFromSegments(segments = []) { + if (!Array.isArray(segments) || !segments.length) return []; + const quant = 1e6; + const nodeKey = (v) => `${Math.round(Number(v?.x || 0) * quant)},${Math.round(Number(v?.y || 0) * quant)},${Math.round(Number(v?.z || 0) * quant)}`; + const nodePos = new Map(); + const nodeEdges = new Map(); + const edgeNodes = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (!seg?.a || !seg?.b) continue; + const ka = nodeKey(seg.a); + const kb = nodeKey(seg.b); + edgeNodes[i] = [ka, kb]; + if (!nodePos.has(ka)) nodePos.set(ka, seg.a.clone()); + if (!nodePos.has(kb)) nodePos.set(kb, seg.b.clone()); + if (!nodeEdges.has(ka)) nodeEdges.set(ka, []); + if (!nodeEdges.has(kb)) nodeEdges.set(kb, []); + nodeEdges.get(ka).push(i); + nodeEdges.get(kb).push(i); + } + const used = new Set(); + const loops = []; + for (let i = 0; i < segments.length; i++) { + if (used.has(i) || !edgeNodes[i]) continue; + let [startNode, nextNode] = edgeNodes[i]; + const segIndices = [i]; + const points = [nodePos.get(startNode)?.clone(), nodePos.get(nextNode)?.clone()].filter(Boolean); + used.add(i); + let prevEdge = i; + let closed = false; + for (let guard = 0; guard < segments.length + 4; guard++) { + if (nextNode === startNode) { + closed = true; + break; + } + const options = (nodeEdges.get(nextNode) || []).filter(edgeIndex => !used.has(edgeIndex) && edgeIndex !== prevEdge); + if (!options.length) break; + const edgeIndex = options[0]; + const pair = edgeNodes[edgeIndex]; + if (!pair) break; + const [a, b] = pair; + const newNode = a === nextNode ? b : a; + used.add(edgeIndex); + segIndices.push(edgeIndex); + const p = nodePos.get(newNode); + if (p) points.push(p.clone()); + prevEdge = edgeIndex; + nextNode = newNode; + } + if (points.length >= 2) { + loops.push({ segmentIndices: segIndices, points, closed: closed && points.length >= 4 }); + } + } + return loops; + } + + function shouldPromoteLoopSelection(loop, minSegments = 10) { + const segCount = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices.length : 0; + const threshold = Math.max(3, Number(minSegments) || 10); + const pts = Array.isArray(loop?.points) ? loop.points : []; + if (pts.length < 4) return false; + const closed = !!loop?.closed; + if (!closed) return false; + + let maxTurnDeg = 0; + let sharpTurnCount = 0; + const count = pts.length; + for (let i = 0; i < count; i++) { + const p0 = pts[(i - 1 + count) % count]; + const p1 = pts[i]; + const p2 = pts[(i + 1) % count]; + if (!p0 || !p1 || !p2) continue; + const e1 = new THREE.Vector3().subVectors(p1, p0); + const e2 = new THREE.Vector3().subVectors(p2, p1); + if (e1.lengthSq() <= 1e-16 || e2.lengthSq() <= 1e-16) continue; + e1.normalize(); + e2.normalize(); + const dot = Math.max(-1, Math.min(1, e1.dot(e2))); + const turnDeg = Math.acos(dot) * 180 / Math.PI; + if (turnDeg > maxTurnDeg) maxTurnDeg = turnDeg; + if (turnDeg > 85) sharpTurnCount++; + } + + // Strong circle-like detection: points at roughly constant radius from centroid. + // This should promote cylinder cap rings even when user tuning raises segment threshold. + // Guard with turn-angle smoothness so sharp-corner polygons (square/hex) remain segment-pickable. + const center = new THREE.Vector3(); + for (const p of pts) center.add(p); + center.multiplyScalar(1 / pts.length); + let sumR = 0; + const radii = []; + for (const p of pts) { + const r = p.distanceTo(center); + radii.push(r); + sumR += r; + } + const meanR = sumR / Math.max(1, radii.length); + if (meanR > 1e-8) { + let varR = 0; + for (const r of radii) { + const d = r - meanR; + varR += d * d; + } + const sigmaR = Math.sqrt(varR / Math.max(1, radii.length)); + const rel = sigmaR / meanR; + if (segCount >= 5 && rel <= 0.08 && maxTurnDeg <= 55) { + return true; + } + } + + if (segCount < threshold) return false; + + // Promote only "smooth" dense loops. Mixed straight/curved boundaries + // (with sharp corners) should remain segment-selectable. + if (sharpTurnCount >= 3) return false; + return maxTurnDeg <= 80; + } + + function buildSmoothChainFromLoop(loop, anchorSegIndex) { + const segIndices = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + const pts = Array.isArray(loop?.points) ? loop.points : []; + const closed = !!loop?.closed; + const n = segIndices.length; + if (!n || pts.length < 3) return null; + const anchorPos = segIndices.indexOf(Number(anchorSegIndex)); + if (anchorPos < 0) return null; + + const segLen = new Array(n).fill(0).map((_, i) => { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + return (a && b && a.distanceToSquared) ? Math.sqrt(a.distanceToSquared(b)) : 0; + }); + const dirAt = (i) => { + const a = pts[i]; + const b = pts[(i + 1) % pts.length]; + if (!a || !b) return null; + const d = new THREE.Vector3().subVectors(b, a); + const len = d.length(); + if (len <= 1e-9) return null; + return d.multiplyScalar(1 / len); + }; + const angleBetweenSegs = (i, j) => { + const di = dirAt(i); + const dj = dirAt(j); + if (!di || !dj) return 180; + const dotv = Math.max(-1, Math.min(1, di.dot(dj))); + return Math.acos(dotv) * 180 / Math.PI; + }; + + const nextIndex = (i, step) => { + if (closed) return (i + step + n) % n; + const v = i + step; + return (v < 0 || v >= n) ? null : v; + }; + + let start = anchorPos; + let end = anchorPos; + let typical = Math.max(1e-9, segLen[anchorPos] || 1); + let count = 1; + const maxExpand = closed ? n - 1 : n; + + const canGrow = (from, cand) => { + if (cand === null) return false; + const l1 = Math.max(1e-9, segLen[from] || 1e-9); + const l2 = Math.max(1e-9, segLen[cand] || 1e-9); + const ratio = Math.max(l1, l2, typical) / Math.max(1e-9, Math.min(l1, l2, typical)); + if (ratio > 2.5) return false; + const turn = angleBetweenSegs(from, cand); + return turn <= 55; + }; + + for (let guard = 0; guard < maxExpand; guard++) { + const cand = nextIndex(start, -1); + if (!canGrow(cand, start)) break; + start = cand; + typical = (typical * count + Math.max(1e-9, segLen[start] || 1e-9)) / (count + 1); + count++; + } + for (let guard = 0; guard < maxExpand; guard++) { + const cand = nextIndex(end, +1); + if (!canGrow(end, cand)) break; + end = cand; + typical = (typical * count + Math.max(1e-9, segLen[end] || 1e-9)) / (count + 1); + count++; + if (closed && nextIndex(end, +1) === start) break; + } + + if (count < 2) return null; + if (closed && count >= n - 1) return null; + + const path = []; + let i = start; + path.push(pts[i]?.clone?.() || null); + for (let guard = 0; guard < n + 2; guard++) { + const ni = nextIndex(i, +1); + if (ni === null) break; + path.push(pts[ni]?.clone?.() || null); + if (i === end) break; + i = ni; + if (i === start) break; + } + const clean = path.filter(Boolean); + if (clean.length < 2) return null; + return { + startPos: start, + endPos: end, + startSegIndex: Number(segIndices[start]), + endSegIndex: Number(segIndices[end]), + pathWorld: clean + }; + } + + function makeFaceMaterials() { + return { + hover: new THREE.MeshBasicMaterial({ + color: 0xffa347, + transparent: true, + opacity: 0.26, + side: THREE.DoubleSide, + depthTest: false, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1 + }), + selected: new THREE.MeshBasicMaterial({ + color: 0xffa347, + transparent: true, + opacity: 0.34, + side: THREE.DoubleSide, + depthTest: false, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1 + }) + }; + } + + function quantPointKey(v) { + const q = 1e6; + const x = Math.round(Number(v?.x || 0) * q); + const y = Math.round(Number(v?.y || 0) * q); + const z = Math.round(Number(v?.z || 0) * q); + return `${x}:${y}:${z}`; + } + + return { + _rebuildTimer: null, + _rebuilding: false, + _pendingReason: null, + _meshCache: new Map(), + _meshViews: new Map(), + _selectedIds: new Set(), + _hoveredIds: new Set(), + _root: null, + _material: null, + _edgeMaterial: null, + _selectedFaceKeys: new Set(), + _hoveredFaceKey: null, + _selectedEdgeKeys: new Set(), + _hoveredEdgeKey: null, + _renderPrefs: { + edgeLoopPromotionSegments: 10, + edgeHoverLineWidth: 2.5, + edgeSelectedLineWidth: 3.25 + }, + _faceMats: null, + _worker: null, + _workerReady: false, + _workerReqId: 0, + _workerPending: new Map(), + _rebuildSeq: 0, + _geomSurfaceIdByFaceKey: new Map(), + _geomSegmentIdByEdgeKey: new Map(), + _geomBoundaryIdByLoopKey: new Map(), + _edgeKeyByGeomSegmentId: new Map(), + _loopKeyByGeomBoundaryId: new Map(), + _frozenChamferEdges: null, + _frozenEdgeOverlays: null, + + async init() { + await ensureKernel(); + if (!this._material) { + this._material = new THREE.MeshPhongMaterial({ + color: 0x8d939a, + shininess: 28, + transparent: true, + opacity: 1, + side: THREE.DoubleSide + }); + this._edgeMaterial = new THREE.LineBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.22 + }); + } + if (!this._faceMats) { + this._faceMats = makeFaceMaterials(); + } + this.ensureWorker(); + }, + + ensureWorker() { + if (this._worker) return this._worker; + try { + const worker = new Worker(new URL('../worker/solids_worker.js', import.meta.url), { type: 'module' }); + worker.onmessage = (event) => { + const msg = event?.data || {}; + const req = this._workerPending.get(msg?.id); + if (!req) return; + this._workerPending.delete(msg.id); + if (msg?.ok) req.resolve(msg); + else req.reject(new Error(msg?.error || 'worker rebuild failed')); + }; + worker.onerror = (error) => { + for (const req of this._workerPending.values()) { + req.reject(error instanceof Error ? error : new Error(String(error))); + } + this._workerPending.clear(); + this._worker = null; + this._workerReady = false; + }; + this._worker = worker; + this._workerReady = true; + } catch (error) { + this._worker = null; + this._workerReady = false; + } + return this._worker; + }, + + requestWorkerRebuild(snapshot, reason = 'worker') { + const worker = this.ensureWorker(); + if (!worker) return Promise.reject(new Error('worker unavailable')); + const id = ++this._workerReqId; + return new Promise((resolve, reject) => { + this._workerPending.set(id, { resolve, reject }); + worker.postMessage({ + id, + type: 'rebuild', + reason, + snapshot + }); + }); + }, + + attach(world) { + if (this._root) return; + this._root = new THREE.Group(); + this._root.name = 'void-solids-runtime'; + this._frozenEdgeOverlays = new THREE.Group(); + this._frozenEdgeOverlays.name = 'void-solids-frozen-edge-overlays'; + this._root.add(this._frozenEdgeOverlays); + world?.add?.(this._root); + }, + + list() { + const api = getApi(); + return Array.isArray(api.document.current?.generated?.solids) + ? api.document.current.generated.solids + : []; + }, + + buildGeometryStoreSnapshot() { + this._geomSurfaceIdByFaceKey = new Map(); + this._geomSegmentIdByEdgeKey = new Map(); + this._geomBoundaryIdByLoopKey = new Map(); + this._edgeKeyByGeomSegmentId = new Map(); + this._loopKeyByGeomBoundaryId = new Map(); + const surfaces = []; + const boundaries = []; + const segments = []; + const points = []; + const regions = []; + const pointIdByKey = new Map(); + const topology = { + surface_to_segments: {}, + segment_to_surfaces: {} + }; + + const getPointId = (p, role = 'boundary-vertex') => { + const key = quantPointKey(p); + let pid = pointIdByKey.get(key); + if (!pid) { + pid = `point:${pointIdByKey.size}`; + pointIdByKey.set(key, pid); + points.push({ + id: pid, + x: Number(p?.x || 0), + y: Number(p?.y || 0), + z: Number(p?.z || 0), + role + }); + } + return pid; + }; + + for (const [solidId, view] of this._meshViews.entries()) { + if (!view?.faceGroups?.size) continue; + for (const [faceId, meta] of view.faceGroups.entries()) { + const faceKey = `${solidId}:${faceId}`; + const surfaceId = `surface:${faceKey}`; + const loops = this.getFaceBoundaryLoops(faceKey) || []; + const normal = meta?.normal || {}; + const center = meta?.center || {}; + surfaces.push({ + id: surfaceId, + solid_id: solidId, + face_id: faceId, + type: meta?.planar ? 'planar' : 'curved', + center: { + x: Number(center.x || 0), + y: Number(center.y || 0), + z: Number(center.z || 0) + }, + normal: { + x: Number(normal.x || 0), + y: Number(normal.y || 0), + z: Number(normal.z || 1) + }, + source: { + type: 'solid-face', + face_key: faceKey + } + }); + this._geomSurfaceIdByFaceKey.set(faceKey, surfaceId); + + const surfaceSegmentIds = []; + for (let li = 0; li < loops.length; li++) { + const loop = loops[li]; + let loopPoints = Array.isArray(loop?.points) ? loop.points.slice() : []; + if (loopPoints.length < 2) continue; + const closed = !!loop?.closed; + if (closed && loopPoints.length >= 3) { + const first = loopPoints[0]; + const last = loopPoints[loopPoints.length - 1]; + if (first && last && first.distanceToSquared?.(last) <= 1e-16) { + loopPoints = loopPoints.slice(0, -1); + } + } + if (loopPoints.length < 2) continue; + + const boundaryId = `boundary:${faceKey}:${li}`; + this._geomBoundaryIdByLoopKey.set(`faceedgeloop:${solidId}:${faceId}:${li}`, boundaryId); + this._loopKeyByGeomBoundaryId.set(boundaryId, `faceedgeloop:${solidId}:${faceId}:${li}`); + const boundarySegmentIds = []; + const stepCount = closed ? loopPoints.length : (loopPoints.length - 1); + for (let si = 0; si < stepCount; si++) { + const a = loopPoints[si]; + const b = loopPoints[(si + 1) % loopPoints.length]; + if (!a || !b) continue; + if (a.distanceToSquared?.(b) <= 1e-16) continue; + const segmentId = `segment:${faceKey}:${li}:${si}`; + const aId = getPointId(a, 'boundary-vertex'); + const bId = getPointId(b, 'boundary-vertex'); + const mid = a.clone().add(b).multiplyScalar(0.5); + const midId = getPointId(mid, 'boundary-midpoint'); + segments.push({ + id: segmentId, + boundary_id: boundaryId, + kind: 'line', + a: { x: Number(a.x || 0), y: Number(a.y || 0), z: Number(a.z || 0) }, + b: { x: Number(b.x || 0), y: Number(b.y || 0), z: Number(b.z || 0) }, + mid: { x: Number(mid.x || 0), y: Number(mid.y || 0), z: Number(mid.z || 0) }, + point_ids: [aId, bId, midId], + source: { + type: 'solid-edge', + edge_key: `faceedge:${faceKey}:${Number(loop?.segmentIndices?.[si] ?? si)}` + } + }); + this._geomSegmentIdByEdgeKey.set(`faceedge:${faceKey}:${Number(loop?.segmentIndices?.[si] ?? si)}`, segmentId); + this._edgeKeyByGeomSegmentId.set(segmentId, `faceedge:${faceKey}:${Number(loop?.segmentIndices?.[si] ?? si)}`); + boundarySegmentIds.push(segmentId); + surfaceSegmentIds.push(segmentId); + topology.segment_to_surfaces[segmentId] = [surfaceId]; + } + boundaries.push({ + id: boundaryId, + surface_id: surfaceId, + segment_ids: boundarySegmentIds, + closed, + source: { + type: 'solid-face-loop', + face_key: faceKey, + loop_index: li + } + }); + regions.push({ + id: `region:${faceKey}:${li}`, + surface_id: surfaceId, + boundary_ids: [boundaryId], + source: { + type: 'surface-loop-region', + face_key: faceKey, + loop_index: li + } + }); + } + topology.surface_to_segments[surfaceId] = surfaceSegmentIds; + } + } + return { + surfaces, + boundaries, + segments, + points, + regions, + topology, + meta: { + feature_count: Number(getApi()?.features?.list?.()?.length || 0) + } + }; + }, + + getSolidDependencySignature(solidId) { + const id = String(solidId || ''); + if (!id) return null; + const meshData = this._meshCache?.get?.(id); + const pos = meshData?.positions; + const idx = meshData?.indices; + if (!pos?.length || !idx?.length) return null; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + for (let i = 0; i < pos.length; i += 3) { + const x = pos[i]; + const y = pos[i + 1]; + const z = pos[i + 2]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (z < minZ) minZ = z; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + if (z > maxZ) maxZ = z; + } + const q = v => Math.round(Number(v || 0) * 1000) / 1000; + return [ + pos.length, + idx.length, + q(minX), q(minY), q(minZ), + q(maxX), q(maxY), q(maxZ) + ].join('|'); + }, + + getExportRecords(ids = []) { + const requested = Array.isArray(ids) ? ids.filter(Boolean) : []; + const wanted = requested.length ? new Set(requested) : null; + const solids = this.list(); + const out = []; + for (const solid of solids) { + const id = solid?.id; + if (!id) continue; + if (wanted && !wanted.has(id)) continue; + const meshData = this._meshCache.get(id); + if (!meshData) continue; + const varr = flattenMeshToTriangleVertexArray(meshData); + if (!varr.length) continue; + out.push({ + id, + file: String(solid?.name || `solid-${id}`), + varr + }); + } + return out; + }, + + setSelected(ids = []) { + this._selectedIds = new Set(ids || []); + this.syncRuntime(); + }, + + setHovered(ids = []) { + this._hoveredIds = new Set(ids || []); + this.syncRuntime(); + }, + + syncRuntime() { + if (!this._root) return; + const solids = this.list(); + const byId = new Set(solids.map(s => s?.id).filter(Boolean)); + + for (const [id, view] of this._meshViews.entries()) { + if (byId.has(id)) continue; + this._root.remove(view.group); + view.mesh.geometry?.dispose?.(); + view.indexedGeometry?.dispose?.(); + view.mesh.material?.dispose?.(); + view.edges.geometry?.dispose?.(); + view.edges.material?.dispose?.(); + for (const overlay of view.faceOverlays?.values?.() || []) { + overlay.geometry?.dispose?.(); + } + while (view.edgeOverlays?.children?.length) { + const child = view.edgeOverlays.children[0]; + child.geometry?.dispose?.(); + child.material?.dispose?.(); + view.edgeOverlays.remove(child); + } + this._meshViews.delete(id); + } + + for (const solid of solids) { + const id = solid?.id; + if (!id) continue; + const meshData = this._meshCache.get(id); + const visible = solid?.visible !== false; + if (!meshData) { + const stale = this._meshViews.get(id); + if (stale) { + stale.group.visible = false; + } + continue; + } + let view = this._meshViews.get(id); + if (!view) { + const built = buildSolidGeometry(meshData); + const mesh = new THREE.Mesh(built.render, this._material.clone()); + mesh.userData.solidId = id; + mesh.userData.solid = true; + const edgesGeom = new THREE.EdgesGeometry(built.render, SOLID_CREASE_ANGLE_DEG); + const edges = new THREE.LineSegments(edgesGeom, this._edgeMaterial.clone()); + edges.userData.solidId = id; + edges.userData.solidEdge = true; + // Edge picking needs a small tolerance bump over global line picks. + const baseRaycast = edges.raycast.bind(edges); + edges.raycast = function(raycaster, intersects) { + const prev = Number(raycaster?.params?.Line?.threshold || 0); + if (raycaster?.params?.Line) { + raycaster.params.Line.threshold = Math.max(prev, 1); + } + baseRaycast(raycaster, intersects); + if (raycaster?.params?.Line) { + raycaster.params.Line.threshold = prev; + } + }; + const overlays = new THREE.Group(); + overlays.name = `solid-${id}-face-overlays`; + const edgeOverlays = new THREE.Group(); + edgeOverlays.name = `solid-${id}-edge-overlays`; + const group = new THREE.Group(); + group.name = `solid-${id}`; + group.add(mesh); + group.add(edges); + group.add(overlays); + group.add(edgeOverlays); + this._root.add(group); + view = { group, mesh, edges, overlays, edgeOverlays, faceOverlays: new Map(), faceTriToGroup: new Int32Array(0), faceGroups: new Map(), indexedGeometry: built.indexed }; + this._meshViews.set(id, view); + } else { + // Always replace geometry on rebuild. Topology counts can stay + // constant while positions change (depth/direction/symmetric). + view.mesh.geometry?.dispose?.(); + view.indexedGeometry?.dispose?.(); + view.edges.geometry?.dispose?.(); + for (const overlay of view.faceOverlays?.values?.() || []) { + overlay.geometry?.dispose?.(); + } + view.faceOverlays?.clear?.(); + while (view.overlays?.children?.length) { + view.overlays.remove(view.overlays.children[0]); + } + while (view.edgeOverlays?.children?.length) { + const child = view.edgeOverlays.children[0]; + child.geometry?.dispose?.(); + child.material?.dispose?.(); + view.edgeOverlays.remove(child); + } + const built = buildSolidGeometry(meshData); + view.mesh.geometry = built.render; + view.indexedGeometry = built.indexed; + view.edges.geometry = new THREE.EdgesGeometry(built.render, SOLID_CREASE_ANGLE_DEG); + } + // Build selectable face regions from the same geometry used for ray hits. + // This keeps surface-region hover/selection aligned with rendered shading. + const faceData = buildSurfaceRegionData(view.mesh.geometry || view.indexedGeometry); + view.faceTriToGroup = faceData.triToGroup; + view.faceGroups = faceData.groups; + for (const [faceId, face] of faceData.groups.entries()) { + const mesh = new THREE.Mesh(face.geometry, this._faceMats.hover); + mesh.visible = false; + mesh.renderOrder = 40; + mesh.userData.solidFaceOverlay = true; + view.overlays.add(mesh); + view.faceOverlays.set(faceId, mesh); + } + const selected = this._selectedIds.has(id); + const hovered = this._hoveredIds.has(id); + if (view.mesh.material?.color) { + view.mesh.material.color.setHex(selected ? 0xa0b7d1 : (hovered ? 0x97a8b8 : 0x8d939a)); + } + if (view.edges.material?.opacity !== undefined) { + view.edges.material.opacity = selected ? 0.9 : (hovered ? 0.55 : 0.22); + } + view.group.visible = visible; + } + this._selectedFaceKeys = new Set(Array.from(this._selectedFaceKeys).filter(key => this.getFaceByKey(key))); + if (this._hoveredFaceKey && !this.getFaceByKey(this._hoveredFaceKey)) { + this._hoveredFaceKey = null; + } + this._selectedEdgeKeys = new Set(Array.from(this._selectedEdgeKeys).filter(key => this.getEdgeByKey(key))); + if (this._hoveredEdgeKey && !this.getEdgeByKey(this._hoveredEdgeKey)) { + this._hoveredEdgeKey = null; + } + this.syncFaceOverlays(); + this.syncEdgeOverlays(); + const api = getApi(); + const doc = api?.document?.current || null; + if (doc) { + const snapshot = this.buildGeometryStoreSnapshot(); + api.geometryStore?.applySolidSnapshot?.(doc, snapshot); + } + }, + + getPickMeshes() { + const out = []; + for (const view of this._meshViews.values()) { + if (view?.group?.visible !== false && view?.mesh?.visible !== false) { + out.push(view.mesh); + } + } + return out; + }, + + getPickEdges() { + const out = []; + for (const view of this._meshViews.values()) { + if (view?.group?.visible === false) continue; + if (view?.edges?.visible === false) continue; + if (view?.mesh?.visible === false) continue; + out.push(view.edges); + } + return out; + }, + + getPickEdgeForSolid(solidId) { + const id = String(solidId || ''); + if (!id) return null; + const view = this._meshViews.get(id); + if (!view || view?.group?.visible === false || view?.mesh?.visible === false || view?.edges?.visible === false) { + return null; + } + return view.edges || null; + }, + + getEdgeSegmentWorld(object, segmentIndex) { + if (!object?.geometry || segmentIndex < 0) return null; + object.updateMatrixWorld?.(true); + const pos = object.geometry.getAttribute?.('position'); + if (!pos) return null; + const idx = object.geometry.getIndex?.(); + const ai = segmentIndex * 2; + const bi = ai + 1; + let ia = ai; + let ib = bi; + if (idx?.array?.length) { + if (bi >= idx.array.length) return null; + ia = idx.array[ai]; + ib = idx.array[bi]; + } else if (bi >= pos.count) { + return null; + } + const a = new THREE.Vector3().fromBufferAttribute(pos, ia).applyMatrix4(object.matrixWorld); + const b = new THREE.Vector3().fromBufferAttribute(pos, ib).applyMatrix4(object.matrixWorld); + return { a, b }; + }, + + getEdgeHitFromIntersections(intersections = []) { + if (!Array.isArray(intersections)) return null; + for (const hit of intersections) { + const object = hit?.object; + if (!object || !isObjectEffectivelyVisible(object)) continue; + if (object?.userData?.solidEdge !== true) continue; + const solidId = String(object?.userData?.solidId || ''); + if (!solidId) continue; + + let segIndex = Number(hit?.index); + let seg = Number.isFinite(segIndex) ? this.getEdgeSegmentWorld(object, segIndex) : null; + + // Some line raycast paths do not provide a stable segment index. + // Resolve by nearest world-space segment to the reported hit point. + if (!seg && hit?.point) { + const pos = object.geometry?.getAttribute?.('position'); + const idx = object.geometry?.getIndex?.(); + const segCount = idx?.array?.length + ? Math.floor(idx.array.length / 2) + : Math.floor((pos?.count || 0) / 2); + let bestI = -1; + let bestD2 = Infinity; + for (let i = 0; i < segCount; i++) { + const cand = this.getEdgeSegmentWorld(object, i); + if (!cand) continue; + const d2 = distancePointToSegmentSquared(hit.point, cand.a, cand.b); + if (d2 < bestD2) { + bestD2 = d2; + bestI = i; + seg = cand; + } + } + if (bestI >= 0) { + segIndex = bestI; + } + } + + if (!seg) continue; + const mid = seg.a.clone().add(seg.b).multiplyScalar(0.5); + return { + solidId, + index: segIndex, + aWorld: seg.a, + bWorld: seg.b, + midWorld: mid, + intersection: hit + }; + } + return null; + }, + + getEdgeByKey(key) { + const raw = String(key || ''); + const frozen = this._frozenChamferEdges; + if (frozen) { + const exact = frozen.byKey?.get?.(raw) || null; + if (exact) return exact; + if (raw.startsWith('segment:')) { + const mapped = frozen.geomSegToEdgeKey?.get?.(raw) || null; + if (mapped) { + return frozen.byKey?.get?.(mapped) || null; + } + if (raw.startsWith('segment:faceedge:') || raw.startsWith('segment:faceedgeloop:')) { + const ek = raw.substring('segment:'.length); + return frozen.byKey?.get?.(ek) || null; + } + } + if (raw.startsWith('boundary:')) { + const mapped = frozen.geomBoundaryToEdgeKey?.get?.(raw) || null; + if (mapped) { + return frozen.byKey?.get?.(mapped) || null; + } + } + // In frozen chamfer mode, never fall through to live topology lookup. + return null; + } + if (raw.startsWith('faceedgeloop:')) { + const parts = raw.split(':'); + if (parts.length < 4) return null; + const loopIndex = Number(parts[parts.length - 1]); + const faceId = Number(parts[parts.length - 2]); + const solidId = parts.slice(1, -2).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex)) return null; + const loops = this.getFaceBoundaryLoops(`${solidId}:${faceId}`) || []; + const loop = loops[loopIndex]; + if (!loop?.points?.length) return null; + const pathWorld = loop.points.map(p => p.clone()); + if (loop.closed && pathWorld.length >= 2) { + const first = pathWorld[0]; + const last = pathWorld[pathWorld.length - 1]; + if (first.distanceToSquared(last) > 1e-16) { + pathWorld.push(first.clone()); + } + } + const meshEdgeKeys = []; + for (let i = 0; i + 1 < pathWorld.length; i++) { + const mk = this.getNearestMeshEdgeKeyForWorldSegment(solidId, pathWorld[i], pathWorld[i + 1]); + if (mk && !meshEdgeKeys.includes(mk)) meshEdgeKeys.push(mk); + } + const segIndex = Number(loop.segmentIndices?.[0]); + return { + key: raw, + solidId, + faceId, + index: Number.isFinite(segIndex) ? segIndex : null, + pathWorld, + loop: true, + meshEdgeKey: meshEdgeKeys[0] || null, + meshEdgeKeys + }; + } + if (raw.startsWith('faceedgechain:')) { + const parts = raw.split(':'); + if (parts.length < 6) return null; + const endSegIndex = Number(parts[parts.length - 1]); + const startSegIndex = Number(parts[parts.length - 2]); + const loopIndex = Number(parts[parts.length - 3]); + const faceId = Number(parts[parts.length - 4]); + const solidId = parts.slice(1, -4).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex) + || !Number.isFinite(startSegIndex) || !Number.isFinite(endSegIndex)) return null; + const loops = this.getFaceBoundaryLoops(`${solidId}:${faceId}`) || []; + const loop = loops[loopIndex]; + if (!loop?.points?.length) return null; + const segIndices = Array.isArray(loop.segmentIndices) ? loop.segmentIndices : []; + const startPos = segIndices.indexOf(startSegIndex); + const endPos = segIndices.indexOf(endSegIndex); + if (startPos < 0 || endPos < 0) return null; + const n = segIndices.length; + const pts = loop.points; + const pathWorld = []; + let i = startPos; + pathWorld.push(pts[i]?.clone?.() || null); + for (let guard = 0; guard < n + 2; guard++) { + const ni = loop.closed ? ((i + 1) % n) : (i + 1); + if (ni < 0 || ni >= n) break; + pathWorld.push(pts[ni]?.clone?.() || null); + if (i === endPos) break; + i = ni; + if (loop.closed && i === startPos) break; + } + const clean = pathWorld.filter(Boolean); + if (clean.length < 2) return null; + const meshEdgeKeys = []; + for (let si = 0; si + 1 < clean.length; si++) { + const mk = this.getNearestMeshEdgeKeyForWorldSegment(solidId, clean[si], clean[si + 1]); + if (mk && !meshEdgeKeys.includes(mk)) meshEdgeKeys.push(mk); + } + return { + key: raw, + solidId, + faceId, + index: startSegIndex, + chain: true, + pathWorld: clean, + aWorld: clean[0]?.clone?.() || null, + bWorld: clean[clean.length - 1]?.clone?.() || null, + midWorld: clean[Math.floor(clean.length / 2)]?.clone?.() || null, + meshEdgeKey: meshEdgeKeys[0] || null, + meshEdgeKeys + }; + } + if (raw.startsWith('faceedge:')) { + const parts = raw.split(':'); + if (parts.length < 4) return null; + const segIndex = Number(parts[parts.length - 1]); + const faceId = Number(parts[parts.length - 2]); + const solidId = parts.slice(1, -2).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(segIndex)) return null; + const segs = this.getFaceBoundarySegments(`${solidId}:${faceId}`) || []; + const seg = segs[segIndex]; + if (!seg?.a || !seg?.b) return null; + const meshEdgeKey = this.getNearestMeshEdgeKeyForWorldSegment(solidId, seg.a, seg.b); + return { + key: raw, + solidId, + index: segIndex, + faceId, + aWorld: seg.a, + bWorld: seg.b, + midWorld: seg.mid || seg.a.clone().add(seg.b).multiplyScalar(0.5), + meshEdgeKey: meshEdgeKey || null + }; + } + const splitAt = raw.lastIndexOf(':'); + if (splitAt <= 0 || splitAt >= raw.length - 1) return null; + const solidId = raw.substring(0, splitAt); + const edgeIndex = Number(raw.substring(splitAt + 1)); + if (!solidId || !Number.isFinite(edgeIndex)) return null; + const edgeObj = this.getPickEdgeForSolid(solidId); + if (!edgeObj) return null; + const seg = this.getEdgeSegmentWorld(edgeObj, edgeIndex); + if (!seg) return null; + const meshEdgeKey = this.getNearestMeshEdgeKeyForWorldSegment(solidId, seg.a, seg.b); + return { + key: `${solidId}:${edgeIndex}`, + solidId, + index: edgeIndex, + aWorld: seg.a, + bWorld: seg.b, + midWorld: seg.a.clone().add(seg.b).multiplyScalar(0.5), + meshEdgeKey: meshEdgeKey || null + }; + }, + + getNearestMeshEdgeKeyForWorldSegment(solidId, aWorld, bWorld) { + if (!solidId || !aWorld || !bWorld) return null; + const view = this._meshViews.get(String(solidId)); + const geo = view?.indexedGeometry; + const pos = geo?.getAttribute?.('position')?.array; + const idx = geo?.getIndex?.()?.array; + const mesh = view?.mesh; + if (!pos?.length || !idx?.length || !mesh?.matrixWorld) return null; + mesh.updateMatrixWorld?.(true); + const reqA = aWorld.clone ? aWorld.clone() : new THREE.Vector3(Number(aWorld.x || 0), Number(aWorld.y || 0), Number(aWorld.z || 0)); + const reqB = bWorld.clone ? bWorld.clone() : new THREE.Vector3(Number(bWorld.x || 0), Number(bWorld.y || 0), Number(bWorld.z || 0)); + const scoreSegment = (ea, eb) => { + const d1 = ea.distanceTo(reqA) + eb.distanceTo(reqB); + const d2 = ea.distanceTo(reqB) + eb.distanceTo(reqA); + return Math.min(d1, d2); + }; + const edgeToTris = new Map(); + const edgeVerts = new Map(); + const triCount = Math.floor(idx.length / 3); + for (let t = 0; t < triCount; t++) { + const i0 = idx[t * 3]; + const i1 = idx[t * 3 + 1]; + const i2 = idx[t * 3 + 2]; + const edges = [[i0, i1], [i1, i2], [i2, i0]]; + for (const [va, vb] of edges) { + const ek = edgeKey(va, vb); + const list = edgeToTris.get(ek); + if (list) list.push(t); + else edgeToTris.set(ek, [t]); + if (!edgeVerts.has(ek)) edgeVerts.set(ek, [va, vb]); + } + } + let bestKey = null; + let bestScore = Infinity; + for (const [ek, tris] of edgeToTris.entries()) { + if (!Array.isArray(tris) || tris.length < 2) continue; + const rep = edgeVerts.get(ek); + const va = Number(rep?.[0]); + const vb = Number(rep?.[1]); + if (!Number.isFinite(va) || !Number.isFinite(vb)) continue; + const pa = new THREE.Vector3(pos[va * 3], pos[va * 3 + 1], pos[va * 3 + 2]).applyMatrix4(mesh.matrixWorld); + const pb = new THREE.Vector3(pos[vb * 3], pos[vb * 3 + 1], pos[vb * 3 + 2]).applyMatrix4(mesh.matrixWorld); + const score = scoreSegment(pa, pb); + if (score < bestScore) { + bestScore = score; + bestKey = ek; + } + } + return bestKey; + }, + + getFaceEdgeHit(faceKey, worldPoint, maxWorldDist = 2.5) { + if (!faceKey || !worldPoint) return null; + const frozen = this._frozenChamferEdges; + if (frozen?.list?.length) { + const maxD2 = Math.max(0.01, Number(maxWorldDist || 2.5) ** 2); + let best = null; + let bestD2 = Infinity; + const eps = 1e-10; + for (const edge of frozen.list) { + let d2 = Infinity; + if (Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2) { + for (let i = 0; i < edge.pathWorld.length - 1; i++) { + const a = edge.pathWorld[i]; + const b = edge.pathWorld[i + 1]; + if (!a || !b) continue; + const cand = distancePointToSegmentSquared(worldPoint, a, b); + if (cand < d2) d2 = cand; + } + } else if (edge?.aWorld && edge?.bWorld) { + d2 = distancePointToSegmentSquared(worldPoint, edge.aWorld, edge.bWorld); + } + if (!Number.isFinite(d2)) continue; + const better = d2 < (bestD2 - eps); + const tiePreferPath = Math.abs(d2 - bestD2) <= eps + && !!edge?.pathWorld + && edge.pathWorld.length >= 3 + && !(best?.pathWorld && best.pathWorld.length >= 3); + const nearPreferPath = !better + && !!edge?.pathWorld + && edge.pathWorld.length >= 3 + && !(best?.pathWorld && best.pathWorld.length >= 3) + && d2 <= (bestD2 * 1.15 + eps); + if (better || tiePreferPath || nearPreferPath) { + bestD2 = d2; + best = edge; + } + } + if (best && bestD2 <= maxD2) { + return { + key: best.key, + solidId: best.solidId, + faceId: best.faceId, + index: best.index, + loop: !!best.loop, + pathWorld: Array.isArray(best.pathWorld) ? best.pathWorld : null, + aWorld: best.aWorld, + bWorld: best.bWorld, + midWorld: best.midWorld + }; + } + return null; + } + const splitAt = String(faceKey).lastIndexOf(':'); + if (splitAt <= 0) return null; + const solidId = String(faceKey).substring(0, splitAt); + const faceId = Number(String(faceKey).substring(splitAt + 1)); + if (!solidId || !Number.isFinite(faceId)) return null; + const segs = this.getFaceBoundarySegments(faceKey) || []; + if (!segs.length) return null; + const loops = this.getFaceBoundaryLoops(faceKey) || []; + const segLoopMeta = new Map(); + for (let li = 0; li < loops.length; li++) { + const loop = loops[li]; + const segIndices = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + for (const si of segIndices) { + segLoopMeta.set(Number(si), { loopIndex: li, closed: !!loop?.closed }); + } + } + + let bestAnyIndex = -1; + let bestAnyD2 = Infinity; + let bestClosedIndex = -1; + let bestClosedD2 = Infinity; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (!seg?.a || !seg?.b) continue; + const d2 = distancePointToSegmentSquared(worldPoint, seg.a, seg.b); + if (d2 < bestAnyD2) { + bestAnyD2 = d2; + bestAnyIndex = i; + } + const meta = segLoopMeta.get(i); + if (meta?.closed && d2 < bestClosedD2) { + bestClosedD2 = d2; + bestClosedIndex = i; + } + } + const maxD2 = maxWorldDist * maxWorldDist; + if (bestAnyIndex < 0 || bestAnyD2 > maxD2) return null; + + // Prefer closed-loop boundaries over open seam chains when both are plausible. + let bestIndex = bestAnyIndex; + if (bestClosedIndex >= 0 && bestClosedD2 <= maxD2) { + const openPicked = !segLoopMeta.get(bestAnyIndex)?.closed; + if (openPicked || bestClosedD2 <= (bestAnyD2 * 1.5)) { + bestIndex = bestClosedIndex; + } + } + const seg = segs[bestIndex]; + const loopIndex = loops.findIndex(loop => Array.isArray(loop?.segmentIndices) && loop.segmentIndices.includes(bestIndex)); + if (loopIndex >= 0) { + const loop = loops[loopIndex]; + if (shouldPromoteLoopSelection(loop, this._renderPrefs?.edgeLoopPromotionSegments)) { + const pathWorld = Array.isArray(loop?.points) ? loop.points.map(p => p.clone()) : []; + if (loop?.closed && pathWorld.length >= 2) { + const first = pathWorld[0]; + const last = pathWorld[pathWorld.length - 1]; + if (first.distanceToSquared(last) > 1e-16) { + pathWorld.push(first.clone()); + } + } + if (pathWorld.length >= 2) { + return { + key: `faceedgeloop:${solidId}:${faceId}:${loopIndex}`, + solidId, + faceId, + index: bestIndex, + pathWorld, + loop: true + }; + } + } + const chain = buildSmoothChainFromLoop(loop, bestIndex); + if (chain?.pathWorld?.length >= 2) { + return { + key: `faceedgechain:${solidId}:${faceId}:${loopIndex}:${chain.startSegIndex}:${chain.endSegIndex}`, + solidId, + faceId, + index: bestIndex, + chain: true, + pathWorld: chain.pathWorld + }; + } + } + return { + key: `faceedge:${solidId}:${faceId}:${bestIndex}`, + solidId, + faceId, + index: bestIndex, + aWorld: seg.a, + bWorld: seg.b, + midWorld: seg.mid || seg.a.clone().add(seg.b).multiplyScalar(0.5) + }; + }, + + resolveEdgeFromSource(source = {}) { + if (source?.type !== 'solid-edge') return null; + const targetSolidId = String(source?.solid_id || ''); + const targetFeatureId = String(source?.solid_feature_id || ''); + const sourceFaceId = Number(source?.face_id); + const sa = source?.a; + const sb = source?.b; + if (!sa || !sb) return null; + const srcA = new THREE.Vector3(Number(sa.x || 0), Number(sa.y || 0), Number(sa.z || 0)); + const srcB = new THREE.Vector3(Number(sb.x || 0), Number(sb.y || 0), Number(sb.z || 0)); + const solids = this.list() || []; + const scoreSegment = (aWorld, bWorld) => { + const d1 = aWorld.distanceTo(srcA) + bWorld.distanceTo(srcB); + const d2 = aWorld.distanceTo(srcB) + bWorld.distanceTo(srcA); + return Math.min(d1, d2); + }; + if (targetSolidId && Number.isFinite(sourceFaceId)) { + const faceKey = `${targetSolidId}:${sourceFaceId}`; + const segs = this.getFaceBoundarySegments(faceKey) || []; + const sourceEdgeIndex = Number(source?.edge_index); + if (Number.isFinite(sourceEdgeIndex) && sourceEdgeIndex >= 0 && sourceEdgeIndex < segs.length) { + const seg = segs[sourceEdgeIndex]; + if (seg?.a && seg?.b) { + return { + solidId: targetSolidId, + index: sourceEdgeIndex, + aWorld: seg.a, + bWorld: seg.b, + midWorld: seg.a.clone().add(seg.b).multiplyScalar(0.5) + }; + } + } + const face = this.getFaceByKey(faceKey); + const faceFrame = face?.meta ? this.frameFromFaceMeta(face.meta, source?.face_frame || null) : null; + const faceBasis = frameToBasis(faceFrame); + const srcLocalA = source?.local_a && faceBasis ? source.local_a : null; + const srcLocalB = source?.local_b && faceBasis ? source.local_b : null; + const srcPredA = srcLocalA ? frameLocalToWorld(srcLocalA, faceBasis) : null; + const srcPredB = srcLocalB ? frameLocalToWorld(srcLocalB, faceBasis) : null; + let bestFace = null; + let bestFaceScore = Infinity; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (!seg?.a || !seg?.b) continue; + const score = (srcPredA && srcPredB) + ? Math.min( + seg.a.distanceTo(srcPredA) + seg.b.distanceTo(srcPredB), + seg.a.distanceTo(srcPredB) + seg.b.distanceTo(srcPredA) + ) + : scoreSegment(seg.a, seg.b); + if (score < bestFaceScore) { + bestFaceScore = score; + bestFace = { solidId: targetSolidId, index: i, aWorld: seg.a, bWorld: seg.b }; + } + } + if (bestFace) { + bestFace.midWorld = bestFace.aWorld.clone().add(bestFace.bWorld).multiplyScalar(0.5); + return bestFace; + } + } + const searchSets = []; + if (targetSolidId) { + searchSets.push([targetSolidId]); + } + if (targetFeatureId) { + const byFeature = []; + for (const solid of solids) { + if (String(solid?.source?.feature_id || '') === targetFeatureId && solid?.id) { + byFeature.push(String(solid.id)); + } + } + if (byFeature.length) searchSets.push(byFeature); + } + const all = []; + for (const solid of solids) { + if (solid?.id) all.push(String(solid.id)); + } + if (all.length) searchSets.push(all); + let best = null; + let bestScore = Infinity; + const scanSet = (wanted = []) => { + for (const solidId of wanted) { + const view = this._meshViews.get(solidId); + const edgesObj = view?.edges; + if (!edgesObj?.geometry) continue; + const pos = edgesObj.geometry.getAttribute?.('position'); + const idx = edgesObj.geometry.getIndex?.(); + if (!pos) continue; + const segCount = idx?.array?.length + ? Math.floor(idx.array.length / 2) + : Math.floor(pos.count / 2); + for (let i = 0; i < segCount; i++) { + const seg = this.getEdgeSegmentWorld(edgesObj, i); + if (!seg) continue; + const score = scoreSegment(seg.a, seg.b); + if (score < bestScore) { + bestScore = score; + best = { solidId, index: i, aWorld: seg.a, bWorld: seg.b }; + } + } + } + }; + for (const wanted of searchSets) { + if (best) break; + scanSet(wanted); + } + if (!best) return null; + best.midWorld = best.aWorld.clone().add(best.bWorld).multiplyScalar(0.5); + return best; + }, + + resolvePointFromSource(source = {}) { + if (source?.type !== 'solid-edge') return null; + const targetSolidId = String(source?.solid_id || ''); + const sourceFaceId = Number(source?.face_id); + if (targetSolidId && Number.isFinite(sourceFaceId) && source?.local_point) { + const faceKey = `${targetSolidId}:${sourceFaceId}`; + const face = this.getFaceByKey(faceKey); + if (face?.meta) { + const frame = this.frameFromFaceMeta(face.meta, source?.face_frame || null); + const basis = frameToBasis(frame); + const world = frameLocalToWorld(source.local_point, basis); + if (world) return world; + } + } + const seg = this.resolveEdgeFromSource(source); + if (!seg) return null; + const kind = source?.point_kind || 'mid'; + if (kind === 'a') return seg.aWorld; + if (kind === 'b') return seg.bWorld; + return seg.midWorld; + }, + + getFaceHitFromIntersections(intersections = []) { + if (!Array.isArray(intersections)) return null; + for (const hit of intersections) { + const object = hit?.object; + if (!object || !isObjectEffectivelyVisible(object)) continue; + const solidId = object?.userData?.solidId; + const tri = hit?.faceIndex; + if (!solidId || tri === undefined || tri === null) continue; + const view = this._meshViews.get(solidId); + if (!view) continue; + const groupId = view.faceTriToGroup?.[tri]; + if (groupId === undefined || groupId < 0) continue; + const key = `${solidId}:${groupId}`; + return { key, solidId, groupId, intersection: hit }; + } + return null; + }, + + getFaceByKey(key) { + const raw = String(key || ''); + const splitAt = raw.lastIndexOf(':'); + if (splitAt <= 0 || splitAt >= raw.length - 1) return null; + const solidId = raw.substring(0, splitAt); + const faceIdRaw = raw.substring(splitAt + 1); + const faceId = Number(faceIdRaw); + if (!Number.isFinite(faceId)) return null; + const view = this._meshViews.get(solidId); + const meta = view?.faceGroups?.get(faceId); + if (!view || !meta) return null; + return { key: `${solidId}:${faceId}`, solidId, faceId, view, meta }; + }, + + getPromotedLoopEdgeKeyForSelection(edgeKey) { + const raw = String(edgeKey || ''); + if (!raw.startsWith('faceedge:')) return raw || null; + const parts = raw.split(':'); + if (parts.length < 4) return raw; + const segIndex = Number(parts[parts.length - 1]); + const faceId = Number(parts[parts.length - 2]); + const solidId = parts.slice(1, -2).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(segIndex)) return raw; + const faceKey = `${solidId}:${faceId}`; + const loops = this.getFaceBoundaryLoops(faceKey) || []; + for (let li = 0; li < loops.length; li++) { + const loop = loops[li]; + const segs = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + if (!segs.includes(segIndex)) continue; + if (!shouldPromoteLoopSelection(loop, this._renderPrefs?.edgeLoopPromotionSegments)) { + return raw; + } + return `faceedgeloop:${solidId}:${faceId}:${li}`; + } + return raw; + }, + + resolveCanonicalFaceEntity(faceKey) { + const key = String(faceKey || ''); + if (!key) return null; + return { + kind: 'surface', + id: this._geomSurfaceIdByFaceKey.get(key) || `surface:${key}` + }; + }, + + resolveCanonicalEdgeEntity(edgeKey) { + const key = String(edgeKey || ''); + if (!key) return null; + const loopBoundary = this._geomBoundaryIdByLoopKey.get(key); + if (loopBoundary) { + return { kind: 'boundary', id: loopBoundary }; + } + const segId = this._geomSegmentIdByEdgeKey.get(key); + if (segId) { + return { kind: 'boundary-segment', id: segId }; + } + return { kind: 'boundary-segment', id: `segment:${key}` }; + }, + + getEdgeKeyForBoundaryRef(refId) { + const raw = String(refId || ''); + if (!raw) return null; + const frozen = this._frozenChamferEdges; + if (frozen) { + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + return frozen.byKey?.has?.(raw) ? raw : null; + } + if (raw.startsWith('segment:faceedge:') || raw.startsWith('segment:faceedgeloop:')) { + const key = raw.substring('segment:'.length); + return frozen.byKey?.has?.(key) ? key : null; + } + if (raw.startsWith('segment:')) { + return frozen.geomSegToEdgeKey?.get?.(raw) || null; + } + if (raw.startsWith('boundary:')) { + return frozen.geomBoundaryToEdgeKey?.get?.(raw) || null; + } + } + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + return raw; + } + if (raw.startsWith('segment:faceedge:') || raw.startsWith('segment:faceedgeloop:')) { + return raw.substring('segment:'.length); + } + if (raw.startsWith('segment:')) { + return this._edgeKeyByGeomSegmentId.get(raw) || null; + } + if (raw.startsWith('boundary:')) { + return this._loopKeyByGeomBoundaryId.get(raw) || null; + } + return null; + }, + + resolveChamferRefToEdgeKey(ref = {}) { + const frozen = this._frozenChamferEdges; + if (!frozen?.list?.length) return null; + const mapped = this.getEdgeKeyForBoundaryRef(ref?.boundary_segment_id || ref?.entity?.id || ''); + if (mapped && this.getEdgeByKey(mapped)) return mapped; + const explicit = String(ref?.key || '').trim(); + if (explicit && this.getEdgeByKey(explicit)) return explicit; + return null; + }, + + captureChamferEdgeSnapshotFromCurrentViews() { + const byKey = new Map(); + const list = []; + const geomSegToEdgeKey = new Map(); + const geomBoundaryToEdgeKey = new Map(); + for (const [solidId, view] of this._meshViews.entries()) { + const faceGroups = view?.faceGroups; + if (!faceGroups || typeof faceGroups.keys !== 'function') continue; + for (const faceId of faceGroups.keys()) { + if (!Number.isFinite(faceId)) continue; + const faceKey = `${solidId}:${faceId}`; + const segs = this.getFaceBoundarySegments(faceKey) || []; + for (let i = 0; i < segs.length; i++) { + const seg = segs[i]; + if (!seg?.a || !seg?.b) continue; + const key = `faceedge:${solidId}:${faceId}:${i}`; + const edge = { + key, + solidId, + faceId, + index: i, + aWorld: seg.a.clone ? seg.a.clone() : new THREE.Vector3(seg.a.x, seg.a.y, seg.a.z), + bWorld: seg.b.clone ? seg.b.clone() : new THREE.Vector3(seg.b.x, seg.b.y, seg.b.z), + midWorld: seg.mid?.clone ? seg.mid.clone() : (seg.a.clone ? seg.a.clone().add(seg.b).multiplyScalar(0.5) : new THREE.Vector3()), + meshEdgeKey: this.getNearestMeshEdgeKeyForWorldSegment(solidId, seg.a, seg.b) || null + }; + byKey.set(key, edge); + list.push(edge); + const geomSeg = this._geomSegmentIdByEdgeKey.get(key); + if (geomSeg) geomSegToEdgeKey.set(geomSeg, key); + } + const loops = this.getFaceBoundaryLoops(faceKey) || []; + for (let li = 0; li < loops.length; li++) { + const loop = loops[li]; + const points = Array.isArray(loop?.points) ? loop.points : []; + if (points.length < 2) continue; + if (shouldPromoteLoopSelection(loop, this._renderPrefs?.edgeLoopPromotionSegments)) { + const pathWorld = points.map(p => p.clone ? p.clone() : new THREE.Vector3(p.x, p.y, p.z)); + if (loop?.closed && pathWorld.length >= 2) { + const first = pathWorld[0]; + const last = pathWorld[pathWorld.length - 1]; + if (first.distanceToSquared(last) > 1e-16) pathWorld.push(first.clone()); + } + if (pathWorld.length >= 2) { + const key = `faceedgeloop:${solidId}:${faceId}:${li}`; + const edge = { + key, + solidId, + faceId, + index: Number(loop?.segmentIndices?.[0] ?? null), + loop: true, + pathWorld, + aWorld: pathWorld[0].clone(), + bWorld: pathWorld[1].clone(), + midWorld: pathWorld[0].clone().add(pathWorld[1]).multiplyScalar(0.5), + meshEdgeKey: null + }; + byKey.set(key, edge); + const geomBoundary = this._geomBoundaryIdByLoopKey.get(key); + if (geomBoundary) geomBoundaryToEdgeKey.set(geomBoundary, key); + } + } else { + const segIndices = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + const chainKeys = new Set(); + for (const segIndex of segIndices) { + const chain = buildSmoothChainFromLoop(loop, Number(segIndex)); + if (!chain?.pathWorld?.length || chain.pathWorld.length < 2) continue; + const key = `faceedgechain:${solidId}:${faceId}:${li}:${chain.startSegIndex}:${chain.endSegIndex}`; + if (chainKeys.has(key)) continue; + chainKeys.add(key); + const edge = { + key, + solidId, + faceId, + index: Number(segIndex), + chain: true, + pathWorld: chain.pathWorld, + aWorld: chain.pathWorld[0].clone(), + bWorld: chain.pathWorld[chain.pathWorld.length - 1].clone(), + midWorld: chain.pathWorld[Math.floor(chain.pathWorld.length / 2)].clone(), + meshEdgeKey: null + }; + byKey.set(key, edge); + } + } + } + } + } + this._frozenChamferEdges = { byKey, list, geomSegToEdgeKey, geomBoundaryToEdgeKey }; + this._selectedEdgeKeys = new Set(Array.from(this._selectedEdgeKeys).filter(key => this.getEdgeByKey(key))); + if (this._hoveredEdgeKey && !this.getEdgeByKey(this._hoveredEdgeKey)) { + this._hoveredEdgeKey = null; + } + this.syncEdgeOverlays(); + }, + + async beginChamferEdgeSnapshot(featureId = null) { + const api = getApi(); + const doc = api.document.current; + const features = api.features.list() || []; + if (!doc || !Array.isArray(features)) { + this.captureChamferEdgeSnapshotFromCurrentViews(); + return; + } + + const featureIndex = featureId + ? features.findIndex(feature => feature?.id === featureId) + : -1; + const canRollback = featureIndex >= 0; + const originalTimeline = doc.timeline?.index ?? null; + let rolledBack = false; + + try { + if (canRollback) { + doc.timeline = doc.timeline || { index: null }; + doc.timeline.index = featureIndex > 0 ? (featureIndex - 1) : -1; + rolledBack = true; + await this.rebuild('chamfer.snapshot.pre', { persist: false }); + } + this.captureChamferEdgeSnapshotFromCurrentViews(); + } finally { + if (rolledBack) { + doc.timeline = doc.timeline || { index: null }; + doc.timeline.index = originalTimeline; + await this.rebuild('chamfer.snapshot.restore', { persist: false }); + this.syncEdgeOverlays(); + } + } + }, + + endChamferEdgeSnapshot() { + this._frozenChamferEdges = null; + this._selectedEdgeKeys = new Set(Array.from(this._selectedEdgeKeys).filter(key => this.getEdgeByKey(key))); + if (this._hoveredEdgeKey && !this.getEdgeByKey(this._hoveredEdgeKey)) { + this._hoveredEdgeKey = null; + } + this.syncEdgeOverlays(); + }, + + getFaceBoundarySegments(key) { + const face = this.getFaceByKey(key); + if (!face?.meta?.geometry) return []; + if (!Array.isArray(face.meta.boundarySegmentsLocal)) { + face.meta.boundarySegmentsLocal = buildBoundarySegmentsFromGeometry(face.meta.geometry); + } + const local = face.meta.boundarySegmentsLocal || []; + if (!local.length) return []; + const mesh = face?.view?.mesh || null; + if (!mesh?.matrixWorld) return []; + mesh.updateMatrixWorld?.(true); + const out = []; + for (const seg of local) { + if (!seg?.a || !seg?.b) continue; + const a = seg.a.clone().applyMatrix4(mesh.matrixWorld); + const b = seg.b.clone().applyMatrix4(mesh.matrixWorld); + out.push({ + a, + b, + mid: a.clone().add(b).multiplyScalar(0.5) + }); + } + return out; + }, + + getFaceBoundaryLoops(key) { + const face = this.getFaceByKey(key); + if (!face?.meta?.geometry) return []; + if (!Array.isArray(face.meta.boundarySegmentsLocal)) { + face.meta.boundarySegmentsLocal = buildBoundarySegmentsFromGeometry(face.meta.geometry); + } + if (!Array.isArray(face.meta.boundaryLoopsLocal)) { + face.meta.boundaryLoopsLocal = buildBoundaryLoopsFromSegments(face.meta.boundarySegmentsLocal || []); + } + const loops = face.meta.boundaryLoopsLocal || []; + if (!loops.length) return []; + const mesh = face?.view?.mesh || null; + if (!mesh?.matrixWorld) return []; + mesh.updateMatrixWorld?.(true); + return loops.map(loop => ({ + segmentIndices: Array.isArray(loop.segmentIndices) ? loop.segmentIndices.slice() : [], + closed: !!loop.closed, + points: Array.isArray(loop.points) + ? loop.points.map(p => p.clone().applyMatrix4(mesh.matrixWorld)) + : [] + })); + }, + + setHoveredFace(key = null) { + const next = key && this.getFaceByKey(key) ? key : null; + if (next === this._hoveredFaceKey) return; + this._hoveredFaceKey = next; + this.syncFaceOverlays(); + }, + + setSelectedFaces(keys = []) { + this._selectedFaceKeys = new Set((keys || []).filter(key => this.getFaceByKey(key))); + this.syncFaceOverlays(); + }, + + toggleSelectedFace(key, multi = false) { + if (!this.getFaceByKey(key)) return Array.from(this._selectedFaceKeys); + if (!multi) this._selectedFaceKeys.clear(); + if (this._selectedFaceKeys.has(key)) this._selectedFaceKeys.delete(key); + else this._selectedFaceKeys.add(key); + this.syncFaceOverlays(); + return Array.from(this._selectedFaceKeys); + }, + + clearFaceSelection() { + this._selectedFaceKeys.clear(); + this._hoveredFaceKey = null; + this.syncFaceOverlays(); + }, + + getSelectedFaceKeys() { + return Array.from(this._selectedFaceKeys); + }, + + setHoveredEdge(key = null) { + const next = key && this.getEdgeByKey(key) ? key : null; + if (next === this._hoveredEdgeKey) return; + this._hoveredEdgeKey = next; + this.syncEdgeOverlays(); + }, + + setSelectedEdges(keys = []) { + this._selectedEdgeKeys = new Set((keys || []).filter(key => this.getEdgeByKey(key))); + this.syncEdgeOverlays(); + }, + + toggleSelectedEdge(key, multi = false) { + if (!this.getEdgeByKey(key)) return Array.from(this._selectedEdgeKeys); + if (!multi) this._selectedEdgeKeys.clear(); + if (this._selectedEdgeKeys.has(key)) this._selectedEdgeKeys.delete(key); + else this._selectedEdgeKeys.add(key); + this.syncEdgeOverlays(); + return Array.from(this._selectedEdgeKeys); + }, + + clearEdgeSelection() { + this._selectedEdgeKeys.clear(); + this._hoveredEdgeKey = null; + this.syncEdgeOverlays(); + }, + + getSelectedEdgeKeys() { + return Array.from(this._selectedEdgeKeys); + }, + + getRenderPreferences() { + return { ...(this._renderPrefs || {}) }; + }, + + setRenderPreferences(next = {}) { + const curr = this._renderPrefs || {}; + const merged = { + edgeLoopPromotionSegments: Math.max(3, Math.round(Number(next.edgeLoopPromotionSegments ?? curr.edgeLoopPromotionSegments ?? 10) || 10)), + edgeHoverLineWidth: Math.max(0.5, Number(next.edgeHoverLineWidth ?? curr.edgeHoverLineWidth ?? 2.5) || 2.5), + edgeSelectedLineWidth: Math.max(0.5, Number(next.edgeSelectedLineWidth ?? curr.edgeSelectedLineWidth ?? 3.25) || 3.25) + }; + this._renderPrefs = merged; + this.syncEdgeOverlays(); + return this.getRenderPreferences(); + }, + + getSketchTargetForFaceKey(key) { + const face = this.getFaceByKey(key); + if (!face) return null; + const { meta, solidId, faceId } = face; + if (!meta.planar) return null; + const frame = this.frameFromFaceMeta(meta, null); + if (!frame) return null; + const solid = this.list().find(item => item?.id === solidId) || null; + return { + kind: 'face', + id: `${solidId}:f${faceId}`, + name: 'Face', + frame, + source: { + type: 'solid-face', + solid_id: solidId, + face_id: faceId, + solid_feature_id: solid?.source?.feature_id || null, + dep_sig: this.getSolidDependencySignature(solidId), + anchor: { + x: Number(meta.center?.x || 0), + y: Number(meta.center?.y || 0), + z: Number(meta.center?.z || 0) + }, + anchor_normal: { + x: Number(meta.normal?.x || 0), + y: Number(meta.normal?.y || 0), + z: Number(meta.normal?.z || 1) + } + } + }; + }, + + frameFromFaceMeta(meta, preferredFrame = null) { + if (!meta?.planar || !meta?.center || !meta?.normal) return null; + const center = meta.center.clone(); + const normal = meta.normal.clone().normalize(); + let xAxis = preferredFrame?.x_axis + ? new THREE.Vector3( + Number(preferredFrame.x_axis.x || 0), + Number(preferredFrame.x_axis.y || 0), + Number(preferredFrame.x_axis.z || 0) + ) + : new THREE.Vector3(1, 0, 0); + if (xAxis.lengthSq() <= 1e-12) { + xAxis.set(1, 0, 0); + } + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + if (xAxis.lengthSq() <= 1e-10) { + xAxis.set(1, 0, 0); + if (Math.abs(xAxis.dot(normal)) > 0.95) { + xAxis.set(0, 1, 0); + } + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + } + if (xAxis.lengthSq() <= 1e-10) { + xAxis.set(0, 0, 1).addScaledVector(normal, -normal.z); + } + xAxis.normalize(); + return { + origin: { x: center.x, y: center.y, z: center.z }, + normal: { x: normal.x, y: normal.y, z: normal.z }, + x_axis: { x: xAxis.x, y: xAxis.y, z: xAxis.z } + }; + }, + + applyOffsetToFrame(frame, offset = 0) { + const off = Number(offset || 0); + if (!frame || !Number.isFinite(off) || Math.abs(off) < 1e-12) { + return frame ? JSON.parse(JSON.stringify(frame)) : null; + } + const normal = frame.normal || {}; + const nx = Number(normal.x || 0); + const ny = Number(normal.y || 0); + const nz = Number(normal.z || 0); + const nlen = Math.hypot(nx, ny, nz) || 1; + const ox = Number(frame.origin?.x || 0) + (nx / nlen) * off; + const oy = Number(frame.origin?.y || 0) + (ny / nlen) * off; + const oz = Number(frame.origin?.z || 0) + (nz / nlen) * off; + return { + origin: { x: ox, y: oy, z: oz }, + normal: { + x: nx / nlen, + y: ny / nlen, + z: nz / nlen + }, + x_axis: { + x: Number(frame.x_axis?.x || 1), + y: Number(frame.x_axis?.y || 0), + z: Number(frame.x_axis?.z || 0) + } + }; + }, + + resolveSketchFrameForSource(source, preferredFrame = null) { + const sourceType = String(source?.type || ''); + if (sourceType !== 'solid-face' && sourceType !== 'face') return null; + const solidId = String(source?.solid_id || ''); + const preferredSolidId = solidId || null; + const view = solidId ? this._meshViews.get(solidId) : null; + const sourceFaceId = Number(source?.face_id); + const sourceFeatureId = String(source?.solid_feature_id || ''); + const anchor = source?.anchor + ? new THREE.Vector3( + Number(source.anchor.x || 0), + Number(source.anchor.y || 0), + Number(source.anchor.z || 0) + ) + : null; + const anchorNormal = source?.anchor_normal + ? new THREE.Vector3( + Number(source.anchor_normal.x || 0), + Number(source.anchor_normal.y || 0), + Number(source.anchor_normal.z || 1) + ).normalize() + : null; + let preferredOrigin = null; + let preferredNormal = null; + if (preferredFrame?.origin && preferredFrame?.normal) { + preferredOrigin = new THREE.Vector3( + Number(preferredFrame.origin.x || 0), + Number(preferredFrame.origin.y || 0), + Number(preferredFrame.origin.z || 0) + ); + preferredNormal = new THREE.Vector3( + Number(preferredFrame.normal.x || 0), + Number(preferredFrame.normal.y || 0), + Number(preferredFrame.normal.z || 1) + ).normalize(); + } + let best = null; + let bestScore = -Infinity; + const evalView = (sid, meshView, scoreBias = 0) => { + if (!meshView?.faceGroups?.size) return; + for (const [faceId, meta] of meshView.faceGroups.entries()) { + if (!meta?.planar) continue; + const n = meta.normal.clone().normalize(); + const alignPref = preferredNormal ? n.dot(preferredNormal) : 0; + if (preferredNormal && alignPref < 0.95) { + continue; + } + const alignAnchor = anchorNormal ? n.dot(anchorNormal) : 0; + if (anchorNormal && alignAnchor < 0.93) { + continue; + } + const distPref = preferredOrigin ? preferredOrigin.distanceTo(meta.center) : 0; + const planeDistAnchor = anchor + ? Math.abs(n.dot(anchor) - n.dot(meta.center)) + : 0; + const centerDistAnchor = anchor ? anchor.distanceTo(meta.center) : 0; + const sameFaceBonus = (sid === preferredSolidId && Number.isFinite(sourceFaceId) && sourceFaceId === faceId) + ? 2 + : 0; + const score = + (alignPref * 6) + + (alignAnchor * 2) - + (distPref * 0.03) - + (planeDistAnchor * 6) - + (centerDistAnchor * 0.003) + + scoreBias + + sameFaceBonus; + if (score > bestScore + 1e-9) { + bestScore = score; + best = { solidId: sid, faceId, meta, distPref, planeDistAnchor, centerDistAnchor }; + } else if (Math.abs(score - bestScore) <= 1e-9 && best) { + // Deterministic tie-break to avoid jitter. + const bestTuple = [best.planeDistAnchor, best.distPref, best.centerDistAnchor, String(best.solidId), Number(best.faceId)]; + const nextTuple = [planeDistAnchor, distPref, centerDistAnchor, String(sid), Number(faceId)]; + if ( + nextTuple[0] < bestTuple[0] - 1e-9 || + (Math.abs(nextTuple[0] - bestTuple[0]) <= 1e-9 && ( + nextTuple[1] < bestTuple[1] - 1e-9 || + (Math.abs(nextTuple[1] - bestTuple[1]) <= 1e-9 && ( + nextTuple[2] < bestTuple[2] - 1e-9 || + (Math.abs(nextTuple[2] - bestTuple[2]) <= 1e-9 && ( + nextTuple[3] < bestTuple[3] || + (nextTuple[3] === bestTuple[3] && nextTuple[4] < bestTuple[4]) + )) + )) + )) + ) { + best = { solidId: sid, faceId, meta, distPref, planeDistAnchor, centerDistAnchor }; + } + } + } + }; + // Always attempt the referenced solid first for stability. + if (view) { + evalView(solidId, view, 0.1); + } + + // If nothing matched on the referenced solid (or it no longer exists), + // fall back to same-feature solids, then all solids. + if (!best) { + const solidsById = new Map((this.list() || []).map(item => [String(item?.id || ''), item])); + const candidates = []; + for (const [sid, meshView] of this._meshViews.entries()) { + if (view && sid === solidId) continue; + const solid = solidsById.get(String(sid)); + const sameFeature = sourceFeatureId && String(solid?.source?.feature_id || '') === sourceFeatureId; + candidates.push({ sid, meshView, sameFeature }); + } + if (sourceFeatureId && candidates.some(c => c.sameFeature)) { + for (const c of candidates) { + if (!c.sameFeature) continue; + evalView(c.sid, c.meshView, 0.06); + } + } + if (!best) { + for (const c of candidates) { + const bias = preferredSolidId && c.sid === preferredSolidId ? 0.02 : 0; + evalView(c.sid, c.meshView, bias); + } + } + } + if (!best) return null; + return { + solidId: best.solidId, + faceId: best.faceId, + frame: this.frameFromFaceMeta(best.meta, preferredFrame) + }; + }, + + refreshSketchFaceAttachments() { + const api = getApi(); + const features = api.features.list() || []; + let changed = false; + for (const feature of features) { + if (feature?.type !== 'sketch') continue; + const target = feature?.target || {}; + let source = target?.source || null; + let resolved = null; + + // Preferred path: if target.id already references an existing face key, + // resolve directly from current runtime face data. + if (target?.kind === 'face' && typeof target?.id === 'string') { + const m = target.id.match(/^(.*):f(\d+)$/); + if (m) { + const directKey = `${String(m[1] || '')}:${Number(m[2])}`; + const directTarget = this.getSketchTargetForFaceKey(directKey); + if (directTarget?.frame) { + resolved = { + solidId: String(m[1] || ''), + faceId: Number(m[2]), + frame: directTarget.frame + }; + source = { + ...(source || {}), + ...(directTarget.source || {}), + type: 'solid-face', + solid_id: String(m[1] || ''), + face_id: Number(m[2]) + }; + } + } + } + + // Backfill missing/incomplete face source metadata from target.id + // (format: ":f") so attachments can rebind without + // requiring manual sketch edit. + if ((!source || (!source.type && target?.kind === 'face')) && typeof target?.id === 'string') { + const m = target.id.match(/^(.*):f(\d+)$/); + if (m) { + source = { + ...(source || {}), + type: 'solid-face', + solid_id: String(m[1] || ''), + face_id: Number(m[2]) + }; + api.features.mutateTransient(feature.id, item => { + item.target = item.target || {}; + item.target.source = { + ...(item.target.source || {}), + type: 'solid-face', + solid_id: source.solid_id, + face_id: source.face_id + }; + }); + } + } + if (source?.type !== 'solid-face' && source?.type !== 'face') continue; + if (!resolved) { + resolved = this.resolveSketchFrameForSource(source, feature.plane || null); + } + if (!resolved?.frame) continue; + const frame = this.applyOffsetToFrame(resolved.frame, Number(feature?.target?.offset || 0)); + const prev = feature.plane || {}; + const nextSolidId = String(resolved.solidId || source?.solid_id || ''); + const same = + Math.abs((prev.origin?.x || 0) - frame.origin.x) < 1e-6 && + Math.abs((prev.origin?.y || 0) - frame.origin.y) < 1e-6 && + Math.abs((prev.origin?.z || 0) - frame.origin.z) < 1e-6 && + Math.abs((prev.normal?.x || 0) - frame.normal.x) < 1e-6 && + Math.abs((prev.normal?.y || 0) - frame.normal.y) < 1e-6 && + Math.abs((prev.normal?.z || 0) - frame.normal.z) < 1e-6 && + Math.abs((prev.x_axis?.x || 0) - frame.x_axis.x) < 1e-6 && + Math.abs((prev.x_axis?.y || 0) - frame.x_axis.y) < 1e-6 && + Math.abs((prev.x_axis?.z || 0) - frame.x_axis.z) < 1e-6 && + Number(source?.face_id) === Number(resolved.faceId) && + String(source?.solid_id || '') === nextSolidId && + source?.type === 'solid-face'; + if (same) continue; + api.features.mutateTransient(feature.id, item => { + item.plane = frame; + item.target = item.target || {}; + item.target.source = item.target.source || {}; + item.target.source.type = 'solid-face'; + item.target.source.solid_id = nextSolidId; + item.target.source.face_id = resolved.faceId; + if (!item.target.source.solid_feature_id) { + const solid = this.list().find(s => s?.id === nextSolidId); + item.target.source.solid_feature_id = solid?.source?.feature_id || null; + } + item.target.source.dep_sig = this.getSolidDependencySignature(nextSolidId); + item.target.source.anchor = { + x: Number(resolved.frame.origin.x || 0), + y: Number(resolved.frame.origin.y || 0), + z: Number(resolved.frame.origin.z || 0) + }; + item.target.source.anchor_normal = { + x: Number(resolved.frame.normal.x || 0), + y: Number(resolved.frame.normal.y || 0), + z: Number(resolved.frame.normal.z || 1) + }; + item.target.id = `${nextSolidId}:f${resolved.faceId}`; + item.target.kind = 'face'; + item.target.name = 'Face'; + }); + changed = true; + } + return changed; + }, + + syncFaceOverlays() { + for (const view of this._meshViews.values()) { + for (const [faceId, overlay] of view.faceOverlays?.entries?.() || []) { + const key = `${view.mesh?.userData?.solidId}:${faceId}`; + const selected = this._selectedFaceKeys.has(key); + const hovered = this._hoveredFaceKey === key; + overlay.visible = selected || hovered; + overlay.material = selected ? this._faceMats.selected : this._faceMats.hover; + } + } + }, + + syncEdgeOverlays() { + const { renderer } = space.internals(); + const rw = Math.max(1, Number(renderer?.domElement?.clientWidth || renderer?.domElement?.width || window.innerWidth || 1)); + const rh = Math.max(1, Number(renderer?.domElement?.clientHeight || renderer?.domElement?.height || window.innerHeight || 1)); + const frozenActive = !!this._frozenChamferEdges; + this._root?.updateMatrixWorld?.(true); + if (this._frozenEdgeOverlays) { + while (this._frozenEdgeOverlays.children.length) { + const child = this._frozenEdgeOverlays.children[0]; + child.geometry?.dispose?.(); + child.material?.dispose?.(); + this._frozenEdgeOverlays.remove(child); + } + } + for (const [solidId, view] of this._meshViews.entries()) { + if (!view?.edgeOverlays) continue; + while (view.edgeOverlays.children.length) { + const child = view.edgeOverlays.children[0]; + child.geometry?.dispose?.(); + child.material?.dispose?.(); + view.edgeOverlays.remove(child); + } + if (frozenActive) { + continue; + } + view.group?.updateMatrixWorld?.(true); + const wanted = []; + for (const key of this._selectedEdgeKeys) { + const edge = this.getEdgeByKey(key); + if (edge?.solidId === solidId) wanted.push({ key, selected: true }); + } + if (this._hoveredEdgeKey && !this._selectedEdgeKeys.has(this._hoveredEdgeKey)) { + const edge = this.getEdgeByKey(this._hoveredEdgeKey); + if (edge?.solidId === solidId) wanted.push({ key: this._hoveredEdgeKey, selected: false }); + } + for (const item of wanted) { + const edge = this.getEdgeByKey(item.key); + const path = Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2 + ? edge.pathWorld.map(p => view.group.worldToLocal(p.clone())) + : (edge?.aWorld && edge?.bWorld) + ? [view.group.worldToLocal(edge.aWorld.clone()), view.group.worldToLocal(edge.bWorld.clone())] + : null; + if (!path || path.length < 2) continue; + const geo = new LineGeometry(); + const positions = []; + for (const p of path) { + positions.push(Number(p.x || 0), Number(p.y || 0), Number(p.z || 0)); + } + geo.setPositions(positions); + const mat = new LineMaterial({ + color: item.selected ? 0xff9933 : 0xffb366, + linewidth: item.selected + ? Number(this._renderPrefs?.edgeSelectedLineWidth || 3.25) + : Number(this._renderPrefs?.edgeHoverLineWidth || 2.5), + transparent: true, + opacity: item.selected ? 0.95 : 0.85, + depthTest: false, + depthWrite: false, + dashed: false + }); + mat.resolution.set(rw, rh); + const line = new Line2(geo, mat); + line.frustumCulled = false; + line.renderOrder = 80; + view.edgeOverlays.add(line); + } + } + if (!frozenActive) return; + if (!this._frozenEdgeOverlays && this._root) { + this._frozenEdgeOverlays = new THREE.Group(); + this._frozenEdgeOverlays.name = 'void-solids-frozen-edge-overlays'; + this._root.add(this._frozenEdgeOverlays); + } + if (!this._frozenEdgeOverlays || !this._root) return; + this._frozenEdgeOverlays.updateMatrixWorld?.(true); + const wanted = []; + for (const key of this._selectedEdgeKeys) { + if (this.getEdgeByKey(key)) wanted.push({ key, selected: true }); + } + if (this._hoveredEdgeKey && !this._selectedEdgeKeys.has(this._hoveredEdgeKey)) { + if (this.getEdgeByKey(this._hoveredEdgeKey)) wanted.push({ key: this._hoveredEdgeKey, selected: false }); + } + for (const item of wanted) { + const edge = this.getEdgeByKey(item.key); + const pathWorld = Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2 + ? edge.pathWorld + : (edge?.aWorld && edge?.bWorld) + ? [edge.aWorld, edge.bWorld] + : null; + if (!pathWorld || pathWorld.length < 2) continue; + const geo = new LineGeometry(); + const positions = []; + for (const p of pathWorld) { + const local = this._root.worldToLocal(p.clone ? p.clone() : new THREE.Vector3(Number(p.x || 0), Number(p.y || 0), Number(p.z || 0))); + positions.push(Number(local.x || 0), Number(local.y || 0), Number(local.z || 0)); + } + geo.setPositions(positions); + const mat = new LineMaterial({ + color: item.selected ? 0xff9933 : 0xffb366, + linewidth: item.selected + ? Number(this._renderPrefs?.edgeSelectedLineWidth || 3.25) + : Number(this._renderPrefs?.edgeHoverLineWidth || 2.5), + transparent: true, + opacity: item.selected ? 0.95 : 0.85, + depthTest: false, + depthWrite: false, + dashed: false + }); + mat.resolution.set(rw, rh); + const line = new Line2(geo, mat); + line.frustumCulled = false; + line.renderOrder = 80; + this._frozenEdgeOverlays.add(line); + } + }, + + scheduleRebuild(reason = 'schedule', delay = 25) { + this._pendingReason = reason; + clearTimeout(this._rebuildTimer); + this._rebuildTimer = setTimeout(() => { + this.rebuild(this._pendingReason || 'schedule'); + this._pendingReason = null; + }, delay); + }, + + async rebuild(reason = 'manual', options = {}) { + const api = getApi(); + const persist = options?.persist !== false; + if (this._rebuilding) { + this._pendingReason = reason; + return this.list(); + } + this._rebuilding = true; + const seq = ++this._rebuildSeq; + try { + let result = null; + let passReason = reason; + for (let pass = 0; pass < 3; pass++) { + api.sketchRuntime?.sync?.(); + const snapshot = buildRebuildSnapshot(api); + const forceMainThread = String(passReason || '').startsWith('feature.edit.exit'); + if (forceMainThread) { + result = await rebuildGeneratedSolids(api, { reason: passReason, persist: false }); + } else { + try { + const workerReply = await this.requestWorkerRebuild(snapshot, passReason); + result = { + solids: workerReply?.solids || [], + meshCache: meshCacheFromWorkerPayload(workerReply?.meshes || []) + }; + } catch (error) { + console.warn('void.solids: worker rebuild failed, using main-thread fallback', error); + result = await rebuildGeneratedSolids(api, { reason: passReason, persist: false }); + } + } + if (seq !== this._rebuildSeq) { + return this.list(); + } + api.document.current.generated = api.document.current.generated || {}; + api.document.current.generated.solids = result?.solids || []; + if (persist) { + await api.document.save({ + kind: 'micro', + opType: 'solid.rebuild', + undoable: false, + clearRedo: false, + payload: { + reason: passReason || 'rebuild', + solids: api.document.current.generated.solids.length + } + }); + } + this._meshCache = result?.meshCache || new Map(); + this.syncRuntime(); + let derivedChanged = false; + for (const feature of (api.features.list() || [])) { + if (feature?.type !== 'sketch') continue; + if (api.interact?.refreshDerivedSketchGeometry?.(feature)) { + derivedChanged = true; + } + } + const rebound = this.refreshSketchFaceAttachments(); + if (rebound || derivedChanged) { + if (persist) { + await api.document.save({ + kind: 'micro', + opType: 'feature.auto.refresh', + undoable: false, + clearRedo: false, + payload: { + rebound: !!rebound, + derived: !!derivedChanged + } + }); + } + } + if (rebound && pass < 2) { + api.sketchRuntime?.sync?.(); + passReason = 'sketch.face.rebind'; + continue; + } + if (derivedChanged && pass < 2) { + api.sketchRuntime?.sync?.(); + passReason = 'sketch.derived.refresh'; + continue; + } + if (rebound || derivedChanged) { + api.sketchRuntime?.sync?.(); + } + break; + } + return result?.solids || this.list(); + } finally { + this._rebuilding = false; + if (this._pendingReason) { + const next = this._pendingReason; + this._pendingReason = null; + this.scheduleRebuild(next, 10); + } + } + }, + + async rebuildDownstreamFrom(featureId, reason = 'feature.edit.exit') { + const api = getApi(); + const doc = api.document.current; + const features = api.features.list() || []; + const idx = features.findIndex(feature => feature?.id === featureId); + if (!doc || idx < 0) { + return this.rebuild(reason); + } + doc.timeline = doc.timeline || { index: null }; + const originalTimeline = doc.timeline.index ?? null; + try { + const max = features.length; + for (let count = idx + 1; count <= max; count++) { + doc.timeline.index = count >= max ? null : (count - 1); + await this.rebuild(`${reason}.step.${count}`, { persist: false }); + } + } finally { + doc.timeline.index = originalTimeline; + } + return this.rebuild(`${reason}.final`); + } + }; +} + +export { createSolidsApi }; diff --git a/src/void/datum.js b/src/void/datum.js new file mode 100644 index 00000000..59668081 --- /dev/null +++ b/src/void/datum.js @@ -0,0 +1,382 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; +import { Plane } from './plane.js'; + +const { Group } = THREE; + +/** + * Datum - composed of three orthogonal plane primitives + * Provides the default coordinate system reference for modeling + */ +const datum = { + group: null, // THREE.Group containing all planes + planes: {}, // { xy, xz, yz } - Plane instances + size: 200, // Plane dimensions + visible: true, + overlay: null, // Overlay reference for label updates + labelHandlers: new Map(), + changeHandlers: new Set(), + + /** + * Initialize datum with three orthogonal planes + */ + init(options = {}) { + this.size = options.size || 200; + this.group = new Group(); + this.group.name = 'datum'; + + // Create three plane primitives with labels (like Onshape) + this.planes.xy = new Plane({ + id: 'datum-xy', + name: 'XY Plane', + label: 'Top', + size: this.size + }); + + this.planes.xz = new Plane({ + id: 'datum-xz', + name: 'XZ Plane', + label: 'Front', + size: this.size + }); + + this.planes.yz = new Plane({ + id: 'datum-yz', + name: 'YZ Plane', + label: 'Right', + size: this.size + }); + + // Position XZ plane (vertical, front-back) + this.planes.xz.setRotation(Math.PI / 2, 0, 0); + + // Position YZ plane (vertical, left-right) + this.planes.yz.setRotation(0, Math.PI / 2, 0); + + // Add all plane groups to datum group + this.group.add(this.planes.xy.getGroup()); + this.group.add(this.planes.xz.getGroup()); + this.group.add(this.planes.yz.getGroup()); + + // Keep labels in sync with plane transform/size updates + for (const [key, plane] of Object.entries(this.planes)) { + const handler = () => { + if (this.overlay) { + this.updateLabel(this.overlay, key, plane); + } + }; + this.labelHandlers.set(key, handler); + plane.onChange(handler); + } + + // Set initial visibility + this.setVisible(options.visible !== undefined ? options.visible : true); + + console.log({ datum_initialized: true, size: this.size, planes: 3 }); + + return this.group; + }, + + /** + * Get all plane primitives + */ + getPlanes() { + return Object.values(this.planes); + }, + + /** + * Update all plane labels in overlay (should be called by main init) + */ + updateLabels(overlay) { + if (!overlay) return; + this.overlay = overlay; + + // Add or update labels for each plane + for (const [key, plane] of Object.entries(this.planes)) { + this.updateLabel(overlay, key, plane); + } + }, + + /** + * Update a single plane label in the overlay + */ + updateLabel(overlay, key, plane) { + const label = plane.getLabel(); + if (!label) return; + + const labelId = `datum-label-${key}`; + const corner = plane.getTopLeftCorner(); + const hidden = !this.visible || !plane.getGroup()?.visible; + + if (overlay.elements.has(labelId)) { + overlay.update(labelId, { pos3d: corner, text: label, hidden }); + } else { + overlay.add(labelId, 'text', { + pos3d: corner, + text: label, + color: '#b0b0b0', + fontSize: 13, + anchor: 'start', + hidden, + className: 'datum-label' + }); + } + }, + + /** + * Set size of all planes + */ + setSize(size) { + this.size = size; + for (const plane of Object.values(this.planes)) { + plane.setSize(size); + } + this.notifyChange(); + }, + + /** + * Set visibility of all planes + */ + setVisible(visible) { + this.visible = visible; + if (this.group) { + this.group.visible = visible; + } + this.notifyChange(); + }, + + /** + * Show specific plane + */ + show(planeName) { + if (this.planes[planeName]) { + this.planes[planeName].setVisible(true); + } else { + console.warn(`datum: unknown plane ${planeName}`); + } + }, + + /** + * Hide specific plane + */ + hide(planeName) { + if (this.planes[planeName]) { + this.planes[planeName].setVisible(false); + } else { + console.warn(`datum: unknown plane ${planeName}`); + } + }, + + /** + * Set opacity of all planes + */ + setOpacity(opacity) { + for (const plane of Object.values(this.planes)) { + plane.setOpacity(opacity); + } + }, + + /** + * Set color of specific plane + */ + setColor(planeName, color) { + if (this.planes[planeName]) { + this.planes[planeName].setColor(color); + } else { + console.warn(`datum: unknown plane ${planeName}`); + } + }, + + /** + * Set outline color of specific plane + */ + setOutlineColor(planeName, color) { + if (this.planes[planeName]) { + this.planes[planeName].setOutlineColor(color); + } else { + console.warn(`datum: unknown plane ${planeName}`); + } + }, + + /** + * Get plane primitive by name + */ + getPlane(planeName) { + return this.planes[planeName] || null; + }, + + /** + * Serialize datum to JSON + */ + toJSON() { + return { + type: 'datum', + size: this.size, + visible: this.visible, + planes: { + xy: this.planes.xy.toJSON(), + xz: this.planes.xz.toJSON(), + yz: this.planes.yz.toJSON() + } + }; + }, + + /** + * Canonical default datum state for new documents. + */ + defaultState(size = 200) { + return { + type: 'datum', + size, + visible: true, + planes: { + xy: { + id: 'datum-xy', + name: 'XY Plane', + label: 'Top', + type: 'plane', + size: { width: size, height: size }, + visible: true, + frame: { + origin: { x: 0, y: 0, z: 0 }, + normal: { x: 0, y: 0, z: 1 }, + x_axis: { x: 1, y: 0, z: 0 }, + size: { width: size, height: size } + } + }, + xz: { + id: 'datum-xz', + name: 'XZ Plane', + label: 'Front', + type: 'plane', + size: { width: size, height: size }, + visible: true, + frame: { + origin: { x: 0, y: 0, z: 0 }, + normal: { x: 0, y: -1, z: 0 }, + x_axis: { x: 1, y: 0, z: 0 }, + size: { width: size, height: size } + } + }, + yz: { + id: 'datum-yz', + name: 'YZ Plane', + label: 'Right', + type: 'plane', + size: { width: size, height: size }, + visible: true, + frame: { + origin: { x: 0, y: 0, z: 0 }, + normal: { x: 1, y: 0, z: 0 }, + x_axis: { x: 0, y: 1, z: 0 }, + size: { width: size, height: size } + } + } + } + }; + }, + + /** + * Apply serialized datum state onto existing runtime planes. + */ + applyJSON(data) { + if (!data || !this.planes) return; + + if (typeof data.size === 'number') { + this.size = data.size; + } + if (typeof data.visible === 'boolean') { + this.setVisible(data.visible); + } + + const planeData = data.planes || {}; + for (const [key, plane] of Object.entries(this.planes)) { + const src = planeData[key]; + if (!src) continue; + + if (src.name !== undefined) plane.name = src.name; + if (src.label !== undefined) plane.setLabel(src.label); + if (src.color !== undefined) plane.setColor(src.color); + if (src.outlineColor !== undefined) plane.setOutlineColor(src.outlineColor); + if (src.opacity !== undefined) plane.setOpacity(src.opacity); + if (src.outlineOpacity !== undefined) plane.setOutlineOpacity(src.outlineOpacity); + if (src.showHandles !== undefined) plane.showHandles = src.showHandles; + if (src.visible !== undefined) plane.setVisible(src.visible); + + if (src.frame) { + plane.setFrame(src.frame); + } else { + // Legacy fallback for older document data. + const width = src?.size?.width !== undefined ? src.size.width : (src.size !== undefined ? src.size : plane.size); + const height = src?.size?.height !== undefined ? src.size.height : (src.height !== undefined ? src.height : undefined); + plane.setSize(width, height); + + if (src.position) { + plane.setPosition( + src.position.x || 0, + src.position.y || 0, + src.position.z || 0 + ); + } + if (src.rotation) { + plane.setRotation( + src.rotation.x || 0, + src.rotation.y || 0, + src.rotation.z || 0 + ); + } + } + } + this.notifyChange(); + }, + + /** + * Register callback for datum-level changes. + */ + onChange(handler) { + if (typeof handler === 'function') { + this.changeHandlers.add(handler); + } + return this; + }, + + /** + * Remove registered datum-level callback. + */ + offChange(handler) { + this.changeHandlers.delete(handler); + return this; + }, + + /** + * Notify listeners datum changed. + */ + notifyChange() { + for (const handler of this.changeHandlers) { + handler(this); + } + }, + + /** + * Dispose of all resources + */ + dispose() { + if (this.group) { + for (const [key, plane] of Object.entries(this.planes)) { + const handler = this.labelHandlers.get(key); + if (handler) { + plane.offChange(handler); + } + plane.dispose(); + this.group.remove(plane.getGroup()); + } + this.planes = {}; + this.labelHandlers.clear(); + this.overlay = null; + this.group = null; + } + } +}; + +export { datum }; diff --git a/src/void/interact.js b/src/void/interact.js new file mode 100644 index 00000000..57d3690e --- /dev/null +++ b/src/void/interact.js @@ -0,0 +1,369 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; +import { space } from '../moto/space.js'; +import { datum } from './datum.js'; +import { api } from './api.js'; +import { properties } from './properties.js'; +import * as targetOps from './interact/targets.js'; +import * as pointOps from './interact/points.js'; +import * as selectionOps from './interact/selection.js'; +import * as planeOps from './interact/planes.js'; +import * as sketchOps from './sketch/index.js'; + +/** + * Interaction manager for void:form primitives + * Handles selection, hover, and dragging behaviors + */ +const interact = { + selectedPlanes: new Set(), + hoveredPlane: null, + selectedPoints: new Set(), + hoveredPoint: null, + draggedHandle: null, + draggedPlane: null, + dragHandleName: null, + dragAnchorPos: null, + dragStartSizes: new Map(), + dragStartCenters: new Map(), + planes: [], + upSelectCalled: false, + wasHandleDrag: false, + hoverIntersection: null, + handleScreenRadiusPx: 7, + handleBaseRadius: 4, + pointHitRadiusPx: 10, + pointIds: ['origin-point'], + _tmpWorldPos: new THREE.Vector3(), + sketchTool: 'select', + selectedSketchEntities: new Set(), + selectedSketchProfiles: new Set(), + selectedSolidFaceKeys: new Set(), + selectedSolidEdgeKeys: new Set(), + selectedSketchArcCenters: new Set(), + selectedSketchConstraints: new Set(), + hoveredSketchEntityId: null, + hoveredDerivedCandidate: null, + selectedDerivedSelections: new Map(), + hoveredSketchProfileKey: null, + hoveredSolidFaceKey: null, + hoveredSolidEdgeKey: null, + hoveredSketchConstraintId: null, + sketchPointerDown: null, + sketchDrag: null, + sketchLineStart: null, + sketchLineStartRefId: null, + sketchLineStartSeq: null, + sketchArcStart: null, + sketchArcStartRefId: null, + sketchArcEnd: null, + sketchArcEndRefId: null, + sketchArcPreview: null, + sketchCircleCenter: null, + sketchCircleSecond: null, + sketchCircleCenterRefId: null, + sketchCircleSecondRefId: null, + sketchCircleStartSeq: null, + sketchRectStart: null, + sketchRectStartRefId: null, + sketchRectStartSeq: null, + sketchRectPreview: null, + sketchRectCenterMode: false, + sketchMirrorMode: false, + sketchMirrorAxisId: null, + sketchCircularPatternMode: false, + sketchCircularPatternCenterRef: null, + sketchGridPatternMode: false, + sketchGridPatternCenterRef: null, + sketchPointerSeq: 0, + sketchMarquee: null, + sketchMarqueeEl: null, + _lastSketchDownStamp: null, + _lastSketchUpStamp: null, + _skipNextWindowSketchDown: false, + _skipNextWindowSketchUp: false, + _focusRaycaster: new THREE.Raycaster(), + _focusNDC: new THREE.Vector2(), + focusTweenMs: 120, + isSketchRetargetMode() { + if (!(this.isSketchEditing && this.isSketchEditing())) return false; + const featureId = properties.currentFeatureId || null; + if (!featureId) return false; + const feature = api.features?.findById?.(featureId); + if (!feature || feature.type !== 'sketch') return false; + const source = feature?.target?.source || null; + return !(source && source.type); + }, + + optionFocusOnDown(event) { + if (!event || event.button !== 2 || !event.altKey) { + return false; + } + const { camera, renderer } = space.internals(); + const canvas = renderer?.domElement || null; + if (!camera || !canvas) { + return false; + } + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) { + return false; + } + const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + const y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + this._focusNDC.set(x, y); + this._focusRaycaster.setFromCamera(this._focusNDC, camera); + const hits = this._focusRaycaster.intersectObjects(space.objects(), true); + if (!hits?.length) { + return false; + } + const hit = hits.find(rec => rec?.object?.visible !== false) || hits[0]; + const point = hit?.point; + if (!point) { + return false; + } + space.view.panTo(point.x, point.y, point.z, undefined, undefined, this.focusTweenMs); + return true; + }, + + init() { + this.planes = datum.getPlanes(); + + window.addEventListener('keydown', event => { + if (space.isFocused()) { + return false; + } + if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.code === 'KeyF') { + space.view.fit(null, { tween: true }); + event.preventDefault(); + return true; + } + let handled = false; + switch (event.code) { + case 'Space': + { + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId; + if (editingChamfer) { + handled = true; + break; + } + this.deselectAll(); + } + handled = true; + break; + case 'KeyN': + handled = this.viewNormalToHover(); + break; + case 'KeyP': + handled = this.toggleDatumPlanesVisibility(); + break; + default: + handled = this.handleSketchKeyDown(event); + break; + } + if (handled) { + event.preventDefault(); + } + }); + window.addEventListener('mousemove', event => { + if (this.isSketchEditing() && !this.isSketchRetargetMode()) { + this.handleSketchPointerMove?.(event); + } + }); + + space.mouse.down((event) => { + this.optionFocusOnDown(event); + }); + + space.mouse.downSelect((int, event, ints) => { + if (event && event.button !== 0) { + return; + } + if (this.isSketchEditing() && !this.isSketchRetargetMode()) { + if (!int && int !== null) { + return this.getInteractiveObjects(); + } + this._lastSketchDownStamp = event?.timeStamp ?? null; + this._skipNextWindowSketchDown = true; + this.handleSketchPointerDown(event, ints); + return; + } + if (!int && int !== null) { + return this.getInteractiveObjects(); + } + + let targetInt = int; + if (ints && ints.length > 0) { + const handleInt = ints.find(hit => { + const handleType = hit?.object?.userData?.handleType; + const plane = hit?.object?.userData?.plane; + return handleType === 'plane-resize' && plane && this.selectedPlanes.has(plane); + }); + if (handleInt) { + targetInt = handleInt; + } + } + + const obj = targetInt?.object; + const handleType = obj?.userData?.handleType; + if (handleType === 'plane-resize') { + this.startHandleDrag(obj, targetInt, event); + } + }); + + space.mouse.upSelect((int, event, ints) => { + if (event && event.button !== 0) { + return; + } + if (this.isSketchEditing() && !this.isSketchRetargetMode()) { + // Query phase from space.js: return selectable objects only. + if (!event && int === undefined) { + return this.getInteractiveObjects(); + } + this.upSelectCalled = true; + this._skipNextWindowSketchUp = true; + this.handleSketchMouseUp(event, ints); + this._lastSketchUpStamp = event?.timeStamp ?? null; + this.sketchPointerDown = null; + this.wasHandleDrag = false; + return; + } + if (!int && int !== null) { + this.wasHandleDrag = false; + return this.getInteractiveObjects(); + } + this.upSelectCalled = true; + if (!this.wasHandleDrag) { + this.handleMouseUp(int, event, ints); + } + this.wasHandleDrag = false; + }); + + space.mouse.up((event, ints) => { + if (event && event.button !== 0) { + return; + } + if (this.isSketchEditing() && !this.isSketchRetargetMode()) { + // sketch completion is handled by mouseUpSelect (preferred) + // or window mouseup fallback when mouseUpSelect is skipped. + return; + } + if (!this.upSelectCalled && !this.draggedHandle && !this.wasHandleDrag && ints && ints.length > 0) { + this.handleMouseUp(ints[0], event, ints); + } + this.upSelectCalled = false; + }); + window.addEventListener('mouseup', event => { + if (event && event.button !== 0) { + return; + } + if (!this.isSketchEditing() || this.isSketchRetargetMode()) { + return; + } + if (this._skipNextWindowSketchUp) { + this._skipNextWindowSketchUp = false; + this.upSelectCalled = false; + return; + } + if (event?.timeStamp && this._lastSketchUpStamp === event.timeStamp) { + this.upSelectCalled = false; + return; + } + // When space.js doesn't emit up/upSelect (empty selection array), + // complete the sketch interaction here. + if (!this.upSelectCalled && this.sketchPointerDown) { + this.handleSketchMouseUp(event); + this._lastSketchUpStamp = event?.timeStamp ?? null; + this.sketchPointerDown = null; + } + this.upSelectCalled = false; + }); + window.addEventListener('mousedown', event => { + if (event && event.button !== 0) { + return; + } + if (!this.isSketchEditing() || this.isSketchRetargetMode()) { + return; + } + if (this._skipNextWindowSketchDown) { + this._skipNextWindowSketchDown = false; + return; + } + if (this._lastSketchDownStamp !== null && event.timeStamp === this._lastSketchDownStamp) { + return; + } + const { container } = space.internals(); + if (!container || !container.contains(event.target)) { + return; + } + this.handleSketchPointerDown(event); + }); + + space.mouse.onHover((int, event, ints) => { + if (!int && int !== null) { + return this.getInteractiveObjects(); + } + this.handleHover(int, event, ints); + if (this.isSketchEditing()) { + this.handleSketchHover(event, ints); + } + }, () => { + this.handleHover(); + }); + + space.mouse.onDrag((delta, offset, isDone, intersections) => { + if (delta === undefined) { + if (this.draggedHandle) { + return []; + } + if (this.isSketchEditing()) { + return []; + } + return null; + } + + if (this.isSketchEditing()) { + this.handleSketchDrag(delta, offset, isDone, intersections); + if (isDone) { + this.sketchPointerDown = null; + } + return; + } + + if (isDone && this.draggedHandle) { + this.wasHandleDrag = true; + this.draggedHandle = null; + this.draggedPlane = null; + this.dragHandleName = null; + this.dragAnchorPos = null; + this.dragStartSizes.clear(); + this.dragStartCenters.clear(); + return; + } + + if (isDone && !this.draggedHandle && offset) { + const offsetMag = Math.sqrt(offset.x * offset.x + offset.y * offset.y); + if (offsetMag < 5) { + if (intersections && intersections.length > 0) { + this.handleMouseUp(intersections[0], { ctrlKey: false, metaKey: false }, intersections); + } + } + return; + } + + this.handleDrag(delta, offset, isDone, intersections); + }); + + this.setupHandleScaleHooks(); + this.updateHandleScreenScales(); + } +}; + +Object.assign(interact, pointOps); +Object.assign(interact, selectionOps); +Object.assign(interact, planeOps); +Object.assign(interact, targetOps); +Object.assign(interact, sketchOps); + +export { interact }; diff --git a/src/void/interact/planes.js b/src/void/interact/planes.js new file mode 100644 index 00000000..91d99d12 --- /dev/null +++ b/src/void/interact/planes.js @@ -0,0 +1,1338 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; +import { space } from '../../moto/space.js'; +import { api } from '../api.js'; +import { properties } from '../properties.js'; +import { resolveSelectionCandidate, SELECTION_INTENTS, SELECTION_MODES } from './selection_resolver.js'; + +function resolveChamferEdgeIdentity(edge = {}) { + const key = String(edge?.key || '').trim(); + if (key) return `key:${key}`; + const ref = String(edge?.boundary_segment_id || edge?.entity?.id || '').trim(); + const mapped = String(api.solids?.getEdgeKeyForBoundaryRef?.(ref) || '').trim(); + if (mapped) return `mapped:${mapped}`; + if (ref) return `ref:${ref}`; + const solidId = String(edge?.solidId || '').trim(); + const edgeIndex = Number(edge?.edgeIndex); + if (solidId && Number.isFinite(edgeIndex)) { + return `idx:${solidId}:${edgeIndex}`; + } + return null; +} + +function buildChamferEdgeIdentitySet(edge = {}) { + const set = new Set(); + const add = (value) => { + const v = String(value || '').trim(); + if (v) set.add(v); + }; + add(resolveChamferEdgeIdentity(edge)); + const key = String(edge?.key || '').trim(); + if (key) { + add(`key:${key}`); + add(`mapped:${key}`); + } + const ref = String(edge?.boundary_segment_id || edge?.entity?.id || '').trim(); + if (ref) { + add(`ref:${ref}`); + const mapped = String(api.solids?.getEdgeKeyForBoundaryRef?.(ref) || '').trim(); + if (mapped) add(`mapped:${mapped}`); + } + return set; +} + +function isEditingExtrudeProfiles() { + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + if (!currentFeature || currentFeature.type !== 'extrude' || currentFeature.id !== currentFeatureId) { + return false; + } + const role = properties.getExtrudePickRole?.() || 'profiles'; + return role === 'profiles'; +} + +function getInteractiveObjects() { + const objects = []; + const retargetMode = !!(this.isSketchRetargetMode && this.isSketchRetargetMode()); + for (const plane of this.planes) { + if (!plane?.getGroup?.().visible) { + continue; + } + // Add plane mesh and outline + objects.push(plane.mesh); + // objects.push(plane.outline); + // Add handles if plane is selected + if (this.selectedPlanes.has(plane)) { + objects.push(...plane.handles); + } + } + + if (!retargetMode) { + for (const rec of api.sketchRuntime?.sketches?.values?.() || []) { + if (!rec?.entitiesGroup?.visible) continue; + for (const view of rec.entityViews?.values?.() || []) { + if (view?.type === 'profile' && view.object?.visible !== false) { + objects.push(view.object); + } + } + } + } + for (const mesh of api.solids?.getPickMeshes?.() || []) { + objects.push(mesh); + } + + const sketchEditingActive = this.isSketchEditing && this.isSketchEditing(); + const sketchRetarget = this.isSketchRetargetMode && this.isSketchRetargetMode(); + const includeSolidEdges = !sketchEditingActive || (sketchEditingActive && !sketchRetarget); + if (includeSolidEdges) { + for (const edgeObj of api.solids?.getPickEdges?.() || []) { + objects.push(edgeObj); + } + } + if (sketchEditingActive && !sketchRetarget) { + const sketch = this.getEditingSketchFeature && this.getEditingSketchFeature(); + const rec = sketch?.id ? api.sketchRuntime?.getRecord?.(sketch.id) : null; + if (rec?.entityViews) { + const points = []; + const lines = []; + for (const view of rec.entityViews.values()) { + if (view?.object) { + if (view.type === 'point') { + const parts = view.object.userData?._markerParts || {}; + if (parts.core) { + points.push(parts.core); + } else { + points.push(view.object); + } + } else { + lines.push(view.object); + } + } + } + objects.push(...points, ...lines); + } + } + + // DON'T add trackPlane here - space.js adds it as trackTo separately + // This ensures it's detected as trackInt, not selectInt + + return objects; +} + +function registerPlane(plane) { + if (!this.planes.includes(plane)) { + this.planes.push(plane); + this.updateHandleScreenScales(); + } +} + +function unregisterPlane(plane) { + const index = this.planes.indexOf(plane); + if (index >= 0) { + this.planes.splice(index, 1); + } + this.selectedPlanes.delete(plane); + if (this.hoveredPlane === plane) { + this.hoveredPlane = null; + } + this.updateHandleScreenScales(); +} + +function setupHandleScaleHooks() { + const viewCtrl = space.view.ctrl; + if (viewCtrl && viewCtrl.addEventListener) { + viewCtrl.addEventListener('change', () => { + this.updateHandleScreenScales(); + }); + } + window.addEventListener('resize', () => { + this.updateHandleScreenScales(); + }); +} + +function updateHandleScreenScales() { + const { camera, renderer } = space.internals(); + if (!camera || !renderer) return; + + const viewHeightPx = renderer.domElement?.clientHeight || renderer.domElement?.height; + if (!viewHeightPx) return; + + for (const plane of this.planes) { + if (!plane?.handles?.length) continue; + for (const handle of plane.handles) { + handle.getWorldPosition(this._tmpWorldPos); + + let worldPerPixel; + if (camera.isPerspectiveCamera) { + const distance = camera.position.distanceTo(this._tmpWorldPos); + const fovRad = camera.fov * Math.PI / 180; + worldPerPixel = (2 * Math.tan(fovRad / 2) * distance) / viewHeightPx; + } else if (camera.isOrthographicCamera) { + worldPerPixel = ((camera.top - camera.bottom) / camera.zoom) / viewHeightPx; + } else { + continue; + } + + const desiredWorldRadius = this.handleScreenRadiusPx * worldPerPixel; + const scale = Math.max(0.001, desiredWorldRadius / this.handleBaseRadius); + handle.scale.setScalar(scale); + } + } +} + +function handleHover(intersection, event, allIntersections) { + const sketchEditing = !!(this.isSketchEditing && this.isSketchEditing()); + const retargetMode = !!(this.isSketchRetargetMode && this.isSketchRetargetMode()); + const editingExtrudeProfiles = !sketchEditing && isEditingExtrudeProfiles(); + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingChamfer = !sketchEditing && currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId; + const primaryHit = this.getPrimarySurfaceHitFromIntersections(allIntersections || (intersection ? [intersection] : [])); + + if (!sketchEditing || retargetMode) { + if (editingChamfer) { + if (primaryHit?.type === 'solid-edge') { + const edgeKey = String(primaryHit.hit?.key || ''); + let edge = null; + const worldPoint = primaryHit.hit?.intersection?.point || null; + if (worldPoint) { + const raw = String(primaryHit.hit?.key || ''); + let faceKey = ''; + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + const parts = raw.split(':'); + const fid = Number(parts[parts.length - 2]); + const sid = parts.slice(1, -2).join(':'); + if (sid && Number.isFinite(fid)) faceKey = `${sid}:${fid}`; + } + if (faceKey) { + const snap = api.solids?.getFaceEdgeHit?.(faceKey, worldPoint, 3.0) || null; + edge = snap?.key ? (api.solids?.getEdgeByKey?.(snap.key) || null) : null; + } + } + if (!edge && edgeKey) { + edge = api.solids?.getEdgeByKey?.(edgeKey) || null; + } + if (edge?.key) { + this.hoveredSolidEdgeKey = edge.key; + api.solids?.setHoveredEdge?.(edge.key); + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + } + this.hoverIntersection = primaryHit.hit?.intersection || intersection || null; + this.setHoveredPoint(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + } + let faceKey = null; + let worldPoint = null; + if (primaryHit?.type === 'solid-face') { + faceKey = String(primaryHit.hit?.key || ''); + worldPoint = primaryHit.hit?.intersection?.point || null; + } + if (faceKey && worldPoint) { + const snap = api.solids?.getFaceEdgeHit?.(faceKey, worldPoint, 3.0) || null; + const edge = snap?.key ? (api.solids?.getEdgeByKey?.(snap.key) || null) : null; + if (edge?.key) { + this.hoveredSolidEdgeKey = edge.key; + api.solids?.setHoveredEdge?.(edge.key); + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + } + this.hoverIntersection = snap.intersection || intersection || null; + this.setHoveredPoint(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + } + if (this.hoveredSolidEdgeKey) { + this.hoveredSolidEdgeKey = null; + api.solids?.setHoveredEdge?.(null); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + // Strict chamfer edit behavior: never fall through to live topology hover paths. + return; + } + if (primaryHit?.type === 'profile') { + const profileHit = primaryHit.hit; + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + } + const key = `${profileHit.featureId}:${profileHit.profileId}`; + this.hoveredSketchProfileKey = key; + api.sketchRuntime?.setHoveredProfile(key); + this.hoverIntersection = intersection || null; + this.setHoveredPoint(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + if (this.hoveredSketchProfileKey) { + this.hoveredSketchProfileKey = null; + api.sketchRuntime?.setHoveredProfile(null); + } + if (!editingExtrudeProfiles && primaryHit?.type === 'solid-edge') { + const solidEdgeHit = primaryHit.hit; + this.hoveredSolidEdgeKey = solidEdgeHit.key; + api.solids?.setHoveredEdge?.(solidEdgeHit.key); + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + } + this.hoverIntersection = solidEdgeHit.intersection || intersection || null; + this.setHoveredPoint(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + if (!editingExtrudeProfiles && primaryHit?.type === 'solid-face') { + const solidFaceHit = primaryHit.hit; + this.hoveredSolidFaceKey = solidFaceHit.key; + api.solids?.setHoveredFace?.(solidFaceHit.key); + if (this.hoveredSolidEdgeKey) { + this.hoveredSolidEdgeKey = null; + api.solids?.setHoveredEdge?.(null); + } + this.hoverIntersection = solidFaceHit.intersection || intersection || null; + this.setHoveredPoint(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + if (this.hoveredSolidEdgeKey) { + this.hoveredSolidEdgeKey = null; + api.solids?.setHoveredEdge?.(null); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + } else { + let nextFaceKey = null; + let nextIntersection = intersection || null; + if (primaryHit?.type === 'solid-edge') { + const edge = primaryHit.hit || null; + const solidId = String(edge?.solidId || ''); + const faceId = Number(edge?.faceId); + if (solidId && Number.isFinite(faceId)) { + nextFaceKey = `${solidId}:${faceId}`; + } else { + const raw = String(edge?.key || ''); + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + const parts = raw.split(':'); + const fid = Number(parts[parts.length - 2]); + const sid = parts.slice(1, -2).join(':'); + if (sid && Number.isFinite(fid)) nextFaceKey = `${sid}:${fid}`; + } + } + nextIntersection = edge?.intersection || nextIntersection; + } else if (primaryHit?.type === 'solid-face') { + const face = primaryHit.hit || null; + nextFaceKey = String(face?.key || '') || null; + nextIntersection = face?.intersection || nextIntersection; + } + if (nextFaceKey) { + this.hoveredSolidFaceKey = nextFaceKey; + // In sketch mode we render boundaries/projections, never solid-face fill hover. + api.solids?.setHoveredFace?.(null); + this.hoverIntersection = nextIntersection; + this.setHoveredPoint(null); + this.hoveredSketchProfileKey = null; + api.sketchRuntime?.setHoveredProfile?.(null); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + this.updateSketchInteractionVisuals?.(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + this.updateSketchInteractionVisuals?.(); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + } + + const pointHit = this.getPointHitFromEvent(event); + if (pointHit) { + this.hoverIntersection = null; + this.setHoveredPoint(pointHit.id); + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + return; + } + + this.setHoveredPoint(null); + + // No intersection means mouse left all objects + if (!intersection) { + this.hoverIntersection = null; + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + return; + } + + this.hoverIntersection = intersection; + + // Use first intersection (closest) - just like kiri/mesh + const plane = intersection.object?.userData?.plane; + const planeVisible = plane?.getGroup?.().visible !== false; + + if (plane && planeVisible && !plane.isSelected()) { + // Found a plane that's not selected + if (this.hoveredPlane !== plane) { + // Clear previous hover + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + } + // Set new hover + plane.setHovered(true); + this.hoveredPlane = plane; + } + } else if (!plane || !planeVisible || plane.isSelected()) { + // No plane found or plane is already selected, clear hover + if (this.hoveredPlane && !this.hoveredPlane.isSelected()) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + } +} + +function getPlaneFromIntersection(intersection) { + if (!intersection || !intersection.object) return null; + return intersection.object.userData?.plane || null; +} + +function getBestPlaneFromIntersections(allIntersections) { + if (!allIntersections || allIntersections.length === 0) return null; + + const internals = space.internals(); + const camera = internals.camera; + const cameraDir = new THREE.Vector3(); + camera.getWorldDirection(cameraDir); + + let bestPlane = null; + let bestDot = Infinity; + + for (const int of allIntersections) { + const plane = int.object?.userData?.plane; + if (!plane) continue; + if (plane.getGroup?.().visible === false) continue; + + if (int.face && int.face.normal) { + const normal = int.face.normal.clone(); + normal.transformDirection(int.object.matrixWorld); + const dot = normal.dot(cameraDir); + if (dot < bestDot) { + bestDot = dot; + bestPlane = plane; + } + } + } + + return bestPlane; +} + +function distancePointToSegment2D(px, py, ax, ay, bx, by) { + const abx = bx - ax; + const aby = by - ay; + const apx = px - ax; + const apy = py - ay; + const abLenSq = (abx * abx) + (aby * aby); + if (abLenSq <= 1e-12) return Math.hypot(px - ax, py - ay); + let t = ((apx * abx) + (apy * aby)) / abLenSq; + t = Math.max(0, Math.min(1, t)); + const qx = ax + (abx * t); + const qy = ay + (aby * t); + return Math.hypot(px - qx, py - qy); +} + +function getSelectedChamferEdgeHitFromScreen(event, maxPx = 10) { + if (!event) return null; + const keys = Array.from(this.selectedSolidEdgeKeys || []); + if (!keys.length) return null; + const { camera, renderer } = space.internals(); + const el = renderer?.domElement; + if (!camera || !el?.getBoundingClientRect) return null; + const rect = el.getBoundingClientRect(); + const mx = Number(event.clientX || 0) - rect.left; + const my = Number(event.clientY || 0) - rect.top; + const w = Math.max(1, rect.width || el.clientWidth || el.width || 1); + const h = Math.max(1, rect.height || el.clientHeight || el.height || 1); + const toScreen = (v) => { + const p = v.clone().project(camera); + return { x: ((p.x + 1) * 0.5) * w, y: ((1 - p.y) * 0.5) * h }; + }; + let bestKey = null; + let bestDist = Infinity; + for (const key of keys) { + const edge = api.solids?.getEdgeByKey?.(key) || null; + const path = Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2 + ? edge.pathWorld + : (edge?.aWorld && edge?.bWorld) ? [edge.aWorld, edge.bWorld] : null; + if (!path || path.length < 2) continue; + for (let i = 0; i + 1 < path.length; i++) { + const a = toScreen(path[i]); + const b = toScreen(path[i + 1]); + const d = distancePointToSegment2D(mx, my, a.x, a.y, b.x, b.y); + if (d < bestDist) { + bestDist = d; + bestKey = key; + } + } + } + if (!bestKey || bestDist > Math.max(2, Number(maxPx || 10))) return null; + return { key: bestKey }; +} + +function handleMouseUp(intersection, event, allIntersections) { + if (this.draggedHandle) { + this.draggedHandle = null; + this.draggedPlane = null; + this.dragHandleName = null; + this.dragAnchorPos = null; + this.dragStartSizes.clear(); + this.dragStartCenters.clear(); + return; + } + + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId; + + if (!(this.isSketchEditing && this.isSketchEditing()) || (this.isSketchRetargetMode && this.isSketchRetargetMode())) { + const primaryHit = this.getPrimarySurfaceHitFromIntersections(allIntersections || (intersection ? [intersection] : [])); + if (primaryHit?.type === 'profile') { + this.selectSketchProfile(primaryHit.hit, event); + return; + } + if (primaryHit?.type === 'solid-edge') { + this.selectSolidEdge(primaryHit.hit, event); + return; + } + if (primaryHit?.type === 'solid-face') { + this.selectSolidFace(primaryHit.hit, event); + return; + } + if (editingChamfer) { + const selectedHit = this.getSelectedChamferEdgeHitFromScreen?.(event, 10) || null; + if (selectedHit?.key) { + this.selectSolidEdge({ key: selectedHit.key, intersection: intersection || null }, event); + return; + } + } + if (isEditingExtrudeProfiles()) { + // While editing extrude profiles, ignore non-profile clicks so solids/planes + // do not steal interaction from profile picking. + return; + } + } + + const pointHit = this.getPointHitFromEvent(event); + if (pointHit) { + this.selectPoint(pointHit.id, event); + return; + } + + if (!intersection) { + const inViewport = this.isEventInsideViewport(event); + if (!inViewport) { + return; + } + if (editingChamfer) { + return; + } + if (!event.ctrlKey && !event.metaKey) { + this.deselectAll(); + } + return; + } + + const best = this.getBestPlaneFromIntersections(allIntersections); + const plane = (best && best.getGroup?.().visible !== false) ? best : intersection.object?.userData?.plane; + if (plane) { + this.selectPlane(plane, event); + } else if (!event.ctrlKey && !event.metaKey) { + if (editingChamfer) { + return; + } + this.deselectAll(); + } +} + +function getSketchProfileHitFromIntersections(intersections) { + if (!Array.isArray(intersections)) return null; + for (const hit of intersections) { + const obj = hit?.object; + const profileId = obj?.userData?.sketchProfileId || null; + const featureId = obj?.userData?.sketchFeatureId || null; + if (profileId && featureId) { + return { featureId, profileId, object: obj }; + } + } + return null; +} + +function getPrimarySurfaceHitFromIntersections(intersections) { + const sketchEditing = !!(this.isSketchEditing && this.isSketchEditing()); + const retargetMode = !!(this.isSketchRetargetMode && this.isSketchRetargetMode()); + const editingExtrudeProfiles = isEditingExtrudeProfiles(); + const mode = editingExtrudeProfiles + ? SELECTION_MODES.extrudeProfiles + : (sketchEditing + ? (retargetMode ? SELECTION_MODES.sketchRetarget : SELECTION_MODES.sketch) + : SELECTION_MODES.solid); + const edgeGateDistance = (sketchEditing && !retargetMode && !editingExtrudeProfiles) ? 0.5 : 2.5; + return resolveSelectionCandidate(intersections, { + api, + mode, + intents: [ + SELECTION_INTENTS.profile, + SELECTION_INTENTS.solidEdge, + SELECTION_INTENTS.solidFace + ], + retargetMode, + editingExtrudeProfiles, + sketchFaceEpsilon: 0.25, + edgeGateDistance + }); +} + +function selectSketchProfile(hit, event) { + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + if (currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId) { + return; + } + if (currentFeature?.type === 'extrude') { + const rawLoops = hit?.object?.userData?.sketchProfileLoops + || (hit?.object?.userData?.sketchProfileLoop ? [hit.object.userData.sketchProfileLoop] : null); + const loops = Array.isArray(rawLoops) + ? rawLoops + .filter(loop => Array.isArray(loop) && loop.length >= 3) + .map(loop => loop.map(p => ({ x: p?.x || 0, y: p?.y || 0 }))) + : []; + const regionId = `profile:${hit.featureId}:${hit.profileId}`; + const profile = { + region_id: regionId + }; + if (loops.length) { + profile.loops = loops; + } + const updated = api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + const current = Array.isArray(feature.input.profiles) ? feature.input.profiles : []; + const key = String(profile?.region_id || ''); + const has = current.some(p => String(p?.region_id || '') === key); + const next = has + ? current.filter(p => String(p?.region_id || '') !== key) + : [...current, profile]; + feature.input.profiles = next; + }, { + opType: 'feature.update', + payload: { field: 'profiles.toggle', profile } + }); + if (updated) { + properties.onChanged?.(); + } + return; + } + + const key = `${hit.featureId}:${hit.profileId}`; + // Sketch area/profile picking should be toggle/multi by default. + // Space/Escape remains the clear/deselect route. + const multi = true; + if (!this.selectedSketchProfiles.size) { + this.selectedSolidFaceKeys?.clear?.(); + this.selectedSolidEdgeKeys?.clear?.(); + this.hoveredSolidFaceKey = null; + this.hoveredSolidEdgeKey = null; + api.solids?.clearFaceSelection?.(); + api.solids?.clearEdgeSelection?.(); + } + if (this.selectedSketchProfiles.has(key)) { + this.selectedSketchProfiles.delete(key); + } else { + this.selectedSketchProfiles.add(key); + } + api.sketchRuntime?.setSelectedProfiles(Array.from(this.selectedSketchProfiles)); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function selectSolidFace(hit, event) { + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingSketch = currentFeature?.type === 'sketch' && currentFeature?.id === currentFeatureId; + const editingExtrude = currentFeature?.type === 'extrude' && currentFeature?.id === currentFeatureId; + const editingBoolean = currentFeature?.type === 'boolean' && currentFeature?.id === currentFeatureId; + const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId; + const extrudeOp = String(currentFeature?.params?.operation || 'new'); + const extrudeRole = properties.getExtrudePickRole?.() || 'profiles'; + const editingExtrudeTargets = editingExtrude + && (extrudeOp === 'add' || extrudeOp === 'subtract') + && extrudeRole === 'targets'; + const forceMulti = editingBoolean || editingExtrudeTargets; + + if (editingChamfer) { + // In chamfer edit mode, face clicks should never clear existing edge picks. + // If we're close to a boundary, treat the face click as an edge pick. + const facePoint = hit?.intersection?.point || null; + const edge = (hit?.key && facePoint) + ? api.solids?.getFaceEdgeHit?.(hit.key, facePoint, 3.0) + : null; + if (edge?.key) { + this.selectSolidEdge({ ...edge, intersection: hit?.intersection || null }, event); + } + return; + } + + const multi = forceMulti || !!(event?.ctrlKey || event?.metaKey); + if (!multi) { + for (const selectedPlane of this.selectedPlanes || []) { + selectedPlane.setSelected(false); + } + this.selectedPlanes?.clear?.(); + this.selectedSketchProfiles?.clear?.(); + this.clearSelectedPoints?.(); + this.selectedSolidEdgeKeys?.clear?.(); + this.hoveredSolidEdgeKey = null; + api.solids?.clearEdgeSelection?.(); + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + } + const selected = api.solids?.toggleSelectedFace?.(hit.key, multi) || []; + this.selectedSolidFaceKeys = new Set(selected); + this.hoveredSolidFaceKey = hit.key; + api.solids?.setHoveredFace?.(hit.key); + const hitSolidId = hit?.solidId || (() => { + const splitAt = String(hit?.key || '').lastIndexOf(':'); + return splitAt > 0 ? String(hit.key).substring(0, splitAt) : null; + })(); + const selectedSolidIds = Array.from(new Set(selected.map(key => { + const splitAt = String(key || '').lastIndexOf(':'); + return splitAt > 0 ? String(key).substring(0, splitAt) : null; + }).filter(Boolean))); + + if (editingSketch) { + const target = hit?.key ? api.solids?.getSketchTargetForFaceKey?.(hit.key) : null; + if (target?.frame) { + const updated = api.features.update(currentFeature.id, feature => { + const offset = Number(feature?.target?.offset || 0); + const hitPoint = hit?.intersection?.point || null; + feature.target = feature.target || {}; + feature.target.kind = 'face'; + feature.target.id = target.id || null; + feature.target.name = target.name || 'Face'; + feature.target.label = target.label || null; + feature.target.source = target.source || null; + if (feature.target.source) { + if (hitPoint) { + feature.target.source.anchor = { + x: Number(hitPoint.x || 0), + y: Number(hitPoint.y || 0), + z: Number(hitPoint.z || 0) + }; + } + const solidId = String(feature.target.source.solid_id || ''); + if (solidId) { + const solid = api.solids?.list?.().find?.(item => item?.id === solidId) || null; + feature.target.source.solid_feature_id = solid?.source?.feature_id || feature.target.source.solid_feature_id || null; + } + } + feature.target.offset = offset; + feature.plane = api.solids?.applyOffsetToFrame?.(target.frame, offset) || target.frame; + }, { + opType: 'feature.update', + payload: { field: 'target.face', key: hit?.key || null } + }); + if (updated) { + properties.onChanged?.(); + } + } + } else if (editingExtrude) { + const operation = String(currentFeature?.params?.operation || 'new'); + const pickRole = properties.getExtrudePickRole?.() || 'profiles'; + if ((operation === 'add' || operation === 'subtract') && pickRole === 'targets' && hitSolidId) { + const updated = api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + const current = Array.isArray(feature.input.targets) ? feature.input.targets.filter(Boolean) : []; + if (current.includes(hitSolidId)) { + feature.input.targets = current.filter(id => id !== hitSolidId); + } else { + feature.input.targets = [...current, hitSolidId]; + } + }, { + opType: 'feature.update', + payload: { field: 'targets.toggle', solidId: hitSolidId } + }); + if (updated) { + const nextFeature = api.features.findById(currentFeature.id); + const nextTargets = Array.isArray(nextFeature?.input?.targets) + ? nextFeature.input.targets.filter(Boolean) + : []; + api.solids?.setSelected?.(nextTargets); + properties.onChanged?.(); + } + } + } else if (editingBoolean) { + const mode = String(currentFeature?.params?.mode || 'add'); + const role = properties.getBooleanPickRole?.() || 'targets'; + const input = currentFeature?.input || {}; + let targets = Array.isArray(input.targets) ? input.targets.filter(Boolean) : []; + let tools = Array.isArray(input.tools) ? input.tools.filter(Boolean) : []; + if (hitSolidId) { + if (mode === 'subtract') { + if (role === 'tools') { + if (tools.includes(hitSolidId)) { + tools = tools.filter(id => id !== hitSolidId); + } else { + tools = [...tools, hitSolidId]; + targets = targets.filter(id => id !== hitSolidId); + } + } else { + if (targets.includes(hitSolidId)) { + targets = targets.filter(id => id !== hitSolidId); + } else { + targets = [...targets, hitSolidId]; + tools = tools.filter(id => id !== hitSolidId); + } + } + } else { + if (targets.includes(hitSolidId)) { + targets = targets.filter(id => id !== hitSolidId); + } else { + targets = [...targets, hitSolidId]; + } + tools = []; + } + } + const updated = api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + feature.input.targets = targets.slice(); + feature.input.tools = tools.slice(); + }, { + opType: 'feature.update', + payload: { field: 'boolean.inputs', targets, tools } + }); + if (updated) { + const selectedIds = mode === 'subtract' + ? Array.from(new Set([...targets, ...tools])) + : targets.slice(); + api.solids?.setSelected?.(selectedIds); + properties.onChanged?.(); + } + } + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function selectSolidEdge(hit, event) { + const key = hit?.key || null; + if (!key) return; + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId; + const multi = true; + if (!this.selectedSolidEdgeKeys?.size) { + for (const selectedPlane of this.selectedPlanes || []) { + selectedPlane.setSelected(false); + } + this.selectedPlanes?.clear?.(); + this.selectedSketchProfiles?.clear?.(); + this.clearSelectedPoints?.(); + this.selectedSolidFaceKeys?.clear?.(); + this.hoveredSolidFaceKey = null; + api.solids?.clearFaceSelection?.(); + } + if (editingChamfer) { + let edge = null; + const hoveredKey = String(this.hoveredSolidEdgeKey || ''); + if (hoveredKey) { + edge = api.solids?.getEdgeByKey?.(hoveredKey) || null; + } + const worldPoint = hit?.intersection?.point || this.hoverIntersection?.point || null; + let faceKey = String(this.hoveredSolidFaceKey || ''); + if (!faceKey) { + const raw = String(hit?.key || ''); + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + const parts = raw.split(':'); + const fid = Number(parts[parts.length - 2]); + const sid = parts.slice(1, -2).join(':'); + if (sid && Number.isFinite(fid)) faceKey = `${sid}:${fid}`; + } + } + if (!edge) { + const snap = (worldPoint && faceKey) + ? (api.solids?.getFaceEdgeHit?.(faceKey, worldPoint, 3.0) || null) + : null; + edge = snap?.key ? (api.solids?.getEdgeByKey?.(snap.key) || null) : null; + } + if (!edge) { + edge = api.solids?.getEdgeByKey?.(key) || null; + } + if (!edge) return; + const edgeEntity = api.solids?.resolveCanonicalEdgeEntity?.(edge.key || key) || null; + const refId = String(edgeEntity?.id || ''); + if (!refId) return; + const ref = { + key: edge.key, + boundary_segment_id: refId, + entity: { + kind: String(edgeEntity?.kind || 'boundary-segment'), + id: refId + }, + solidId: edge.solidId, + edgeIndex: edge.index, + meshEdgeKey: edge.meshEdgeKey || null + }; + if (Array.isArray(edge?.meshEdgeKeys) && edge.meshEdgeKeys.length) { + ref.meshEdgeKeys = edge.meshEdgeKeys.slice(); + } + if (Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2) { + ref.path = edge.pathWorld.map(p => ({ + x: Number(p?.x || 0), + y: Number(p?.y || 0), + z: Number(p?.z || 0) + })); + } + const existing = Array.isArray(currentFeature?.input?.edges) ? currentFeature.input.edges.slice() : []; + const refResolved = api.solids?.resolveChamferRefToEdgeKey?.(ref) || null; + const refIds = buildChamferEdgeIdentitySet(ref); + const has = existing.some(item => { + const itemResolved = api.solids?.resolveChamferRefToEdgeKey?.(item) || null; + if (refResolved && itemResolved && refResolved === itemResolved) { + return true; + } + const ids = buildChamferEdgeIdentitySet(item); + for (const id of ids) { + if (refIds.has(id)) return true; + } + return false; + }); + const edgeRefs = has + ? existing.filter(item => { + const itemResolved = api.solids?.resolveChamferRefToEdgeKey?.(item) || null; + if (refResolved && itemResolved && refResolved === itemResolved) { + return false; + } + const ids = buildChamferEdgeIdentitySet(item); + for (const id of ids) { + if (refIds.has(id)) return false; + } + return true; + }) + : [...existing, ref]; + const selectedKeys = edgeRefs + .map(item => { + const resolved = api.solids?.resolveChamferRefToEdgeKey?.(item) || null; + if (!resolved) return null; + return String(resolved).startsWith('segment:') + ? String(resolved).substring('segment:'.length) + : String(resolved); + }) + .filter(Boolean); + this.selectedSolidEdgeKeys = new Set(selectedKeys); + this.hoveredSolidEdgeKey = edge.key; + api.solids?.setSelectedEdges?.(selectedKeys); + api.solids?.setHoveredEdge?.(edge.key); + api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + feature.input.edges = edgeRefs; + }, { + opType: 'feature.update', + payload: { field: 'edges.set', edges: edgeRefs } + }); + properties.onChanged?.(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + const selected = api.solids?.toggleSelectedEdge?.(key, multi) || []; + this.selectedSolidEdgeKeys = new Set(selected); + this.hoveredSolidEdgeKey = key; + api.solids?.setHoveredEdge?.(key); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function startHandleDrag(handle, intersection, event) { + const plane = handle.userData.plane; + if (!plane) return; + + this.draggedHandle = handle; + this.draggedPlane = plane; + this.dragHandleName = handle.userData.handleName; + + const oppositeCornerName = this.getOppositeCorner(this.dragHandleName); + const oppositeHandle = plane.handles.find(h => h.userData.handleName === oppositeCornerName); + + if (oppositeHandle) { + this.dragAnchorPos = new THREE.Vector3(); + oppositeHandle.getWorldPosition(this.dragAnchorPos); + } + + this.dragStartSizes.clear(); + this.dragStartCenters = new Map(); + for (const selectedPlane of this.selectedPlanes) { + this.dragStartSizes.set(selectedPlane, selectedPlane.size); + const center = new THREE.Vector3(); + selectedPlane.group.getWorldPosition(center); + this.dragStartCenters.set(selectedPlane, center); + } +} + +function getOppositeCorner(cornerName) { + const opposites = { + 'top-right': 'bottom-left', + 'top-left': 'bottom-right', + 'bottom-right': 'top-left', + 'bottom-left': 'top-right' + }; + return opposites[cornerName]; +} + +function handleDrag(delta, offset, isDone, intersections) { + if (!this.draggedHandle || !this.draggedPlane) return; + if (isDone) return; + + const event = delta.event; + if (!event) return; + + const internals = space.internals(); + const camera = internals.camera; + const container = internals.container; + + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const mouseNDC = new THREE.Vector2( + (x / rect.width) * 2 - 1, + -(y / rect.height) * 2 + 1 + ); + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(mouseNDC, camera); + + this.draggedPlane.group.updateMatrixWorld(true); + + const planeNormal = new THREE.Vector3(); + this.draggedPlane.mesh.matrixWorld.extractBasis( + new THREE.Vector3(), + new THREE.Vector3(), + planeNormal + ); + + const planeCenter = new THREE.Vector3(); + this.draggedPlane.group.getWorldPosition(planeCenter); + + const intersectPlane = new THREE.Plane().setFromNormalAndCoplanarPoint(planeNormal, planeCenter); + const newHandlePos = new THREE.Vector3(); + raycaster.ray.intersectPlane(intersectPlane, newHandlePos); + + if (!newHandlePos || !this.dragAnchorPos) return; + + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const zAxis = new THREE.Vector3(); + this.draggedPlane.mesh.matrixWorld.extractBasis(xAxis, yAxis, zAxis); + + const diagonal = new THREE.Vector3().subVectors(newHandlePos, this.dragAnchorPos); + const newWidth = Math.max(10, Math.abs(diagonal.dot(xAxis))); + const newHeight = Math.max(10, Math.abs(diagonal.dot(yAxis))); + const newCenterWorld = new THREE.Vector3().addVectors(this.dragAnchorPos, newHandlePos).multiplyScalar(0.5); + + this.draggedPlane.setSize(newWidth, newHeight); + + if (this.draggedPlane.group.parent) { + const newCenterLocal = this.draggedPlane.group.parent.worldToLocal(newCenterWorld.clone()); + this.draggedPlane.group.position.copy(newCenterLocal); + } else { + this.draggedPlane.group.position.copy(newCenterWorld); + } + + this.draggedPlane.notifyChange(); + this.updateHandleScreenScales(); + space.update(); +} + +function viewNormalToHover() { + let target = this.resolveTreeHoverNormalTarget() || this.resolveViewNormalTarget(this.hoverIntersection) || this.resolveViewNormalFromSelection(); + if (!target && this.isSketchEditing && this.isSketchEditing()) { + const sketch = this.getEditingSketchFeature && this.getEditingSketchFeature(); + const rec = sketch?.id ? api.sketchRuntime?.getRecord?.(sketch.id) : null; + const runtimePlane = rec?.plane; + if (runtimePlane?.mesh && runtimePlane?.group) { + runtimePlane.mesh.updateMatrixWorld(true); + runtimePlane.group.updateMatrixWorld(true); + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const normal = new THREE.Vector3(); + runtimePlane.mesh.matrixWorld.extractBasis(xAxis, yAxis, normal); + normal.normalize(); + const point = new THREE.Vector3(); + runtimePlane.group.getWorldPosition(point); + target = { normal, point }; + } else if (sketch?.plane) { + const frame = sketch.plane; + const normal = new THREE.Vector3( + frame.normal?.x ?? 0, + frame.normal?.y ?? 0, + frame.normal?.z ?? 1 + ).normalize(); + const point = new THREE.Vector3( + frame.origin?.x || 0, + frame.origin?.y || 0, + frame.origin?.z || 0 + ); + target = { normal, point }; + } + } + if (!target) { + return false; + } + + const { normal, point } = target; + const { camera } = space.internals(); + const focus = space.view.getFocus().clone(); + + const camDir = camera.position.clone().sub(focus).normalize(); + const normalA = normal.clone().normalize(); + const normalB = normalA.clone().negate(); + const offsetDir = camDir.dot(normalA) >= camDir.dot(normalB) ? normalA : normalB; + + const left = Math.atan2(offsetDir.x, offsetDir.z); + const up = Math.acos(Math.max(-1, Math.min(1, offsetDir.y))); + const snappedUp = getSnappedCameraUpForNormal(camera, offsetDir); + space.view.panTo(point.x, point.y, point.z, left, up, undefined, snappedUp || undefined); + return true; +} + +function getSnappedCameraUpForNormal(camera, viewDir) { + const dir = viewDir.clone().normalize(); + const currentUpProjected = camera.up.clone().projectOnPlane(dir); + if (currentUpProjected.lengthSq() < 1e-8) { + currentUpProjected.set(0, 1, 0).projectOnPlane(dir); + } + if (currentUpProjected.lengthSq() < 1e-8) { + return null; + } + currentUpProjected.normalize(); + + const basis = [ + new THREE.Vector3(1, 0, 0), + new THREE.Vector3(-1, 0, 0), + new THREE.Vector3(0, 1, 0), + new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 0, 1), + new THREE.Vector3(0, 0, -1) + ]; + + let best = null; + let bestScore = -Infinity; + for (const axis of basis) { + const projected = axis.clone().projectOnPlane(dir); + const lenSq = projected.lengthSq(); + if (lenSq < 1e-8) continue; + projected.normalize(); + const score = projected.dot(currentUpProjected); + if (score > bestScore) { + bestScore = score; + best = projected; + } + } + return best; +} + +function resolveTreeHoverNormalTarget() { + const hoveredSketchId = api.sketchRuntime?.hoveredId; + if (hoveredSketchId) { + const rec = api.sketchRuntime?.getRecord?.(hoveredSketchId); + const runtimePlane = rec?.plane; + if (runtimePlane?.mesh && runtimePlane?.group) { + runtimePlane.mesh.updateMatrixWorld(true); + runtimePlane.group.updateMatrixWorld(true); + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const normal = new THREE.Vector3(); + runtimePlane.mesh.matrixWorld.extractBasis(xAxis, yAxis, normal); + normal.normalize(); + const point = new THREE.Vector3(); + runtimePlane.group.getWorldPosition(point); + return { normal, point }; + } + } + + for (const plane of this.planes || []) { + if (!plane?.isHovered?.()) continue; + if (plane?.getGroup?.() && !plane.getGroup().visible) continue; + if (!plane?.mesh || !plane?.group) continue; + plane.mesh.updateMatrixWorld(true); + plane.group.updateMatrixWorld(true); + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const normal = new THREE.Vector3(); + plane.mesh.matrixWorld.extractBasis(xAxis, yAxis, normal); + normal.normalize(); + const point = new THREE.Vector3(); + plane.group.getWorldPosition(point); + return { normal, point }; + } + + return null; +} + +function toggleDatumPlanesVisibility() { + if (!Array.isArray(this.planes) || this.planes.length === 0) { + return false; + } + const allVisible = this.planes.every(plane => plane?.getGroup?.().visible !== false); + const nextVisible = !allVisible; + for (const plane of this.planes) { + plane?.setVisible?.(nextVisible); + } + return true; +} + +function resolveViewNormalFromSelection() { + if (this.selectedPlanes.size !== 1) { + return null; + } + const plane = this.selectedPlanes.values().next().value; + if (plane?.getGroup && !plane.getGroup().visible) { + return null; + } + if (!plane?.mesh || !plane?.group) { + return null; + } + + plane.mesh.updateMatrixWorld(true); + plane.group.updateMatrixWorld(true); + + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const normal = new THREE.Vector3(); + plane.mesh.matrixWorld.extractBasis(xAxis, yAxis, normal); + normal.normalize(); + + const point = new THREE.Vector3(); + plane.group.getWorldPosition(point); + + return { normal, point }; +} + +function resolveViewNormalTarget(intersection) { + const object = intersection?.object; + if (!object) return null; + const plane = object.userData?.plane; + if (plane && !plane.getGroup?.().visible) { + return null; + } + + const resolver = object.userData?.viewNormalResolver; + if (typeof resolver === 'function') { + const resolved = resolver({ intersection, object }); + if (resolved?.normal && resolved?.point) { + return resolved; + } + } + + object.updateMatrixWorld(true); + + let normal = null; + if (intersection.face?.normal) { + normal = intersection.face.normal.clone().transformDirection(object.matrixWorld).normalize(); + } + + if (!normal && object.userData?.plane?.mesh) { + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + normal = new THREE.Vector3(); + object.userData.plane.mesh.matrixWorld.extractBasis(xAxis, yAxis, normal); + normal.normalize(); + } + + if (!normal) return null; + + const point = this.getFaceCenterWorld(intersection, object) || (() => { + const p = new THREE.Vector3(); + object.getWorldPosition(p); + return p; + })(); + + return { normal, point }; +} + +function getFaceCenterWorld(intersection, object) { + if (object.userData?.plane?.group) { + const center = new THREE.Vector3(); + object.userData.plane.group.getWorldPosition(center); + return center; + } + + const geom = object.geometry; + const face = intersection.face; + + if (geom?.attributes?.position && face) { + const pos = geom.attributes.position; + const a = new THREE.Vector3().fromBufferAttribute(pos, face.a); + const b = new THREE.Vector3().fromBufferAttribute(pos, face.b); + const c = new THREE.Vector3().fromBufferAttribute(pos, face.c); + const center = a.add(b).add(c).multiplyScalar(1 / 3); + return center.applyMatrix4(object.matrixWorld); + } + + return null; +} + +export { + getInteractiveObjects, + registerPlane, + unregisterPlane, + setupHandleScaleHooks, + updateHandleScreenScales, + handleHover, + getPlaneFromIntersection, + getBestPlaneFromIntersections, + getSelectedChamferEdgeHitFromScreen, + handleMouseUp, + getSketchProfileHitFromIntersections, + getPrimarySurfaceHitFromIntersections, + selectSketchProfile, + selectSolidEdge, + selectSolidFace, + startHandleDrag, + getOppositeCorner, + handleDrag, + viewNormalToHover, + resolveTreeHoverNormalTarget, + toggleDatumPlanesVisibility, + resolveViewNormalFromSelection, + resolveViewNormalTarget, + getFaceCenterWorld +}; diff --git a/src/void/interact/points.js b/src/void/interact/points.js new file mode 100644 index 00000000..b73e561f --- /dev/null +++ b/src/void/interact/points.js @@ -0,0 +1,137 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { space } from '../../moto/space.js'; +import { overlay } from '../overlay.js'; +import { api } from '../api.js'; + +function getPointHitFromEvent(event) { + if (!event || !overlay?.elements) { + return null; + } + + const { container } = space.internals(); + if (!container) { + return null; + } + + const rect = container.getBoundingClientRect(); + const ex = event.clientX - rect.left; + const ey = event.clientY - rect.top; + + let best = null; + for (const id of this.pointIds) { + const item = overlay.elements.get(id); + if (!item || item.type !== 'point' || !item.pos3d || item.opts?.hidden) { + continue; + } + const proj = overlay.project3Dto2D(item.pos3d); + if (!proj || !proj.visible) { + continue; + } + const dx = ex - proj.x; + const dy = ey - proj.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > this.pointHitRadiusPx) { + continue; + } + if (!best || dist < best.dist) { + best = { id, item, dist }; + } + } + + return best; +} + +function setHoveredPoint(id) { + if (this.hoveredPoint === id) { + return; + } + const prev = this.hoveredPoint; + this.hoveredPoint = id; + if (prev) { + this.applyPointAppearance(prev); + } + if (id) { + this.applyPointAppearance(id); + } +} + +function clearSelectedPoints() { + if (!this.selectedPoints.size) { + return; + } + const ids = Array.from(this.selectedPoints); + this.selectedPoints.clear(); + for (const id of ids) { + this.applyPointAppearance(id); + } +} + +function selectPoint(id, event) { + const multiSelect = event && (event.ctrlKey || event.metaKey); + + if (!multiSelect) { + // Points are selected like planes: single-select clears prior selection. + for (const plane of this.selectedPlanes) { + plane.setSelected(false); + } + this.selectedPlanes.clear(); + this.selectedSolidFaceKeys?.clear?.(); + this.selectedSolidEdgeKeys?.clear?.(); + this.hoveredSolidFaceKey = null; + this.hoveredSolidEdgeKey = null; + this.selectedSketchProfiles?.clear?.(); + this.hoveredSketchProfileKey = null; + this.clearSketchSelection?.(); + this.cancelSketchLine?.(); + this.setSketchTool?.('select'); + api.solids?.clearFaceSelection?.(); + api.solids?.clearEdgeSelection?.(); + this.clearSelectedPoints(); + this.selectedPoints.add(id); + } else { + if (this.selectedPoints.has(id)) { + this.selectedPoints.delete(id); + } else { + this.selectedPoints.add(id); + } + } + this.applyPointAppearance(id); + this.updateHandleScreenScales(); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function applyPointAppearance(id) { + const item = overlay?.elements?.get(id); + if (!item?.el) { + return; + } + + if (item.opts?.hidden) { + item.el.style.display = 'none'; + return; + } + + const isSelected = this.selectedPoints.has(id); + const isHovered = this.hoveredPoint === id; + + const defaultFill = 'rgba(140, 140, 140, 0.45)'; + const defaultStroke = '#5a9fd4'; + const hoverStroke = '#ff9933'; + + const fill = isSelected ? 'rgba(160, 160, 160, 0.6)' : defaultFill; + const stroke = (isSelected || isHovered) ? hoverStroke : defaultStroke; + const strokeWidth = isSelected ? 2.5 : (isHovered ? 2.2 : 2); + + item.el.setAttribute('fill', fill); + item.el.setAttribute('stroke', stroke); + item.el.setAttribute('stroke-width', String(strokeWidth)); +} + +export { + getPointHitFromEvent, + setHoveredPoint, + clearSelectedPoints, + selectPoint, + applyPointAppearance +}; diff --git a/src/void/interact/selection.js b/src/void/interact/selection.js new file mode 100644 index 00000000..1949d649 --- /dev/null +++ b/src/void/interact/selection.js @@ -0,0 +1,134 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { space } from '../../moto/space.js'; +import { api } from '../api.js'; +import { properties } from '../properties.js'; + +function selectPlane(plane, event) { + const multiSelect = event && (event.ctrlKey || event.metaKey); + const currentFeatureId = properties.currentFeatureId || null; + const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null; + const editingSketch = currentFeature?.type === 'sketch' && currentFeature?.id === currentFeatureId; + + if (multiSelect) { + // Toggle selection with Ctrl/Cmd + if (this.selectedPlanes.has(plane)) { + // Already selected - deselect it + plane.setSelected(false); + this.selectedPlanes.delete(plane); + } else { + // Not selected - add to selection + plane.setSelected(true); + plane.setHovered(false); + this.selectedPlanes.add(plane); + } + } else { + // Single select - deselect all others + for (const selectedPlane of this.selectedPlanes) { + if (selectedPlane !== plane) { + selectedPlane.setSelected(false); + } + } + this.selectedPlanes.clear(); + this.clearSelectedPoints(); + this.selectedSketchProfiles?.clear?.(); + this.selectedSolidFaceKeys?.clear?.(); + this.selectedSolidEdgeKeys?.clear?.(); + this.hoveredSolidFaceKey = null; + this.hoveredSolidEdgeKey = null; + api.solids?.clearFaceSelection?.(); + api.solids?.clearEdgeSelection?.(); + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + + // Select the new plane + plane.setSelected(true); + plane.setHovered(false); + this.selectedPlanes.add(plane); + } + this.updateHandleScreenScales(); + if (editingSketch && plane?.getFrame) { + const updated = api.features.update(currentFeature.id, feature => { + const offset = Number(feature?.target?.offset || 0); + feature.target = feature.target || {}; + feature.target.kind = 'plane'; + feature.target.id = plane.id || null; + feature.target.name = plane.name || plane.label || 'Plane'; + feature.target.label = plane.label || null; + feature.target.source = { type: 'plane', id: plane.id || null }; + feature.target.offset = offset; + const frame = plane.getFrame(); + feature.plane = api.solids?.applyOffsetToFrame?.(frame, offset) || frame; + }, { + opType: 'feature.update', + payload: { field: 'target.plane', id: plane?.id || null } + }); + if (updated) { + properties.onChanged?.(); + } + } + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function isEventInsideViewport(event) { + if (!event) return true; + const { container } = space.internals(); + if (!container) return true; + const target = event.target; + if (!target) return true; + return container.contains(target); +} + +function deselectAll() { + for (const plane of this.selectedPlanes) { + plane.setSelected(false); + } + this.selectedPlanes.clear(); + + if (this.hoveredPlane) { + this.hoveredPlane.setHovered(false); + this.hoveredPlane = null; + } + this.setHoveredPoint(null); + this.clearSelectedPoints(); + this.selectedSketchProfiles?.clear?.(); + this.hoveredSketchProfileKey = null; + this.selectedSolidFaceKeys?.clear?.(); + this.selectedSolidEdgeKeys?.clear?.(); + this.hoveredSolidFaceKey = null; + this.hoveredSolidEdgeKey = null; + api.solids?.clearFaceSelection?.(); + api.solids?.clearEdgeSelection?.(); + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + this.clearSketchSelection?.(); + this.cancelSketchLine?.(); + this.stopSketchMirrorMode?.(); + this.stopSketchCircularPatternMode?.(); + this.stopSketchGridPatternMode?.(); + this.setSketchTool?.('select'); + this.sketchPointerDown = null; + this.sketchDrag = null; + if (api?.sketchRuntime) { + api.sketchRuntime._glyphDrag = null; + } + this.updateHandleScreenScales(); + window.dispatchEvent(new CustomEvent('void-clear-selection')); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function getSelected() { + return this.selectedPlanes; +} + +function isSelected(plane) { + return this.selectedPlanes.has(plane); +} + +export { + selectPlane, + isEventInsideViewport, + deselectAll, + getSelected, + isSelected +}; diff --git a/src/void/interact/selection_resolver.js b/src/void/interact/selection_resolver.js new file mode 100644 index 00000000..d0e2da41 --- /dev/null +++ b/src/void/interact/selection_resolver.js @@ -0,0 +1,185 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; + +const DEFAULTS = Object.freeze({ + sketchFaceEpsilon: 0.25, + edgeGateDistance: 2.5 +}); + +const SELECTION_INTENTS = Object.freeze({ + profile: 'profile', + solidFace: 'solid-face', + solidEdge: 'solid-edge', + point: 'point', + segment: 'segment', + boundary: 'boundary', + surface: 'surface', + region: 'region' +}); + +const SELECTION_MODES = Object.freeze({ + sketch: 'sketch', + sketchRetarget: 'sketch-retarget', + extrudeProfiles: 'extrude-profiles', + solid: 'solid' +}); + +function resolvePrimarySurfaceHit(intersections, options = {}) { + if (!Array.isArray(intersections)) return null; + + const { + api = null, + retargetMode = false, + editingExtrudeProfiles = false, + sketchFaceEpsilon = DEFAULTS.sketchFaceEpsilon, + edgeGateDistance = DEFAULTS.edgeGateDistance + } = options; + + let nearestProfile = null; + let nearestSolidFace = null; + + for (const hit of intersections) { + const obj = hit?.object; + if (!obj) continue; + + const profileId = obj.userData?.sketchProfileId || null; + const featureId = obj.userData?.sketchFeatureId || null; + if (!retargetMode && !nearestProfile && profileId && featureId) { + nearestProfile = { + type: 'profile', + distance: Number(hit?.distance) || 0, + hit: { featureId, profileId, object: obj, intersection: hit }, + entity: { + kind: 'region', + id: `profile:${featureId}:${profileId}` + } + }; + } + + if (!nearestSolidFace) { + const solidFaceHit = api?.solids?.getFaceHitFromIntersections?.([hit]); + if (solidFaceHit) { + const faceEntity = api?.solids?.resolveCanonicalFaceEntity?.(solidFaceHit.key) || null; + nearestSolidFace = { + type: 'solid-face', + distance: Number(hit?.distance) || 0, + hit: solidFaceHit, + entity: faceEntity || null + }; + } + } + } + + let nearestSolidEdge = null; + if (nearestSolidFace?.hit?.key && nearestSolidFace?.hit?.intersection?.point) { + // Primary path: resolve boundary from the currently hovered face itself. + // This keeps edge picks aligned with face boundaries and avoids cross-face + // edge steals (e.g. cylinder seam lines). + const edge = api?.solids?.getFaceEdgeHit?.( + nearestSolidFace.hit.key, + nearestSolidFace.hit.intersection.point, + edgeGateDistance + ); + if (edge) { + nearestSolidEdge = { + type: 'solid-edge', + // Slightly prefer edge over owning face when near boundary. + distance: Math.max(0, (nearestSolidFace.distance || 0) - 1e-4), + hit: { + ...edge, + intersection: nearestSolidFace.hit.intersection + }, + entity: api?.solids?.resolveCanonicalEdgeEntity?.(edge.key) || null + }; + } + } + + if (!nearestSolidEdge && nearestSolidFace?.hit?.solidId) { + // Fallback: use rendered edge intersections on the same solid. + const sameSolidEdgeInts = intersections.filter(hit => { + const obj = hit?.object; + return obj?.userData?.solidEdge === true + && String(obj?.userData?.solidId || '') === String(nearestSolidFace.hit.solidId || ''); + }); + if (sameSolidEdgeInts.length) { + const edgeHit = api?.solids?.getEdgeHitFromIntersections?.(sameSolidEdgeInts) || null; + if (edgeHit?.aWorld && edgeHit?.bWorld) { + const hitEdge = { + key: `${edgeHit.solidId}:${edgeHit.index}`, + solidId: edgeHit.solidId, + index: edgeHit.index, + aWorld: edgeHit.aWorld, + bWorld: edgeHit.bWorld, + midWorld: edgeHit.midWorld + }; + nearestSolidEdge = { + type: 'solid-edge', + distance: Number(edgeHit?.intersection?.distance) || Math.max(0, (nearestSolidFace.distance || 0) - 1e-4), + hit: { + ...hitEdge, + intersection: edgeHit.intersection || nearestSolidFace.hit.intersection + }, + entity: api?.solids?.resolveCanonicalEdgeEntity?.(hitEdge.key) || null + }; + } + } + } + + if (editingExtrudeProfiles) { + return nearestProfile || null; + } + if (nearestProfile && nearestSolidEdge && nearestSolidFace) { + return [nearestProfile, nearestSolidEdge, nearestSolidFace] + .sort((a, b) => a.distance - b.distance)[0]; + } + if (nearestProfile && nearestSolidEdge) { + const delta = nearestProfile.distance - nearestSolidEdge.distance; + if (delta <= sketchFaceEpsilon) return nearestProfile; + return nearestSolidEdge; + } + if (nearestProfile && nearestSolidFace) { + const delta = nearestProfile.distance - nearestSolidFace.distance; + if (delta <= sketchFaceEpsilon) { + return nearestProfile; + } + return nearestSolidFace; + } + if (nearestSolidEdge && nearestSolidFace) { + return nearestSolidEdge.distance <= nearestSolidFace.distance + sketchFaceEpsilon + ? nearestSolidEdge + : nearestSolidFace; + } + if (nearestProfile) return nearestProfile; + if (nearestSolidEdge) return nearestSolidEdge; + if (nearestSolidFace) return nearestSolidFace; + return null; +} + +function resolveSelectionCandidate(intersections, options = {}) { + const mode = options.mode || SELECTION_MODES.solid; + const intents = new Set(Array.isArray(options.intents) ? options.intents : []); + + // Phase 1 parity routing: + // keep current behavior, but route through explicit mode/intent context. + if (mode === SELECTION_MODES.extrudeProfiles) { + return resolvePrimarySurfaceHit(intersections, { + ...options, + editingExtrudeProfiles: true + }); + } + if (intents.size === 0 + || intents.has(SELECTION_INTENTS.profile) + || intents.has(SELECTION_INTENTS.solidFace) + || intents.has(SELECTION_INTENTS.solidEdge)) { + return resolvePrimarySurfaceHit(intersections, options); + } + return null; +} + +export { + SELECTION_INTENTS, + SELECTION_MODES, + resolvePrimarySurfaceHit, + resolveSelectionCandidate +}; diff --git a/src/void/interact/targets.js b/src/void/interact/targets.js new file mode 100644 index 00000000..5c00790f --- /dev/null +++ b/src/void/interact/targets.js @@ -0,0 +1,149 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; + +import { api } from '../api.js'; + +function getPrimarySketchTarget() { + return this.resolveSketchTarget(this.hoverIntersection) || this.resolveSketchTargetFromSelection(); +} + +function resolveSketchTargetFromSelection() { + const selectedFaceKeys = api.solids?.getSelectedFaceKeys?.() || Array.from(this.selectedSolidFaceKeys || []); + const selectedFaceCount = selectedFaceKeys.length; + if (selectedFaceCount > 0) { + if (selectedFaceCount === 1) { + const key = selectedFaceKeys[0]; + const target = api.solids?.getSketchTargetForFaceKey?.(key); + if (target?.frame) { + return target; + } + } + // If any solid face is selected but it's not a valid sketch target (for now: non-planar), + // do not silently fall back to a datum plane. + return null; + } + if (this.selectedPlanes.size !== 1) { + return null; + } + const plane = this.selectedPlanes.values().next().value; + return this.sketchTargetFromPlane(plane); +} + +function resolveSketchTarget(intersection) { + const object = intersection?.object; + if (!object) return null; + + const resolver = object.userData?.sketchTargetResolver; + if (typeof resolver === 'function') { + const resolved = resolver({ intersection, object }); + if (resolved?.frame) { + return resolved; + } + } + + const plane = object.userData?.plane; + if (plane) { + return this.sketchTargetFromPlane(plane); + } + + if (intersection.face && object.geometry) { + return this.sketchTargetFromFace(intersection, object); + } + + return null; +} + +function sketchTargetFromPlane(plane) { + if (!plane || typeof plane.getFrame !== 'function') { + return null; + } + return { + kind: 'plane', + id: plane.id, + name: plane.name || plane.label || 'Plane', + label: plane.label || null, + frame: plane.getFrame(), + source: { + type: 'plane', + id: plane.id + } + }; +} + +function sketchTargetFromFace(intersection, object) { + if (!intersection?.face || !object) { + return null; + } + + object.updateMatrixWorld(true); + + const normal = intersection.face.normal.clone().transformDirection(object.matrixWorld).normalize(); + const center = this.getFaceCenterWorld(intersection, object) || intersection.point?.clone(); + if (!center) return null; + + const xAxis = this.getFaceXAxisWorld(intersection, object, normal); + if (!xAxis) return null; + + const faceIndex = intersection.faceIndex ?? -1; + return { + kind: 'face', + id: `${object.uuid}:f${faceIndex}`, + name: 'Face', + frame: this.makeFrame(center, normal, xAxis), + source: { + type: 'face', + object_id: object.uuid, + face_index: faceIndex + } + }; +} + +function makeFrame(origin, normal, xAxis) { + return { + origin: { x: origin.x, y: origin.y, z: origin.z }, + normal: { x: normal.x, y: normal.y, z: normal.z }, + x_axis: { x: xAxis.x, y: xAxis.y, z: xAxis.z } + }; +} + +function getFaceXAxisWorld(intersection, object, normal) { + const geom = object.geometry; + const face = intersection.face; + const pos = geom?.attributes?.position; + if (!face || !pos) { + return this.projectAxisOnPlane(new THREE.Vector3(1, 0, 0), normal); + } + + const a = new THREE.Vector3().fromBufferAttribute(pos, face.a).applyMatrix4(object.matrixWorld); + const b = new THREE.Vector3().fromBufferAttribute(pos, face.b).applyMatrix4(object.matrixWorld); + const edge = b.sub(a); + if (edge.lengthSq() < 1e-12) { + return this.projectAxisOnPlane(new THREE.Vector3(1, 0, 0), normal); + } + return this.projectAxisOnPlane(edge, normal); +} + +function projectAxisOnPlane(axis, normal) { + const out = axis.clone().addScaledVector(normal, -axis.dot(normal)); + if (out.lengthSq() < 1e-12) { + const fallback = Math.abs(normal.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0); + fallback.addScaledVector(normal, -fallback.dot(normal)); + if (fallback.lengthSq() < 1e-12) { + return null; + } + return fallback.normalize(); + } + return out.normalize(); +} + +export { + getPrimarySketchTarget, + resolveSketchTargetFromSelection, + resolveSketchTarget, + sketchTargetFromPlane, + sketchTargetFromFace, + makeFrame, + getFaceXAxisWorld, + projectAxisOnPlane +}; diff --git a/src/void/overlay.js b/src/void/overlay.js new file mode 100644 index 00000000..aa3f4284 --- /dev/null +++ b/src/void/overlay.js @@ -0,0 +1,386 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { $ } from '../moto/webui.js'; +import { space } from '../moto/space.js'; + +// SVG namespace +const SVG_NS = 'http://www.w3.org/2000/svg'; + +// 2D overlay system for tracking 3D points +const overlay = { + container: null, // Reference to #sketch-overlay div + svg: null, // SVG element + elements: new Map(), // Map + enabled: true, + camera: null, + renderer: null, + + /** + * Initialize overlay system + */ + init() { + this.container = $('sketch-overlay'); + if (!this.container) { + console.error('overlay: sketch-overlay element not found'); + return; + } + + // Get camera and renderer from space + const internals = space.internals(); + this.camera = internals.camera; + this.renderer = internals.renderer; + + // External update callback (for datum labels, etc.) + this.onUpdate = null; + + // Create SVG element + this.svg = document.createElementNS(SVG_NS, 'svg'); + this.svg.style.width = '100%'; + this.svg.style.height = '100%'; + this.svg.style.position = 'absolute'; + this.svg.style.top = '0'; + this.svg.style.left = '0'; + this.svg.style.pointerEvents = 'none'; + // Don't set viewBox - let it use default user coordinates + + this.container.appendChild(this.svg); + + // Hook into camera movement for updates + this.setupCameraHook(); + + // Handle window resize + window.addEventListener('resize', () => { + this.updateAll(); + }); + + console.log({ overlay_initialized: true }); + }, + + /** + * Setup camera movement hook with debouncing + */ + setupCameraHook() { + // Rebind when projection/control object changes. + if (this.viewCtrl && this.onViewChange) { + this.viewCtrl.removeEventListener('change', this.onViewChange); + } + + const viewCtrl = this.viewCtrl = space.view.ctrl; + if (viewCtrl && viewCtrl.addEventListener) { + let updateQueued = false; + this.onViewChange = () => { + if (this.enabled && !updateQueued) { + updateQueued = true; + requestAnimationFrame(() => { + this.updateAll(); + updateQueued = false; + }); + } + }; + viewCtrl.addEventListener('change', this.onViewChange); + } + }, + + /** + * Called when camera/control internals are recreated (e.g. projection toggle). + */ + onProjectionChanged() { + const internals = space.internals(); + this.camera = internals.camera; + this.renderer = internals.renderer; + this.setupCameraHook(); + this.updateAll(); + }, + + /** + * Project 3D world position to 2D screen coordinates + */ + project3Dto2D(worldPos) { + if (!this.camera || !this.renderer) return null; + + const vector = worldPos.clone(); + vector.project(this.camera); + + // Use canvas client dimensions for accurate projection + const canvas = this.renderer.domElement; + const x = (vector.x + 1) / 2 * canvas.clientWidth; + const y = -(vector.y - 1) / 2 * canvas.clientHeight; + const z = vector.z; // For depth testing (z > 1 = behind camera) + + return { x, y, z, visible: z < 1 }; + }, + + /** + * Add overlay element + * @param {string} id - Unique identifier + * @param {string} type - Element type: 'point', 'text', 'line', 'path' + * @param {object} opts - Options specific to type + * @returns {SVGElement} Created element + */ + add(id, type, opts = {}) { + if (this.elements.has(id)) { + console.warn(`overlay: element ${id} already exists`); + return this.elements.get(id).el; + } + + let el; + switch (type) { + case 'point': + el = this.createPoint(opts); + break; + case 'text': + el = this.createText(opts); + break; + case 'line': + el = this.createLine(opts); + break; + case 'path': + el = this.createPath(opts); + break; + default: + console.error(`overlay: unknown type ${type}`); + return null; + } + + el.setAttribute('data-overlay-id', id); + this.svg.appendChild(el); + + this.elements.set(id, { + el, + type, + pos3d: opts.pos3d || null, + pos3d2: opts.pos3d2 || null, // For lines + opts + }); + + // Initial projection + this.updateElement(id); + + return el; + }, + + /** + * Create point element (circle) + */ + createPoint(opts) { + const circle = document.createElementNS(SVG_NS, 'circle'); + circle.setAttribute('r', opts.radius || 4); + circle.setAttribute('fill', opts.color || '#5a9fd4'); + circle.setAttribute('stroke', opts.stroke || 'none'); + circle.setAttribute('stroke-width', opts.strokeWidth || 1); + if (opts.className) { + circle.setAttribute('class', opts.className); + } + return circle; + }, + + /** + * Create text element + */ + createText(opts) { + const text = document.createElementNS(SVG_NS, 'text'); + text.textContent = opts.text || ''; + text.setAttribute('fill', opts.color || '#e0e0e0'); + text.setAttribute('font-size', opts.fontSize || 12); + text.setAttribute('font-family', opts.fontFamily || 'sans-serif'); + text.setAttribute('text-anchor', opts.anchor || 'middle'); + text.setAttribute('dominant-baseline', 'middle'); + if (opts.className) { + text.setAttribute('class', opts.className); + } + return text; + }, + + /** + * Create line element + */ + createLine(opts) { + const line = document.createElementNS(SVG_NS, 'line'); + line.setAttribute('stroke', opts.color || '#5a9fd4'); + line.setAttribute('stroke-width', opts.width || 1); + if (opts.dashed) { + line.setAttribute('stroke-dasharray', '4,4'); + } + if (opts.className) { + line.setAttribute('class', opts.className); + } + return line; + }, + + /** + * Create path element + */ + createPath(opts) { + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', opts.d || ''); + path.setAttribute('fill', opts.fill || 'none'); + path.setAttribute('stroke', opts.stroke || '#5a9fd4'); + path.setAttribute('stroke-width', opts.strokeWidth || 1); + if (opts.className) { + path.setAttribute('class', opts.className); + } + return path; + }, + + /** + * Remove overlay element + */ + remove(id) { + const item = this.elements.get(id); + if (item) { + this.svg.removeChild(item.el); + this.elements.delete(id); + } + }, + + /** + * Update element properties + */ + update(id, opts) { + const item = this.elements.get(id); + if (!item) { + console.warn(`overlay: element ${id} not found`); + return; + } + + item.opts = item.opts || {}; + + // Update stored position if provided + if (opts.pos3d) { + item.pos3d = opts.pos3d; + } + if (opts.pos3d2) { + item.pos3d2 = opts.pos3d2; + } + if (opts.hidden !== undefined) { + item.opts.hidden = !!opts.hidden; + } + + // Update text content + if (opts.text && item.type === 'text') { + item.el.textContent = opts.text; + } + + // Update color + if (opts.color) { + if (item.type === 'point') { + item.el.setAttribute('fill', opts.color); + } else if (item.type === 'text') { + item.el.setAttribute('fill', opts.color); + } else if (item.type === 'line') { + item.el.setAttribute('stroke', opts.color); + } + } + + // Re-project + this.updateElement(id); + }, + + /** + * Update single element projection + */ + updateElement(id) { + const item = this.elements.get(id); + if (!item || !item.pos3d) return; + + // Respect caller-controlled visibility flags (e.g. tree eye toggles). + if (item.opts?.hidden) { + item.el.style.display = 'none'; + return; + } + + const proj = this.project3Dto2D(item.pos3d); + if (!proj) return; + + // Hide if behind camera + if (!proj.visible) { + item.el.style.display = 'none'; + return; + } else { + item.el.style.display = ''; + } + + // Update position based on type + if (item.type === 'point') { + item.el.setAttribute('cx', proj.x); + item.el.setAttribute('cy', proj.y); + } else if (item.type === 'text') { + item.el.setAttribute('x', proj.x); + item.el.setAttribute('y', proj.y); + } else if (item.type === 'line' && item.pos3d2) { + // Lines need two points + const proj2 = this.project3Dto2D(item.pos3d2); + if (!proj2 || !proj2.visible) { + item.el.style.display = 'none'; + return; + } + item.el.setAttribute('x1', proj.x); + item.el.setAttribute('y1', proj.y); + item.el.setAttribute('x2', proj2.x); + item.el.setAttribute('y2', proj2.y); + } + + // Optional: adjust opacity based on depth + if (item.opts.depthFade) { + const opacity = Math.max(0.2, 1 - (proj.z + 1) / 2); + item.el.style.opacity = opacity; + } + }, + + /** + * Update all overlay elements + */ + updateAll() { + if (!this.enabled) return; + + for (const id of this.elements.keys()) { + this.updateElement(id); + } + + // Trigger external update callbacks (e.g., for datum labels) + if (this.onUpdate) { + this.onUpdate(); + } + }, + + /** + * Clear all elements + */ + clear() { + for (const item of this.elements.values()) { + this.svg.removeChild(item.el); + } + this.elements.clear(); + }, + + /** + * Show overlay + */ + show() { + if (this.container) { + this.container.classList.remove('hidden'); + this.updateAll(); + } + }, + + /** + * Hide overlay + */ + hide() { + if (this.container) { + this.container.classList.add('hidden'); + } + }, + + /** + * Enable/disable overlay updates + */ + setEnabled(enabled) { + this.enabled = enabled; + if (enabled) { + this.updateAll(); + } + } +}; + +export { overlay }; diff --git a/src/void/palette.js b/src/void/palette.js new file mode 100644 index 00000000..4a5f12f2 --- /dev/null +++ b/src/void/palette.js @@ -0,0 +1,53 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +const VOID_PALETTE = { + sketch: { + planeDefault: { fill: 0x5a9fd4, fillOpacity: 0.1, outline: 0x5a9fd4, outlineOpacity: 0.65 }, + planeHover: { fill: 0xff9933, fillOpacity: 0.14, outline: 0xff9933, outlineOpacity: 0.95 }, + planeEdit: { fill: 0x9ec7ff, fillOpacity: 0.08, outline: 0x5a9fd4, outlineOpacity: 0.9 }, + linesGray: 0x747474, + linesHover: 0xff9933, + linesEdit: 0xffffff, + linesSelected: 0x9ec7ff, + linesProjectedFace: 0x5a9fd4, + linesDerivedActual: 0xff9933, + linesMirrorAxis: 0xb07cff, + pointsGray: 0x747474, + pointsPrimitiveCore: 0x101010, + pointsRingIdle: 0x747474, + pointsHover: 0xff9933, + pointsEdit: 0xffffff, + pointsSelected: 0x9ec7ff, + pointsDerivedActual: 0xff9933, + profileFillDefault: 0x747474, + profileFillHover: 0xff9933, + profileFillSelected: 0x5a9fd4, + profileOpacityDefault: 0.18, + profileOpacityHover: 0.24, + profileOpacitySelected: 0.28, + constraintGlyphDriven: 0x7e7e7e, + constraintGlyphDerived: 0xc6c6c6, + labelDefault: '#747474', + labelHover: '#ff9933', + labelEdit: '#a7cbff', + lineWidths: { + default: 1.2, + hover: 3.0, + selected: 3.4 + } + }, + viewcube: { + faces: { + front: 0x4a9eff, + back: 0x4a9eff, + right: 0xff4a4a, + left: 0xff4a4a, + top: 0x4aff4a, + bottom: 0x4aff4a + }, + hover: 0xffaa33, + edge: 0x000000 + } +}; + +export { VOID_PALETTE }; diff --git a/src/void/plane.js b/src/void/plane.js new file mode 100644 index 00000000..9c6f96cc --- /dev/null +++ b/src/void/plane.js @@ -0,0 +1,558 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; + +const { Group, PlaneGeometry, CircleGeometry, MeshBasicMaterial, Mesh, DoubleSide, EdgesGeometry, LineSegments, LineBasicMaterial } = THREE; + +/** + * Plane primitive - a fundamental void:form feature + * Used for datum planes, sketch planes, and construction geometry + */ +class Plane { + constructor(options = {}) { + this.id = options.id || `plane-${Date.now()}`; + this.name = options.name || 'Plane'; + this.label = options.label || null; // Optional label text + this.size = options.size || 200; + + // Base colors + this.color = options.color || 0x404040; // Plane fill color + this.outlineColor = options.outlineColor || 0x808080; // Outline color + this.opacity = options.opacity !== undefined ? options.opacity : 0.15; + this.outlineOpacity = options.outlineOpacity !== undefined ? options.outlineOpacity : 0.5; + + // State colors (like Onshape) + this.selectedColor = 0xff9933; // Orange tint when selected + this.selectedOutlineColor = 0xff9933; // Orange outline when selected + this.hoverOutlineColor = 0xff9933; // Orange outline when hovered + + this.showHandles = options.showHandles !== undefined ? options.showHandles : true; + this.baseVisible = options.visible !== undefined ? !!options.visible : true; + + // State tracking + this.selected = false; + this.hovered = false; + this.changeHandlers = new Set(); + + // Create the 3D group + this.group = new Group(); + this.group.name = this.name; + this.group.userData.featureType = 'plane'; + this.group.userData.featureId = this.id; + this.group.userData.plane = this; // Back reference for event handling + + // Corner handles + this.handles = []; + + // Build geometry + this.build(); + } + + /** + * Build or rebuild the plane geometry + */ + build() { + // Clear existing geometry + while (this.group.children.length > 0) { + const child = this.group.children[0]; + child.traverse(obj => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + if (Array.isArray(obj.material)) { + obj.material.forEach(m => m.dispose()); + } else { + obj.material.dispose(); + } + } + }); + this.group.remove(child); + } + this.handles = []; + + // Create the plane mesh (translucent) + const width = this.size; + const height = this.height !== undefined ? this.height : this.size; + const geometry = new PlaneGeometry(width, height); + const material = new MeshBasicMaterial({ + color: this.color, + transparent: true, + opacity: this.opacity, + side: DoubleSide, + depthWrite: false + }); + + this.mesh = new Mesh(geometry, material); + this.mesh.renderOrder = 1; + this.mesh.userData.plane = this; // Back reference for interaction + + // Create the outline + const edges = new EdgesGeometry(geometry); + const lineMaterial = new LineBasicMaterial({ + color: this.outlineColor, + transparent: true, + opacity: this.outlineOpacity, + depthWrite: false + }); + + this.outline = new LineSegments(edges, lineMaterial); + this.outline.renderOrder = 2; // Render outline on top of plane + this.outline.userData.plane = this; // Back reference for interaction + + // Add to group + this.group.add(this.mesh); + this.group.add(this.outline); + + // Create corner handles + if (this.showHandles) { + this.createHandles(); + } + } + + /** + * Create corner handles for resizing + */ + createHandles() { + const halfWidth = this.size / 2; + const halfHeight = (this.height !== undefined ? this.height : this.size) / 2; + const handleRadius = 4; + const handleGeometry = new CircleGeometry(handleRadius, 24); + const handleOutlineGeometry = new CircleGeometry(handleRadius, 24); + const handleMaterial = new MeshBasicMaterial({ + color: 0x707070, + transparent: true, + opacity: 0.35, + depthWrite: false, + side: DoubleSide, + polygonOffset: true, + polygonOffsetFactor: -2, + polygonOffsetUnits: -2 + }); + const handleOutlineMaterial = new LineBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.95, + depthWrite: false + }); + + const corners = [ + { x: -halfWidth, y: -halfHeight, z: 0, name: 'bottom-left' }, + { x: halfWidth, y: -halfHeight, z: 0, name: 'bottom-right' }, + { x: halfWidth, y: halfHeight, z: 0, name: 'top-right' }, + { x: -halfWidth, y: halfHeight, z: 0, name: 'top-left' } + ]; + + for (const corner of corners) { + const handle = new Mesh(handleGeometry.clone(), handleMaterial.clone()); + handle.position.set(corner.x, corner.y, 0); + handle.renderOrder = 3; + handle.userData.handleType = 'plane-resize'; + handle.userData.handleName = corner.name; + handle.userData.plane = this; + + const handleOutline = new LineSegments( + new EdgesGeometry(handleOutlineGeometry.clone()), + handleOutlineMaterial.clone() + ); + handleOutline.position.z = 0.01; + handleOutline.renderOrder = 4; + handle.add(handleOutline); + + // Handles start hidden (only visible when selected) + handle.visible = false; + + this.handles.push(handle); + this.group.add(handle); + } + } + + /** + * Set plane size and update in place (no rebuild) + */ + setSize(size, height) { + this.size = size; + this.height = height !== undefined ? height : size; + + const newWidth = this.size; + const newHeight = this.height !== undefined ? this.height : this.size; + + // Update mesh geometry scale + if (this.mesh && this.mesh.geometry) { + this.mesh.geometry.dispose(); + this.mesh.geometry = new PlaneGeometry(newWidth, newHeight); + } + + // Update outline geometry + if (this.outline && this.mesh) { + this.outline.geometry.dispose(); + this.outline.geometry = new EdgesGeometry(this.mesh.geometry); + } + + // Update handle positions + if (this.handles && this.handles.length > 0) { + const halfWidth = newWidth / 2; + const halfHeight = newHeight / 2; + const positions = [ + { x: -halfWidth, y: -halfHeight }, // bottom-left + { x: halfWidth, y: -halfHeight }, // bottom-right + { x: halfWidth, y: halfHeight }, // top-right + { x: -halfWidth, y: halfHeight } // top-left + ]; + + for (let i = 0; i < this.handles.length && i < positions.length; i++) { + this.handles[i].position.set(positions[i].x, positions[i].y, this.handles[i].position.z); + } + } + + this.notifyChange(); + } + + /** + * Set plane color + */ + setColor(color) { + this.color = color; + if (this.mesh) { + this.mesh.material.color.setHex(color); + } + } + + /** + * Set outline color + */ + setOutlineColor(color) { + this.outlineColor = color; + if (this.outline) { + this.outline.material.color.setHex(color); + } + } + + /** + * Set opacity + */ + setOpacity(opacity) { + this.opacity = opacity; + if (this.mesh) { + this.mesh.material.opacity = opacity; + } + } + + /** + * Set outline opacity + */ + setOutlineOpacity(opacity) { + this.outlineOpacity = opacity; + if (this.outline) { + this.outline.material.opacity = opacity; + } + } + + /** + * Set position + */ + setPosition(x, y, z) { + this.group.position.set(x, y, z); + this.notifyChange(); + } + + /** + * Set rotation (in radians) + */ + setRotation(x, y, z) { + this.group.rotation.set(x, y, z); + this.notifyChange(); + } + + /** + * Set plane frame using canonical document-space data. + * frame = { origin:{x,y,z}, normal:{x,y,z}, x_axis:{x,y,z}, size:{width,height} } + */ + setFrame(frame = {}) { + const { origin, normal, x_axis, size } = frame; + + if (origin) { + this.group.position.set(origin.x || 0, origin.y || 0, origin.z || 0); + } + + const zAxis = new THREE.Vector3( + normal?.x ?? 0, + normal?.y ?? 0, + normal?.z ?? 1 + ); + if (zAxis.lengthSq() < 1e-12) { + zAxis.set(0, 0, 1); + } + zAxis.normalize(); + + const xAxis = new THREE.Vector3( + x_axis?.x ?? 1, + x_axis?.y ?? 0, + x_axis?.z ?? 0 + ); + // Gram-Schmidt project x-axis onto plane normal. + xAxis.addScaledVector(zAxis, -xAxis.dot(zAxis)); + if (xAxis.lengthSq() < 1e-12) { + // Choose stable fallback basis if provided axis is degenerate. + xAxis.copy(Math.abs(zAxis.z) < 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0)); + xAxis.addScaledVector(zAxis, -xAxis.dot(zAxis)); + } + xAxis.normalize(); + + const yAxis = new THREE.Vector3().crossVectors(zAxis, xAxis).normalize(); + xAxis.crossVectors(yAxis, zAxis).normalize(); + + const basis = new THREE.Matrix4().makeBasis(xAxis, yAxis, zAxis); + this.group.quaternion.setFromRotationMatrix(basis); + + if (size) { + this.setSize( + size.width !== undefined ? size.width : this.size, + size.height !== undefined ? size.height : this.height + ); + } + + this.notifyChange(); + } + + /** + * Set visibility + */ + setVisible(visible) { + this.baseVisible = !!visible; + this.group.visible = !!visible; + this.notifyChange(); + } + + getBaseVisible() { + return this.baseVisible; + } + + /** + * Set label text + */ + setLabel(text) { + this.label = text; + this.notifyChange(); + } + + /** + * Get label text + */ + getLabel() { + return this.label; + } + + /** + * Get top-left corner position in world coordinates + */ + getTopLeftCorner() { + const halfWidth = this.size / 2; + const halfHeight = (this.height !== undefined ? this.height : this.size) / 2; + const localPos = new THREE.Vector3(-halfWidth, halfHeight, 0); + this.group.updateMatrixWorld(true); + const worldPos = localPos.applyMatrix4(this.group.matrixWorld); + return worldPos; + } + + /** + * Register callback for geometry/transform/label changes + */ + onChange(handler) { + if (typeof handler === 'function') { + this.changeHandlers.add(handler); + } + return this; + } + + /** + * Remove registered change callback + */ + offChange(handler) { + this.changeHandlers.delete(handler); + return this; + } + + /** + * Notify listeners plane changed + */ + notifyChange() { + for (const handler of this.changeHandlers) { + handler(this); + } + } + + /** + * Show/hide handles + */ + setHandlesVisible(visible) { + this.showHandles = visible; + for (const handle of this.handles) { + handle.visible = visible; + } + } + + /** + * Set selected state + */ + setSelected(selected) { + this.selected = selected; + this.updateAppearance(); + } + + /** + * Get selected state + */ + isSelected() { + return this.selected; + } + + /** + * Set hovered state + */ + setHovered(hovered) { + this.hovered = hovered; + this.updateAppearance(); + } + + /** + * Get hovered state + */ + isHovered() { + return this.hovered; + } + + /** + * Update appearance based on state + */ + updateAppearance() { + if (!this.mesh || !this.outline) return; + + if (this.selected) { + // Selected: orange tint and outline, handles visible + this.mesh.material.color.setHex(this.selectedColor); + this.outline.material.color.setHex(this.selectedOutlineColor); + this.setHandlesVisible(true); + } else if (this.hovered) { + // Hovered: base color, orange outline, no handles + this.mesh.material.color.setHex(this.color); + this.outline.material.color.setHex(this.hoverOutlineColor); + this.setHandlesVisible(false); + } else { + // Default: base colors, no handles + this.mesh.material.color.setHex(this.color); + this.outline.material.color.setHex(this.outlineColor); + this.setHandlesVisible(false); + } + } + + /** + * Get the THREE.Group for adding to scene + */ + getGroup() { + return this.group; + } + + /** + * Get canonical plane frame in document space. + */ + getFrame() { + const origin = { + x: this.group.position.x, + y: this.group.position.y, + z: this.group.position.z + }; + const xAxis = new THREE.Vector3(1, 0, 0).applyQuaternion(this.group.quaternion).normalize(); + const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(this.group.quaternion).normalize(); + return { + origin, + normal: { x: normal.x, y: normal.y, z: normal.z }, + x_axis: { x: xAxis.x, y: xAxis.y, z: xAxis.z }, + size: { + width: this.size, + height: this.height !== undefined ? this.height : this.size + } + }; + } + + /** + * Serialize plane to JSON + */ + toJSON() { + const frame = this.getFrame(); + return { + id: this.id, + name: this.name, + label: this.label, + type: 'plane', + frame, + size: frame.size, + visible: this.baseVisible, + color: this.color, + outlineColor: this.outlineColor, + opacity: this.opacity, + outlineOpacity: this.outlineOpacity, + showHandles: this.showHandles + }; + } + + /** + * Create plane from JSON + */ + static fromJSON(data) { + const plane = new Plane({ + id: data.id, + name: data.name, + label: data.label, + size: data?.size?.width ?? data.size, + color: data.color, + outlineColor: data.outlineColor, + opacity: data.opacity, + outlineOpacity: data.outlineOpacity, + showHandles: data.showHandles + }); + + if (data.frame) { + plane.setFrame(data.frame); + } else { + // Legacy fallback for pre-frame documents. + if (data.position) { + plane.setPosition(data.position.x, data.position.y, data.position.z); + } + + if (data.rotation) { + plane.setRotation(data.rotation.x, data.rotation.y, data.rotation.z); + } + + if (data.height !== undefined || data.size !== undefined) { + plane.setSize(data.size, data.height); + } + } + + if (data.visible !== undefined) { + plane.setVisible(data.visible); + } + + return plane; + } + + /** + * Dispose of all resources + */ + dispose() { + while (this.group.children.length > 0) { + const child = this.group.children[0]; + child.traverse(obj => { + if (obj.geometry) obj.geometry.dispose(); + if (obj.material) { + if (Array.isArray(obj.material)) { + obj.material.forEach(m => m.dispose()); + } else { + obj.material.dispose(); + } + } + }); + this.group.remove(child); + } + } +} + +export { Plane }; diff --git a/src/void/properties.js b/src/void/properties.js new file mode 100644 index 00000000..7f327b18 --- /dev/null +++ b/src/void/properties.js @@ -0,0 +1,1088 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from './api.js'; + +const DATUM_OPTIONS = [ + { id: 'datum-xy', name: 'Top', key: 'xy' }, + { id: 'datum-yz', name: 'Right', key: 'yz' }, + { id: 'datum-xz', name: 'Front', key: 'xz' } +]; +const PROPS_PANEL_POS_KEY = 'props_panel_pos'; +const PANEL_MIN_LEFT = 10; +const PANEL_MIN_TOP = 60; + +function resolveExtrudeProfileRef(profile = {}) { + const regionId = String(profile?.region_id || ''); + const match = regionId.match(/^profile:([^:]+):([^:]+)$/); + if (!match) return { regionId: null, sketchId: null, profileId: null, key: null }; + const sketchId = match[1]; + const profileId = match[2]; + return { regionId, sketchId, profileId, key: regionId }; +} + +function resolveChamferEdgeRefKey(edge = {}) { + const ref = String(edge?.boundary_segment_id || edge?.entity?.id || ''); + if (!ref) return null; + return api.solids?.getEdgeKeyForBoundaryRef?.(ref) || null; +} + +function resolveChamferEdgeIdentity(edge = {}) { + const edgeKey = String(edge?.key || '').trim(); + if (edgeKey) return `key:${edgeKey}`; + const mapped = String(resolveChamferEdgeRefKey(edge) || '').trim(); + if (mapped) return `mapped:${mapped}`; + const ref = String(edge?.boundary_segment_id || edge?.entity?.id || '').trim(); + if (ref) return `ref:${ref}`; + const solidId = String(edge?.solidId || '').trim(); + const edgeIndex = Number(edge?.edgeIndex); + if (solidId && Number.isFinite(edgeIndex)) { + return `idx:${solidId}:${edgeIndex}`; + } + return null; +} + +const properties = { + panel: null, + header: null, + body: null, + currentFeatureId: null, + _onChange: null, + _drag: null, + _savedPos: null, + _loadingPos: false, + _booleanPickRole: 'targets', + _extrudePickRole: 'profiles', + _sessionStartRev: null, + _sessionFeatureId: null, + _sessionFeatureType: null, + + init() { + if (this.panel) return; + + const panel = document.createElement('div'); + panel.className = 'props-panel hidden'; + panel.style.left = '300px'; + panel.style.top = '90px'; + + const header = document.createElement('div'); + header.className = 'props-header'; + + const title = document.createElement('div'); + title.className = 'props-title'; + title.textContent = 'Properties'; + + const actions = document.createElement('div'); + actions.className = 'props-header-actions'; + + const accept = document.createElement('button'); + accept.className = 'props-close'; + accept.textContent = '✓'; + accept.title = 'Accept'; + accept.onclick = () => this.hide('accept'); + + const close = document.createElement('button'); + close.className = 'props-close'; + close.textContent = 'x'; + close.title = 'Cancel'; + close.onclick = () => this.hide('cancel'); + + actions.appendChild(accept); + actions.appendChild(close); + header.appendChild(title); + header.appendChild(actions); + panel.appendChild(header); + + const body = document.createElement('div'); + body.className = 'props-body'; + panel.appendChild(body); + + document.body.appendChild(panel); + + this.panel = panel; + this.header = header; + this.body = body; + + this.restorePosition(); + this.bindDrag(); + }, + + setPanelPosition(x, y) { + if (!this.panel) return; + this.panel.style.right = 'auto'; + this.panel.style.bottom = 'auto'; + this.panel.style.left = `${Math.max(PANEL_MIN_LEFT, x)}px`; + this.panel.style.top = `${Math.max(PANEL_MIN_TOP, y)}px`; + }, + + applyPlacement(pos) { + if (!this.panel || !pos) return; + const anchor = String(pos.anchor || 'tl'); + const x = Number(pos.x); + const y = Number(pos.y); + if (!['tl', 'tr', 'bl', 'br'].includes(anchor)) return; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + + this.panel.style.left = 'auto'; + this.panel.style.right = 'auto'; + this.panel.style.top = 'auto'; + this.panel.style.bottom = 'auto'; + + if (anchor[1] === 'r') { + this.panel.style.right = `${Math.max(PANEL_MIN_LEFT, x)}px`; + } else { + this.panel.style.left = `${Math.max(PANEL_MIN_LEFT, x)}px`; + } + if (anchor[0] === 'b') { + this.panel.style.bottom = `${Math.max(PANEL_MIN_LEFT, y)}px`; + } else { + this.panel.style.top = `${Math.max(PANEL_MIN_TOP, y)}px`; + } + }, + + restorePosition() { + if (this._loadingPos) return; + this._loadingPos = true; + const admin = api.db?.admin; + if (!admin) { + this._loadingPos = false; + return; + } + admin.get(PROPS_PANEL_POS_KEY).then(pos => { + if (!pos || !this.panel) return; + // Backward compatible with legacy format { x, y }. + if (!pos.anchor) { + const x = Number(pos.x); + const y = Number(pos.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + this._savedPos = { x, y }; + this.setPanelPosition(x, y); + return; + } + const anchor = String(pos.anchor); + const x = Number(pos.x); + const y = Number(pos.y); + if (!['tl', 'tr', 'bl', 'br'].includes(anchor)) return; + if (!Number.isFinite(x) || !Number.isFinite(y)) return; + this._savedPos = { anchor, x, y }; + this.applyPlacement(this._savedPos); + }).catch(() => { + // ignore persistence read errors + }).finally(() => { + this._loadingPos = false; + }); + }, + + persistPosition() { + const admin = api.db?.admin; + if (!admin || !this.panel) return; + const rect = this.panel.getBoundingClientRect(); + const midX = window.innerWidth / 2; + const midY = window.innerHeight / 2; + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const horizontal = centerX >= midX ? 'r' : 'l'; + const vertical = centerY >= midY ? 'b' : 't'; + const pos = { anchor: `${vertical}${horizontal}` }; + if (horizontal === 'r') { + pos.x = Math.round(window.innerWidth - rect.right); + } else { + pos.x = Math.round(rect.left); + } + if (vertical === 'b') { + pos.y = Math.round(window.innerHeight - rect.bottom); + } else { + pos.y = Math.round(rect.top); + } + this._savedPos = pos; + admin.put(PROPS_PANEL_POS_KEY, pos).catch(() => { + // ignore persistence write errors + }); + }, + + bindDrag() { + if (!this.header || !this.panel) return; + this.header.addEventListener('mousedown', event => { + if (event.button !== 0) return; + const rect = this.panel.getBoundingClientRect(); + this._drag = { + dx: event.clientX - rect.left, + dy: event.clientY - rect.top + }; + event.preventDefault(); + }); + + window.addEventListener('mousemove', event => { + if (!this._drag || !this.panel) return; + const x = event.clientX - this._drag.dx; + const y = event.clientY - this._drag.dy; + this.setPanelPosition(x, y); + }); + + window.addEventListener('mouseup', () => { + if (this._drag) { + this.persistPosition(); + } + this._drag = null; + }); + + window.addEventListener('resize', () => { + if (!this.panel || this.panel.classList.contains('hidden') || !this._savedPos) return; + if (this._savedPos.anchor) { + this.applyPlacement(this._savedPos); + } else { + this.setPanelPosition(this._savedPos.x, this._savedPos.y); + } + }); + }, + + async showFeature(feature, opts = {}) { + this.init(); + if (!feature || !this.panel || !this.body) return; + this.currentFeatureId = feature.id; + this._sessionFeatureId = feature.id; + this._sessionFeatureType = feature.type || null; + this._sessionStartRev = api.document.current?.head_rev || null; + api.document.beginAtomicEdit({ + feature_id: this._sessionFeatureId, + feature_type: this._sessionFeatureType + }); + this._onChange = opts.onChange || null; + api.sketchRuntime?.setEditing(feature.type === 'sketch' ? feature.id : null); + if (feature.type !== 'sketch') { + api.interact?.clearSketchSelection?.(); + } + if (feature.type === 'chamfer') { + await api.solids?.beginChamferEdgeSnapshot?.(feature.id); + } else { + api.solids?.endChamferEdgeSnapshot?.(); + } + if (this._savedPos) { + if (this._savedPos.anchor) { + this.applyPlacement(this._savedPos); + } else { + this.setPanelPosition(this._savedPos.x, this._savedPos.y); + } + } else { + this.restorePosition(); + } + this.panel.classList.remove('hidden'); + this.renderFeature(feature); + this.syncExtrudeProfileSelection(feature); + api.sketchRuntime?.sync?.(); + if (feature.type !== 'chamfer') { + api.solids?.scheduleRebuild?.('feature.edit.enter'); + } + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + + async hide(mode = 'accept') { + if (!this.panel) return; + const editedFeatureId = this._sessionFeatureId || null; + if (mode === 'cancel') { + const startRev = this._sessionStartRev || null; + const currentRev = api.document.current?.head_rev || null; + if (startRev && currentRev && startRev !== currentRev) { + const revision = await api.document.getRevision(startRev); + if (revision) { + await api.document.applyRevision(revision); + if (typeof this._onChange === 'function') { + this._onChange(); + } + } + } + await api.document.endAtomicEdit({ commit: false }); + } else { + await api.document.endAtomicEdit({ + commit: true, + opType: 'feature.atomic.edit', + payload: { + feature_id: this._sessionFeatureId, + feature_type: this._sessionFeatureType + } + }); + if (typeof this._onChange === 'function') { + this._onChange(); + } + } + this.panel.classList.add('hidden'); + api.solids?.endChamferEdgeSnapshot?.(); + api.interact?.clearSketchSelection?.(); + api.sketchRuntime?.setEditing(null); + if (editedFeatureId) { + api.sketchRuntime?.clearEntityInteraction?.(editedFeatureId); + } + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + this.syncExtrudeProfileSelection(null); + this.syncExtrudeTargetSelection(null); + this.syncBooleanSolidSelection(null); + this.syncChamferEdgeSelection(null); + this.currentFeatureId = null; + this._sessionFeatureId = null; + this._sessionFeatureType = null; + this._sessionStartRev = null; + this._onChange = null; + api.sketchRuntime?.sync?.(); + if (editedFeatureId) { + await api.solids?.rebuildDownstreamFrom?.(editedFeatureId, 'feature.edit.exit'); + } else { + await api.solids?.rebuild?.('feature.edit.exit'); + } + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + + renderFeature(feature) { + this.body.innerHTML = ''; + + const kind = document.createElement('div'); + kind.className = 'props-meta'; + kind.textContent = feature?.type ? feature.type.toUpperCase() : 'FEATURE'; + this.body.appendChild(kind); + + this.body.appendChild(this.createTextField('Name', feature.name || '', value => { + const updated = api.features.rename(feature.id, value); + if (updated) this.onChanged(); + })); + + if (feature.type === 'sketch') { + this.renderSketchFields(feature); + } else if (feature.type === 'extrude') { + this.renderExtrudeFields(feature); + } else if (feature.type === 'chamfer') { + this.renderChamferFields(feature); + } else if (feature.type === 'boolean') { + this.renderBooleanFields(feature); + } + this.syncExtrudeProfileSelection(feature); + this.syncExtrudeTargetSelection(feature); + this.syncBooleanSolidSelection(feature); + this.syncChamferEdgeSelection(feature); + }, + + renderSketchFields(feature) { + const target = feature.target || {}; + const targetArea = this.createSolidPickerArea({ + title: 'Sketch Plane', + active: true, + emptyText: 'No sketch plane selected' + }); + const currentText = this.getSketchTargetDisplay(feature); + if (currentText) { + const row = document.createElement('div'); + row.className = 'props-extrude-profile-row'; + const text = document.createElement('div'); + text.className = 'props-extrude-profile-text'; + text.textContent = currentText; + const clear = document.createElement('button'); + clear.className = 'props-extrude-profile-remove'; + clear.textContent = '×'; + clear.title = 'Clear sketch plane target'; + clear.onclick = () => { + const updated = api.features.update(feature.id, item => { + item.target = item.target || {}; + item.target.kind = null; + item.target.id = null; + item.target.name = null; + item.target.label = null; + item.target.source = null; + }, { + opType: 'feature.update', + payload: { field: 'target.clear' } + }); + if (updated) this.onChanged(); + }; + row.appendChild(text); + row.appendChild(clear); + targetArea.list.appendChild(row); + } else { + targetArea.showEmpty(); + } + this.body.appendChild(targetArea.wrap); + + const offsetValue = Number(target.offset ?? 0); + this.body.appendChild(this.createNumberField('Offset', offsetValue, value => { + const updated = api.features.update(feature.id, item => { + item.target = item.target || {}; + item.target.offset = value; + const sourceType = item?.target?.source?.type || null; + if (sourceType === 'solid-face') { + const source = item.target.source; + const sourceSolidId = String(source?.solid_id || ''); + const sourceFaceId = Number(source?.face_id); + let frame = null; + let nextSolidId = sourceSolidId; + let nextFaceId = sourceFaceId; + + // Offset edits should stay attached to the same face when possible. + if (sourceSolidId && Number.isFinite(sourceFaceId)) { + const direct = api.solids?.getSketchTargetForFaceKey?.(`${sourceSolidId}:${sourceFaceId}`) || null; + if (direct?.frame) { + frame = direct.frame; + } + } + // Fallback only when the original face can no longer be resolved. + if (!frame) { + const resolved = api.solids?.resolveSketchFrameForSource?.(source, item.plane || null); + if (resolved?.frame) { + frame = resolved.frame; + nextSolidId = String(resolved.solidId || sourceSolidId); + nextFaceId = Number(resolved.faceId); + } + } + if (frame) { + item.target.source.solid_id = nextSolidId; + item.target.source.face_id = nextFaceId; + item.target.id = `${nextSolidId}:f${nextFaceId}`; + item.plane = api.solids?.applyOffsetToFrame?.(frame, value) || frame; + } + } else if (sourceType === 'plane' && item.target?.source?.id) { + const option = DATUM_OPTIONS.find(o => o.id === item.target.source.id); + const plane = option ? api.datum.getPlane(option.key) : null; + if (plane?.getFrame) { + item.plane = api.solids?.applyOffsetToFrame?.(plane.getFrame(), value) || plane.getFrame(); + } + } + }, { + opType: 'feature.update', + payload: { field: 'offset', value } + }); + if (updated) this.onChanged(); + })); + }, + + applySketchTargetToFeature(item, target) { + if (!item || !target?.frame) return; + const offset = Number(item?.target?.offset || 0); + item.target = item.target || {}; + item.target.kind = target.kind || 'plane'; + item.target.id = target.id || null; + item.target.name = target.name || null; + item.target.label = target.label || null; + item.target.source = target.source || null; + item.target.offset = offset; + item.plane = api.solids?.applyOffsetToFrame?.(target.frame, offset) || target.frame; + }, + + getSketchTargetDisplay(feature) { + const target = feature?.target || {}; + const source = target?.source || {}; + if (source.type === 'solid-face') { + const solidId = String(source.solid_id || ''); + const faceId = Number(source.face_id); + const solidName = this.getSolidDisplayName(solidId); + const faceText = Number.isFinite(faceId) ? `Face ${faceId + 1}` : 'Face'; + return `${solidName} / ${faceText}`; + } + const planeId = source.id || target.id || null; + const datum = DATUM_OPTIONS.find(opt => opt.id === planeId); + if (datum) return datum.name; + return target.name || target.label || null; + }, + + renderExtrudeFields(feature) { + this.setExtrudeProfileHover(null); + const params = feature?.params || {}; + const operation = ['new', 'add', 'subtract'].includes(String(params.operation || 'new')) + ? String(params.operation || 'new') + : 'new'; + if (operation === 'new') { + this._extrudePickRole = 'profiles'; + } else if (this._extrudePickRole !== 'profiles' && this._extrudePickRole !== 'targets') { + this._extrudePickRole = 'targets'; + } + + this.body.appendChild(this.createSelectField('Mode', operation, [ + { value: 'new', label: 'New' }, + { value: 'add', label: 'Add' }, + { value: 'subtract', label: 'Subtract' } + ], value => { + const next = ['new', 'add', 'subtract'].includes(value) ? value : 'new'; + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.operation = next; + item.input = item.input || {}; + if (!Array.isArray(item.input.targets)) { + item.input.targets = []; + } + }, { + opType: 'feature.update', + payload: { field: 'operation', value: next } + }); + if (updated) { + this._extrudePickRole = next === 'new' ? 'profiles' : 'targets'; + this.onChanged(); + } + })); + + const depthValue = Number(params.depth ?? params.distance ?? 10); + this.body.appendChild(this.createNumberField('Depth', depthValue, value => { + const next = Math.max(0.0001, Math.abs(value)); + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.depth = next; + item.params.distance = next; + }, { + opType: 'feature.update', + payload: { field: 'depth', value: next } + }); + if (updated) this.onChanged(); + })); + + const direction = String(params.direction || 'normal'); + this.body.appendChild(this.createSelectField('Direction', direction, [ + { value: 'normal', label: 'Normal' }, + { value: 'reverse', label: 'Reverse' } + ], value => { + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.direction = value === 'reverse' ? 'reverse' : 'normal'; + }, { + opType: 'feature.update', + payload: { field: 'direction', value } + }); + if (updated) this.onChanged(); + })); + + const symmetric = params.symmetric === true; + this.body.appendChild(this.createCheckboxField('Symmetric', symmetric, checked => { + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.symmetric = !!checked; + }, { + opType: 'feature.update', + payload: { field: 'symmetric', value: !!checked } + }); + if (updated) this.onChanged(); + })); + + const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : []; + const profilesArea = this.createSolidPickerArea({ + title: 'Profiles', + active: this._extrudePickRole === 'profiles', + onActivate: () => { + this._extrudePickRole = 'profiles'; + this.onChanged(); + }, + emptyText: 'No profiles selected' + }); + if (profiles.length) { + for (const profile of profiles) { + const ref = resolveExtrudeProfileRef(profile); + const sketch = ref?.sketchId ? api.features.findById(ref.sketchId) : null; + const row = document.createElement('div'); + row.className = 'props-extrude-profile-row'; + row.onmouseenter = () => this.setExtrudeProfileHover(profile); + row.onmouseleave = () => this.setExtrudeProfileHover(null); + const text = document.createElement('div'); + text.className = 'props-extrude-profile-text'; + text.textContent = `${sketch?.name || ref?.sketchId || 'Sketch'} / ${ref?.profileId || 'region'}`; + const remove = document.createElement('button'); + remove.className = 'props-extrude-profile-remove'; + remove.textContent = '×'; + remove.title = 'Remove profile'; + remove.onclick = () => { + const updated = api.features.update(feature.id, item => { + item.input = item.input || {}; + const current = Array.isArray(item.input.profiles) ? item.input.profiles : []; + const removeKey = String(ref?.key || ''); + item.input.profiles = current.filter(p => { + const pRef = resolveExtrudeProfileRef(p); + const pKey = String(pRef?.key || ''); + if (removeKey && pKey) { + return pKey !== removeKey; + } + return !(pRef?.sketchId === ref?.sketchId && pRef?.profileId === ref?.profileId); + }); + }, { + opType: 'feature.update', + payload: { field: 'profiles.remove', profile } + }); + if (updated) this.onChanged(); + }; + row.appendChild(text); + row.appendChild(remove); + profilesArea.list.appendChild(row); + } + } else { + profilesArea.showEmpty(); + } + this.body.appendChild(profilesArea.wrap); + + if (operation !== 'new') { + const solids = api.solids?.list?.() || []; + const targetIds = Array.isArray(feature?.input?.targets) ? feature.input.targets.filter(Boolean) : []; + const targetsArea = this.createSolidPickerArea({ + title: 'Targets', + active: this._extrudePickRole === 'targets', + onActivate: () => { + this._extrudePickRole = 'targets'; + this.onChanged(); + }, + emptyText: 'No targets selected' + }); + if (targetIds.length) { + for (const solidId of targetIds) { + const solid = solids.find(item => item?.id === solidId); + const row = document.createElement('div'); + row.className = 'props-extrude-profile-row'; + const text = document.createElement('div'); + text.className = 'props-extrude-profile-text'; + text.textContent = this.getSolidDisplayName(solidId, solid); + const remove = document.createElement('button'); + remove.className = 'props-extrude-profile-remove'; + remove.textContent = '×'; + remove.title = 'Remove target'; + remove.onclick = () => { + const updated = api.features.update(feature.id, item => { + item.input = item.input || {}; + const current = Array.isArray(item.input.targets) ? item.input.targets : []; + item.input.targets = current.filter(id => id !== solidId); + }, { + opType: 'feature.update', + payload: { field: 'targets.remove', solidId } + }); + if (updated) this.onChanged(); + }; + row.appendChild(text); + row.appendChild(remove); + targetsArea.list.appendChild(row); + } + } else { + targetsArea.showEmpty(); + } + this.body.appendChild(targetsArea.wrap); + } + }, + + renderBooleanFields(feature) { + const input = this.getBooleanInput(feature); + const mode = String(feature?.params?.mode || 'add'); + this.body.appendChild(this.createSelectField('Mode', mode, [ + { value: 'add', label: 'Add' }, + { value: 'subtract', label: 'Subtract' }, + { value: 'intersect', label: 'Intersect' } + ], value => { + const next = ['add', 'subtract', 'intersect'].includes(value) ? value : 'add'; + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.mode = next; + item.input = item.input || {}; + if (!Array.isArray(item.input.targets)) { + item.input.targets = []; + } + if (!Array.isArray(item.input.tools)) { + item.input.tools = []; + } + }, { + opType: 'feature.update', + payload: { field: 'mode', value: next } + }); + if (updated) this.onChanged(); + })); + + if (mode === 'subtract') { + if (this._booleanPickRole !== 'tools' && this._booleanPickRole !== 'targets') { + this._booleanPickRole = 'targets'; + } + } else { + this._booleanPickRole = 'targets'; + } + + const solids = api.solids?.list?.() || []; + const targets = Array.isArray(input.targets) ? input.targets.filter(Boolean) : []; + const tools = Array.isArray(input.tools) ? input.tools.filter(Boolean) : []; + + const buildSection = (sectionKey, title, ids, emptyText) => { + const active = mode === 'subtract' ? this._booleanPickRole === sectionKey : true; + const area = this.createSolidPickerArea({ + title, + active, + onActivate: () => { + if (mode === 'subtract') { + this._booleanPickRole = sectionKey; + this.onChanged(); + } + }, + emptyText + }); + if (ids.length) { + for (const solidId of ids) { + const solid = solids.find(item => item?.id === solidId); + const row = document.createElement('div'); + row.className = 'props-extrude-profile-row'; + const text = document.createElement('div'); + text.className = 'props-extrude-profile-text'; + text.textContent = this.getSolidDisplayName(solidId, solid); + const remove = document.createElement('button'); + remove.className = 'props-extrude-profile-remove'; + remove.textContent = '×'; + remove.title = `Remove ${sectionKey === 'tools' ? 'tool' : 'target'}`; + remove.onclick = () => { + const updated = api.features.update(feature.id, item => { + item.input = item.input || {}; + const currentTargets = Array.isArray(item.input.targets) ? item.input.targets : []; + const currentTools = Array.isArray(item.input.tools) ? item.input.tools : []; + item.input.targets = currentTargets.filter(id => id && !(sectionKey === 'targets' && id === solidId)); + item.input.tools = currentTools.filter(id => id && !(sectionKey === 'tools' && id === solidId)); + }, { + opType: 'feature.update', + payload: { field: `${sectionKey}.remove`, solidId } + }); + if (updated) this.onChanged(); + }; + row.appendChild(text); + row.appendChild(remove); + area.list.appendChild(row); + } + } else { + area.showEmpty(); + } + this.body.appendChild(area.wrap); + }; + + buildSection('targets', 'Targets', targets, 'No targets selected'); + if (mode === 'subtract') { + buildSection('tools', 'Tools', tools, 'No tools selected'); + } + }, + + renderChamferFields(feature) { + const params = feature?.params || {}; + const distance = Math.max(0.0001, Math.abs(Number(params.distance ?? 1))); + this.body.appendChild(this.createNumberField('Distance', distance, value => { + const next = Math.max(0.0001, Math.abs(Number(value) || 0)); + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.distance = next; + }, { + opType: 'feature.update', + payload: { field: 'distance', value: next } + }); + if (updated) this.onChanged(); + })); + this.body.appendChild(this.createCheckboxField('Show cutters', params.showCutters === true, checked => { + const updated = api.features.update(feature.id, item => { + item.params = item.params || {}; + item.params.showCutters = checked === true; + }, { + opType: 'feature.update', + payload: { field: 'showCutters', value: checked === true } + }); + if (updated) this.onChanged(); + })); + + const edges = Array.isArray(feature?.input?.edges) ? feature.input.edges : []; + const edgeArea = this.createSolidPickerArea({ + title: 'Edges', + active: true, + emptyText: 'No edges selected' + }); + if (edges.length) { + for (const edge of edges) { + const row = document.createElement('div'); + row.className = 'props-extrude-profile-row'; + const text = document.createElement('div'); + text.className = 'props-extrude-profile-text'; + const solidName = this.getSolidDisplayName(edge?.solidId || ''); + const edgeIndex = Number(edge?.edgeIndex); + text.textContent = `${solidName} / Edge ${Number.isFinite(edgeIndex) ? edgeIndex + 1 : '?'}`; + const remove = document.createElement('button'); + remove.className = 'props-extrude-profile-remove'; + remove.textContent = '×'; + remove.title = 'Remove edge'; + remove.onclick = () => { + const removeId = resolveChamferEdgeIdentity(edge); + const updated = api.features.update(feature.id, item => { + item.input = item.input || {}; + const current = Array.isArray(item.input.edges) ? item.input.edges : []; + item.input.edges = current.filter(e => { + const id = resolveChamferEdgeIdentity(e); + if (removeId && id) return id !== removeId; + if (removeId && !id) return true; + if (!removeId && id) return true; + return e !== edge; + }); + }, { + opType: 'feature.update', + payload: { field: 'edges.remove', key: removeId || resolveChamferEdgeRefKey(edge) || null } + }); + if (updated) this.onChanged(); + }; + row.appendChild(text); + row.appendChild(remove); + edgeArea.list.appendChild(row); + } + } else { + edgeArea.showEmpty(); + } + this.body.appendChild(edgeArea.wrap); + }, + + createSolidPickerArea({ title, active = false, onActivate = null, emptyText = 'Nothing selected' }) { + const wrap = document.createElement('div'); + wrap.className = `props-field props-picker-area${active ? ' active' : ''}`; + const label = document.createElement('label'); + label.textContent = title; + wrap.appendChild(label); + const list = document.createElement('div'); + list.className = 'props-extrude-profiles'; + wrap.appendChild(list); + if (typeof onActivate === 'function') { + wrap.onclick = event => { + if (event?.target?.closest?.('.props-extrude-profile-remove')) { + return; + } + onActivate(); + }; + } + return { + wrap, + list, + showEmpty() { + const empty = document.createElement('div'); + empty.className = 'props-extrude-profile-empty'; + empty.textContent = emptyText; + list.appendChild(empty); + } + }; + }, + + getExtrudePickRole() { + return this._extrudePickRole === 'targets' ? 'targets' : 'profiles'; + }, + + syncExtrudeProfileSelection(feature) { + const isExtrude = feature?.type === 'extrude' && this.currentFeatureId === feature?.id; + if (!isExtrude) { + api.interact.selectedSketchProfiles?.clear?.(); + api.interact.hoveredSketchProfileKey = null; + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + api.sketchRuntime?.setForcedVisible?.([]); + return; + } + const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : []; + const keys = profiles + .map(p => { + const ref = resolveExtrudeProfileRef(p); + return (ref?.sketchId && ref?.profileId) ? `${ref.sketchId}:${ref.profileId}` : null; + }) + .filter(Boolean); + const sketchIds = Array.from(new Set(profiles.map(p => resolveExtrudeProfileRef(p)?.sketchId).filter(Boolean))); + api.interact.selectedSketchProfiles = new Set(keys); + api.sketchRuntime?.setSelectedProfiles?.(keys); + api.sketchRuntime?.setForcedVisible?.(sketchIds); + }, + + setExtrudeProfileHover(profile) { + const ref = resolveExtrudeProfileRef(profile); + const key = (ref?.sketchId && ref?.profileId) + ? `${ref.sketchId}:${ref.profileId}` + : null; + const currentFeature = this.currentFeatureId ? api.features.findById(this.currentFeatureId) : null; + const currentFeatureId = currentFeature?.type === 'extrude' ? currentFeature.id : null; + const hoveredSolidIds = []; + if (ref?.sketchId && ref?.profileId) { + const sketchId = String(ref.sketchId); + const profileId = String(ref.profileId); + const profileKey = `${sketchId}:${profileId}`; + const solids = api.solids?.list?.() || []; + const scoped = currentFeatureId + ? solids.filter(solid => String(solid?.source?.feature_id || '') === String(currentFeatureId)) + : solids; + const hasProfileKeyMap = scoped.some(solid => Array.isArray(solid?.source?.profile_keys) && solid.source.profile_keys.length); + for (const solid of solids) { + if (currentFeatureId && String(solid?.source?.feature_id || '') !== String(currentFeatureId)) { + continue; + } + const srcProfile = solid?.source?.profile || null; + const srcProfileKeys = Array.isArray(solid?.source?.profile_keys) ? solid.source.profile_keys : []; + const provProfile = solid?.provenance?.source?.profile || null; + const provFaces = Array.isArray(solid?.provenance?.faces) ? solid.provenance.faces : []; + const matchProfile = p => ( + String(p?.sketchId || '') === sketchId && + String(p?.profileId || '') === profileId + ); + const matchFace = provFaces.some(face => matchProfile(face?.source || null)); + const matchKeys = srcProfileKeys.some(k => String(k || '') === profileKey); + const match = hasProfileKeyMap + ? matchKeys + : (matchKeys || matchProfile(srcProfile) || matchProfile(provProfile) || matchFace); + if (match) { + if (solid?.id) hoveredSolidIds.push(solid.id); + } + } + } + api.solids?.setHovered?.(hoveredSolidIds); + api.interact.hoveredSketchProfileKey = key; + api.sketchRuntime?.setHoveredProfile?.(key); + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + + syncExtrudeTargetSelection(feature) { + const isExtrude = feature?.type === 'extrude' && this.currentFeatureId === feature?.id; + if (!isExtrude) { + return; + } + const operation = String(feature?.params?.operation || 'new'); + const targets = Array.isArray(feature?.input?.targets) ? feature.input.targets.filter(Boolean) : []; + if (operation === 'add' || operation === 'subtract') { + api.solids?.setSelected?.(targets); + } else { + api.solids?.setSelected?.([]); + } + }, + + syncBooleanSolidSelection(feature) { + const isBoolean = feature?.type === 'boolean' && this.currentFeatureId === feature?.id; + if (!isBoolean) { + return; + } + const input = this.getBooleanInput(feature); + const mode = String(feature?.params?.mode || 'add'); + const targets = Array.isArray(input.targets) ? input.targets.filter(Boolean) : []; + const tools = Array.isArray(input.tools) ? input.tools.filter(Boolean) : []; + const selected = mode === 'subtract' ? Array.from(new Set([...targets, ...tools])) : targets; + api.solids?.setSelected?.(selected); + }, + + syncChamferEdgeSelection(feature) { + const isChamfer = feature?.type === 'chamfer' && this.currentFeatureId === feature?.id; + if (!isChamfer) { + api.interact.selectedSolidEdgeKeys?.clear?.(); + api.interact.hoveredSolidEdgeKey = null; + api.solids?.setSelectedEdges?.([]); + api.solids?.setHoveredEdge?.(null); + return; + } + const keys = (Array.isArray(feature?.input?.edges) ? feature.input.edges : []) + .map(edge => { + const resolved = api.solids?.resolveChamferRefToEdgeKey?.(edge) || null; + if (!resolved) return null; + return String(resolved).startsWith('segment:') + ? String(resolved).substring('segment:'.length) + : String(resolved); + }) + .filter(Boolean); + api.interact.selectedSolidEdgeKeys = new Set(keys); + api.solids?.setSelectedEdges?.(keys); + }, + + getBooleanInput(feature) { + const input = feature?.input || {}; + const targets = Array.isArray(input.targets) ? input.targets : []; + const tools = Array.isArray(input.tools) ? input.tools : []; + return { + targets: targets.filter(Boolean), + tools: tools.filter(Boolean) + }; + }, + + getSolidDisplayName(solidId, solid = null) { + const sid = String(solidId || '').trim(); + if (!sid) return 'Solid'; + if (solid?.name && !solid.name.includes(':body:')) { + return solid.name; + } + const match = sid.match(/^(.+):body:(\d+)$/); + if (match) { + const featureId = match[1]; + const bodyIndex = Number(match[2]); + const feature = api.features.findById(featureId); + const base = feature?.name || feature?.type || 'Solid'; + if (Number.isFinite(bodyIndex)) { + return `${base} / Body ${bodyIndex + 1}`; + } + return base; + } + return sid; + }, + + getBooleanPickRole() { + return this._booleanPickRole === 'tools' ? 'tools' : 'targets'; + }, + + createTextField(label, value, onCommit) { + const wrap = document.createElement('div'); + wrap.className = 'props-field'; + const l = document.createElement('label'); + l.textContent = label; + const input = document.createElement('input'); + input.type = 'text'; + input.value = value; + input.onkeydown = event => { + if (event.key === 'Enter') { + input.blur(); + } + }; + input.onblur = () => onCommit(input.value); + wrap.appendChild(l); + wrap.appendChild(input); + return wrap; + }, + + createNumberField(label, value, onCommit) { + const wrap = document.createElement('div'); + wrap.className = 'props-field'; + const l = document.createElement('label'); + l.textContent = label; + const input = document.createElement('input'); + input.type = 'number'; + input.step = '0.1'; + input.value = String(value); + input.onchange = () => { + const next = Number(input.value); + if (Number.isFinite(next)) { + onCommit(next); + } + }; + wrap.appendChild(l); + wrap.appendChild(input); + return wrap; + }, + + createSelectField(label, value, options, onChange) { + const wrap = document.createElement('div'); + wrap.className = 'props-field'; + const l = document.createElement('label'); + l.textContent = label; + const select = document.createElement('select'); + for (const option of options) { + const el = document.createElement('option'); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + } + select.value = value; + select.onchange = () => onChange(select.value); + wrap.appendChild(l); + wrap.appendChild(select); + return wrap; + }, + + createCheckboxField(label, checked, onChange) { + const wrap = document.createElement('div'); + wrap.className = 'props-field'; + const l = document.createElement('label'); + l.textContent = label; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.checked = !!checked; + input.onchange = () => onChange(!!input.checked); + wrap.appendChild(l); + wrap.appendChild(input); + return wrap; + }, + + onChanged() { + const feature = api.features.findById(this.currentFeatureId); + if (feature) { + this.renderFeature(feature); + } + if (typeof this._onChange === 'function') { + this._onChange(); + } + } +}; + +export { properties }; diff --git a/src/void/sketch/api.js b/src/void/sketch/api.js new file mode 100644 index 00000000..798530c3 --- /dev/null +++ b/src/void/sketch/api.js @@ -0,0 +1,36 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function createSketchApi(getApi, idFactory) { + return { + createFromTarget(target) { + const api = getApi(); + const doc = api.document.current; + if (!doc || !target?.frame) { + return null; + } + const sketchCount = (doc.features || []).filter(f => f?.type === 'sketch').length; + const feature = { + id: idFactory(), + type: 'sketch', + name: `Sketch ${sketchCount + 1}`, + created_at: Date.now(), + plane: JSON.parse(JSON.stringify(target.frame)), + entities: [], + constraints: [], + dimensions: [], + target: { + kind: target.kind || 'plane', + id: target.id || null, + name: target.name || null, + label: target.label || null, + source: target.source || null, + offset: Number(target?.offset || 0) + } + }; + api.features.add(feature); + return feature; + } + }; +} + +export { createSketchApi }; diff --git a/src/void/sketch/constants.js b/src/void/sketch/constants.js new file mode 100644 index 00000000..21efbbe9 --- /dev/null +++ b/src/void/sketch/constants.js @@ -0,0 +1,17 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +const SKETCH_HIT_POINT_PX = 11; +const SKETCH_HIT_LINE_PX = 10; +const SKETCH_DRAG_START_PX = 1; +const SKETCH_MIN_LINE_LENGTH = 1e-4; +const SKETCH_POINT_MERGE_EPS = 1e-4; +const SKETCH_VIRTUAL_ORIGIN_ID = '__sketch-origin__'; + +export { + SKETCH_HIT_POINT_PX, + SKETCH_HIT_LINE_PX, + SKETCH_DRAG_START_PX, + SKETCH_MIN_LINE_LENGTH, + SKETCH_POINT_MERGE_EPS, + SKETCH_VIRTUAL_ORIGIN_ID +}; diff --git a/src/void/sketch/constraints.js b/src/void/sketch/constraints.js new file mode 100644 index 00000000..7e775553 --- /dev/null +++ b/src/void/sketch/constraints.js @@ -0,0 +1,358 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { make_gcs_wrapper, Algorithm, SolveStatus } from '../solver/planegcs.js'; +import { + enforceWithFallback, + applyThreePointCircleDefinitions, + captureFixedAnchors, + getLineEndpointId, + applyPolygonPatternConstraints, + applyCircularPatternConstraints, + applyGridPatternConstraints, + applyPointOnArcConstraints, + applyArcCenterCoincidentConstraints, + applyMidpointConstraints, + applyTangentConstraints, + applyMirrorConstraints +} from './constraints_fallback.js'; + +const EPS = 1e-9; + +let gcsWrapper = null; +let gcsInitPromise = null; +let gcsInitError = null; + +function withQuietRedundantLogs(fn) { + const origLog = console.log; + const origErr = console.error; + const isRedundantMsg = msg => { + const text = String(msg || ''); + return text.includes('Redundant solving:') + || text.includes('RedundantSolving-DogLeg-'); + }; + console.log = (...args) => { + if (args.some(isRedundantMsg)) return; + origLog(...args); + }; + console.error = (...args) => { + if (args.some(isRedundantMsg)) return; + origErr(...args); + }; + try { + return fn(); + } finally { + console.log = origLog; + console.error = origErr; + } +} + +function initSketchConstraintsSolver() { + if (gcsWrapper) { + return Promise.resolve(gcsWrapper); + } + if (gcsInitPromise) { + return gcsInitPromise; + } + gcsInitPromise = make_gcs_wrapper().then(wrapper => { + gcsWrapper = wrapper; + return wrapper; + }).catch(error => { + gcsInitError = error; + console.warn('sketch_constraints: planegcs init failed, using fallback solver', error); + return null; + }); + return gcsInitPromise; +} + +function enforceSketchConstraintsInPlace(sketch, opts = {}) { + if (opts?.useFallback) { + return enforceWithFallback(sketch, opts); + } + + if (gcsWrapper) { + try { + return enforceWithPlanegcs(sketch, opts); + } catch (error) { + console.warn('sketch_constraints: planegcs solve failed, using fallback solver', error); + return enforceWithFallback(sketch, opts); + } + } + + if (!gcsInitPromise && !gcsInitError) { + // Fire-and-forget lazy initialization; callers remain synchronous. + initSketchConstraintsSolver(); + } + + return enforceWithFallback(sketch, opts); +} + +function enforceWithPlanegcs(sketch, opts = {}) { + const entities = Array.isArray(sketch?.entities) ? sketch.entities : []; + const constraints = Array.isArray(sketch?.constraints) ? sketch.constraints : []; + if (!entities.length) { + return false; + } + + const primitives = []; + const pointById = new Map(); + const lineById = new Map(); + const arcById = new Map(); + + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + const p = { + id: String(entity.id), + type: 'point', + x: Number(entity.x || 0), + y: Number(entity.y || 0), + fixed: false + }; + pointById.set(entity.id, p); + primitives.push(p); + } + } + + for (const entity of entities) { + const aId = getLineEndpointId(entity, 'a'); + const bId = getLineEndpointId(entity, 'b'); + if (entity?.type === 'line' && entity.id && pointById.has(aId) && pointById.has(bId)) { + const l = { + id: String(entity.id), + type: 'line', + p1_id: String(aId), + p2_id: String(bId) + }; + lineById.set(entity.id, l); + primitives.push(l); + } + if (entity?.type === 'arc' && entity.id && pointById.has(aId) && pointById.has(bId)) { + arcById.set(entity.id, entity); + } + } + + for (const c of constraints) { + const gc = toPlanegcsConstraint(c, pointById, lineById); + if (Array.isArray(gc)) { + primitives.push(...gc); + } else if (gc) { + primitives.push(gc); + } + } + + let changed = false; + const pointEntityById = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e])); + + if (primitives.length) { + gcsWrapper.clear_data(); + gcsWrapper.push_primitives_and_params(primitives); + const status = withQuietRedundantLogs(() => gcsWrapper.solve(Algorithm.DogLeg)); + if (!(status === SolveStatus.Success || status === SolveStatus.Converged)) { + throw new Error(`planegcs solve status=${status}`); + } + + gcsWrapper.apply_solution(); + + const solvedPrimitives = gcsWrapper?.sketch_index?.get_primitives?.() || []; + const solvedPointById = new Map( + solvedPrimitives + .filter(e => e?.type === 'point' && e.id) + .map(e => [e.id, e]) + ); + for (const [id, p] of pointEntityById.entries()) { + const solved = solvedPointById.get(id); + if (!solved) continue; + const nx = Number(solved.x || 0); + const ny = Number(solved.y || 0); + if (Math.abs((p.x || 0) - nx) > EPS || Math.abs((p.y || 0) - ny) > EPS) { + p.x = nx; + p.y = ny; + changed = true; + } + } + } + + const fixed = captureFixedAnchors(constraints, pointEntityById); + const dragged = new Set(Array.isArray(opts?.draggedPointIds) ? opts.draggedPointIds : []); + const draggedArcs = new Set(Array.isArray(opts?.draggedArcIds) ? opts.draggedArcIds : []); + changed = applyPolygonPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged) || changed; + changed = applyCircularPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed; + changed = applyGridPatternConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed; + changed = applyThreePointCircleDefinitions(arcById, pointEntityById, fixed) || changed; + changed = applyPointOnArcConstraints(constraints, pointEntityById, arcById, fixed) || changed; + changed = applyArcCenterCoincidentConstraints(constraints, pointEntityById, lineById, arcById, fixed) || changed; + changed = applyMidpointConstraints(constraints, pointEntityById, fixed, dragged) || changed; + for (let i = 0; i < 4; i++) { + const tChanged = applyTangentConstraints(constraints, pointEntityById, lineById, arcById, fixed); + if (!tChanged) break; + changed = true; + } + changed = applyMirrorConstraints(constraints, pointEntityById, lineById, arcById, fixed, dragged, draggedArcs) || changed; + + // Final polish: reconcile constraints that are handled outside planegcs + // (for example point_on_arc groups used by inscribed/circumscribed polygons) + // so the sketch settles immediately after constraint application. + const fallbackChanged = enforceWithFallback(sketch, { + ...opts, + useFallback: true, + iterations: Math.max(16, opts?.iterations || 24) + }); + return fallbackChanged || changed; +} + +function toPlanegcsConstraint(c, pointById, lineById) { + if (!c?.id || !c?.type) return null; + const id = String(c.id); + const refs = Array.isArray(c.refs) ? c.refs : []; + + if (c.type === 'coincident') { + if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null; + return { + id, + type: 'p2p_coincident', + p1_id: String(refs[0]), + p2_id: String(refs[1]) + }; + } + if (c.type === 'point_on_line') { + if (refs.length < 2) return null; + const pId = pointById.has(refs[0]) ? refs[0] : (pointById.has(refs[1]) ? refs[1] : null); + const lId = lineById.has(refs[0]) ? refs[0] : (lineById.has(refs[1]) ? refs[1] : null); + if (!pId || !lId) return null; + return { + id, + type: 'point_on_line_pl', + p_id: String(pId), + l_id: String(lId) + }; + } + if (c.type === 'dimension') { + if (c?.data?.mode === 'driven') return null; + const value = Number(c?.data?.value); + if (!Number.isFinite(value) || value <= EPS) return null; + if (refs.length === 1 && lineById.has(refs[0])) { + const line = lineById.get(refs[0]); + if (!line) return null; + return { + id, + type: 'p2p_distance', + p1_id: String(line.p1_id), + p2_id: String(line.p2_id), + distance: value + }; + } + if (refs.length >= 2 && pointById.has(refs[0]) && pointById.has(refs[1])) { + return { + id, + type: 'p2p_distance', + p1_id: String(refs[0]), + p2_id: String(refs[1]), + distance: value + }; + } + return null; + } + + if (c.type === 'horizontal') { + const lId = refs[0]; + if (!lId || !lineById.has(lId)) return null; + return { + id, + type: 'horizontal_l', + l_id: String(lId) + }; + } + if (c.type === 'horizontal_points') { + if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null; + const lId = `${id}:hl`; + return [ + { id: lId, type: 'line', p1_id: String(refs[0]), p2_id: String(refs[1]) }, + { id: `${id}:c`, type: 'horizontal_l', l_id: lId } + ]; + } + + if (c.type === 'vertical') { + const lId = refs[0]; + if (!lId || !lineById.has(lId)) return null; + return { + id, + type: 'vertical_l', + l_id: String(lId) + }; + } + if (c.type === 'vertical_points') { + if (refs.length < 2 || !pointById.has(refs[0]) || !pointById.has(refs[1])) return null; + const lId = `${id}:vl`; + return [ + { id: lId, type: 'line', p1_id: String(refs[0]), p2_id: String(refs[1]) }, + { id: `${id}:c`, type: 'vertical_l', l_id: lId } + ]; + } + + if (c.type === 'perpendicular') { + if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null; + return { + id, + type: 'perpendicular_ll', + l1_id: String(refs[0]), + l2_id: String(refs[1]) + }; + } + if (c.type === 'equal') { + if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null; + return { + id, + type: 'equal_length', + l1_id: String(refs[0]), + l2_id: String(refs[1]) + }; + } + if (c.type === 'collinear') { + if (refs.length < 2 || !lineById.has(refs[0]) || !lineById.has(refs[1])) return null; + const l1 = lineById.get(refs[0]); + const l2 = lineById.get(refs[1]); + if (!l1 || !l2) return null; + return [ + { + id: `${id}:parallel`, + type: 'parallel', + l1_id: String(refs[0]), + l2_id: String(refs[1]) + }, + { + id: `${id}:point_on`, + type: 'point_on_line_pl', + p_id: String(l2.p1_id), + l_id: String(refs[0]) + } + ]; + } + + if (c.type === 'fixed') { + const pId = refs[0]; + if (!pId || !pointById.has(pId)) return null; + const p = pointById.get(pId); + const anchor = c.data?.anchors?.[pId] || { x: p.x, y: p.y }; + const cxId = `${id}:x`; + const cyId = `${id}:y`; + // Return as paired constraints; caller accepts arrays from mapper. + return [ + { + id: cxId, + type: 'coordinate_x', + p_id: String(pId), + x: Number(anchor.x || 0) + }, + { + id: cyId, + type: 'coordinate_y', + p_id: String(pId), + y: Number(anchor.y || 0) + } + ]; + } + + return null; +} + + +export { initSketchConstraintsSolver, enforceSketchConstraintsInPlace }; diff --git a/src/void/sketch/constraints_actions.js b/src/void/sketch/constraints_actions.js new file mode 100644 index 00000000..c36b1ed3 --- /dev/null +++ b/src/void/sketch/constraints_actions.js @@ -0,0 +1,882 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from '../api.js'; +import { space } from '../../moto/space.js'; +import { enforceSketchConstraintsInPlace } from './constraints.js'; +import * as sketchCreate from './create.js'; +import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js'; + +function getConstraintMode(constraint) { + const mode = constraint?.data?.mode; + return mode === 'driven' ? 'driven' : 'driving'; +} + +function buildPatternCloneToSourceMap(feature) { + const map = new Map(); + const constraints = Array.isArray(feature?.constraints) ? feature.constraints : []; + for (const c of constraints) { + if (c?.type !== 'circular_pattern' && c?.type !== 'grid_pattern') continue; + const data = c?.data || {}; + const sourceIds = Array.isArray(data?.sourceIds) ? data.sourceIds.filter(id => typeof id === 'string' && id) : []; + const copies = Array.isArray(data?.copies) ? data.copies : []; + const pointMaps = Array.isArray(data?.pointMaps) ? data.pointMaps : []; + for (const copyRec of copies) { + const ids = Array.isArray(copyRec) + ? copyRec + : (Array.isArray(copyRec?.ids) ? copyRec.ids : []); + for (let i = 0; i < sourceIds.length; i++) { + const srcId = sourceIds[i]; + const dstId = ids[i]; + if (typeof srcId === 'string' && srcId && typeof dstId === 'string' && dstId) { + map.set(dstId, srcId); + } + } + } + for (const pointRec of pointMaps) { + const pairs = Array.isArray(pointRec) + ? pointRec + : (Array.isArray(pointRec?.pairs) ? pointRec.pairs : []); + for (const pair of pairs) { + if (!Array.isArray(pair) || pair.length < 2) continue; + const srcId = pair[0]; + const dstId = pair[1]; + if (typeof srcId === 'string' && srcId && typeof dstId === 'string' && dstId) { + map.set(dstId, srcId); + } + } + } + } + return map; +} + +function mapPatternRefToSource(ref, cloneToSource) { + if (ref === SKETCH_VIRTUAL_ORIGIN_ID) return ref; + if (typeof ref !== 'string' || !ref) return ref; + if (ref.startsWith('arc-center:')) { + const arcId = ref.substring('arc-center:'.length); + const srcArcId = cloneToSource.get(arcId) || arcId; + return `arc-center:${srcArcId}`; + } + return cloneToSource.get(ref) || ref; +} + +function applyConstraintDisplayRefs(constraint, displayRefs = null) { + if (!constraint) return; + const refs = Array.isArray(displayRefs) ? displayRefs.filter(Boolean) : []; + if (!refs.length) return; + constraint.ui = constraint.ui || {}; + constraint.ui.display_refs = refs; +} + +function clearSketchTransientInputState(ctx) { + if (!ctx) return; + ctx.sketchPointerDown = null; + ctx.sketchDrag = null; + if (api?.sketchRuntime) { + api.sketchRuntime._glyphDrag = null; + } + // Native prompt can swallow pointer-up events; flush camera/input controls + // so trackball/orbit state cannot remain latched into drag mode. + try { + const ctrl = space?.view?.ctrl; + ctrl?.onMouseUp?.({ button: 0 }); + ctrl?.resetInputState?.(); + const doc = self.document; + const evtInit = { bubbles: true, cancelable: true, button: 0, buttons: 0, clientX: 0, clientY: 0 }; + doc?.dispatchEvent?.(new MouseEvent('mouseup', evtInit)); + if (typeof PointerEvent !== 'undefined') { + doc?.dispatchEvent?.(new PointerEvent('pointerup', evtInit)); + } + } catch (e) { + // no-op + } + space?.update?.(); +} + +function getLineEndpointIds(line) { + if (!line) return [null, null]; + const aId = typeof line?.a === 'string' ? line.a : (typeof line?.p1_id === 'string' ? line.p1_id : null); + const bId = typeof line?.b === 'string' ? line.b : (typeof line?.p2_id === 'string' ? line.p2_id : null); + return [aId, bId]; +} + +function getArcEndpoints(arc) { + if (!arc) return [null, null]; + const aId = typeof arc?.a === 'string' ? arc.a : (typeof arc?.p1_id === 'string' ? arc.p1_id : null); + const bId = typeof arc?.b === 'string' ? arc.b : (typeof arc?.p2_id === 'string' ? arc.p2_id : null); + return [aId, bId]; +} + +function resolvePointLike(byId, ref) { + if (!ref) return null; + if (ref === SKETCH_VIRTUAL_ORIGIN_ID) { + return { x: 0, y: 0 }; + } + if (typeof ref === 'string' && ref.startsWith('arc-center:')) { + const arcId = ref.substring('arc-center:'.length); + const arc = byId.get(arcId); + if (arc?.type !== 'arc') return null; + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + const [aId, bId] = getArcEndpoints(arc); + const a = byId.get(aId); + const b = byId.get(bId); + if (!a || !b) return null; + const mx = Number(arc?.mx); + const my = Number(arc?.my); + if (!Number.isFinite(mx) || !Number.isFinite(my)) return null; + const x1 = a.x || 0; const y1 = a.y || 0; + const x2 = b.x || 0; const y2 = b.y || 0; + const x3 = mx; const y3 = my; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) return null; + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const cx2 = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const cy2 = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + if (!Number.isFinite(cx2) || !Number.isFinite(cy2)) return null; + return { x: cx2, y: cy2 }; + } + const point = byId.get(ref); + if (point?.type === 'point') { + return { x: point.x || 0, y: point.y || 0 }; + } + return null; +} + +function measureDimensionValue(entities, refs = []) { + const byId = new Map((entities || []).map(e => [e?.id, e])); + if (refs.length === 1) { + const ent = byId.get(refs[0]); + if (ent?.type === 'line') { + const [aId, bId] = getLineEndpointIds(ent); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type !== 'point' || b?.type !== 'point') return NaN; + return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + } + if (ent?.type === 'arc') { + const center = resolvePointLike(byId, `arc-center:${ent.id}`); + if (!center) return NaN; + const [aId] = getArcEndpoints(ent); + const a = byId.get(aId); + if (a?.type !== 'point') return NaN; + return Math.hypot((a.x || 0) - center.x, (a.y || 0) - center.y) * 2; + } + return NaN; + } + if (refs.length >= 2) { + const a = resolvePointLike(byId, refs[0]); + const b = resolvePointLike(byId, refs[1]); + if (!a || !b) return NaN; + return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + } + return NaN; +} + +function deleteSelectedSketchConstraints() { + const feature = this.getEditingSketchFeature(); + if (!feature || !this.selectedSketchConstraints?.size) { + return false; + } + const removeIds = new Set(this.selectedSketchConstraints); + let removed = 0; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const keep = []; + for (const c of sketch.constraints) { + if (c?.id && removeIds.has(c.id)) { + removed++; + } else { + keep.push(c); + } + } + sketch.constraints = keep; + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'constraints.remove', ids: Array.from(removeIds) } + }); + if (!removed) { + return false; + } + this.selectedSketchConstraints.clear(); + this.hoveredSketchConstraintId = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function deleteSelectedSketchEntities() { + const feature = this.getEditingSketchFeature(); + if (!feature || !this.selectedSketchEntities.size) { + return false; + } + + const removeIds = new Set(this.selectedSketchEntities); + let removed = 0; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + + const endpointCandidates = new Set(); + for (const entity of sketch.entities) { + if (!removeIds.has(entity?.id)) continue; + if (entity?.type !== 'line' && entity?.type !== 'arc') continue; + if (typeof entity.a === 'string') endpointCandidates.add(entity.a); + if (typeof entity.b === 'string') endpointCandidates.add(entity.b); + if (entity?.type === 'arc') { + const threePointIds = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds : []; + for (const pid of threePointIds) { + if (typeof pid === 'string') endpointCandidates.add(pid); + } + } + } + if (endpointCandidates.size) { + const prospectiveRemove = new Set([...removeIds, ...endpointCandidates]); + const usedByRemainingCurve = new Set(); + for (const entity of sketch.entities) { + if (removeIds.has(entity?.id)) continue; + if (entity?.type !== 'line' && entity?.type !== 'arc') continue; + if (typeof entity.a === 'string') usedByRemainingCurve.add(entity.a); + if (typeof entity.b === 'string') usedByRemainingCurve.add(entity.b); + } + const usedByRemainingConstraint = new Set(); + for (const constraint of sketch.constraints) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.some(ref => prospectiveRemove.has(ref))) continue; + for (const ref of refs) { + usedByRemainingConstraint.add(ref); + } + } + for (const pointId of endpointCandidates) { + if (usedByRemainingCurve.has(pointId)) continue; + if (usedByRemainingConstraint.has(pointId)) continue; + removeIds.add(pointId); + } + } + + const keep = []; + for (const entity of sketch.entities) { + if (removeIds.has(entity.id)) { + removed++; + } else { + keep.push(entity); + } + } + sketch.entities = keep; + sketch.constraints = sketch.constraints.filter(constraint => { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.some(ref => removeIds.has(ref))) return false; + if (constraint?.type === 'circular_pattern') { + const data = constraint?.data || {}; + for (const arr of (data?.copies || [])) { + const ids = Array.isArray(arr) ? arr : (Array.isArray(arr?.ids) ? arr.ids : []); + for (const id of ids) { + if (removeIds.has(id)) return false; + } + } + for (const pairs of (data?.pointMaps || [])) { + const recPairs = Array.isArray(pairs) ? pairs : (Array.isArray(pairs?.pairs) ? pairs.pairs : []); + for (const pair of recPairs) { + if (Array.isArray(pair) && removeIds.has(pair[1])) return false; + } + } + } + return true; + }); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.remove', ids: Array.from(removeIds) } + }); + + if (!removed) { + return false; + } + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.hoveredSketchEntityId = null; + this.setSketchTool('select'); + this.updateSketchInteractionVisuals(); + return true; +} + +function toggleSelectedConstruction() { + const feature = this.getEditingSketchFeature(); + if (!feature || !this.selectedSketchEntities.size) { + return false; + } + + const selected = (feature.entities || []).filter(entity => + this.selectedSketchEntities.has(entity.id) && (entity.type === 'line' || entity.type === 'arc')); + if (!selected.length) { + return false; + } + + const setConstruction = selected.some(entity => !entity.construction); + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + for (const entity of sketch.entities) { + if (!this.selectedSketchEntities.has(entity.id) || (entity.type !== 'line' && entity.type !== 'arc')) { + continue; + } + entity.construction = setConstruction; + } + }, { + opType: 'feature.update', + payload: { field: 'construction', value: setConstruction } + }); + + this.updateSketchInteractionVisuals(); + return true; +} + +function applySketchConstraint(type) { + const feature = this.getEditingSketchFeature(); + if (!feature) { + return false; + } + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const cloneToSource = buildPatternCloneToSourceMap(feature); + const selectedEntityIds = new Set( + Array.from(this.selectedSketchEntities || []) + .map(id => mapPatternRefToSource(id, cloneToSource)) + .filter(Boolean) + ); + const selectedArcCenterIds = new Set( + Array.from(this.selectedSketchArcCenters || []) + .map(id => mapPatternRefToSource(id, cloneToSource)) + .map(ref => typeof ref === 'string' && ref.startsWith('arc-center:') ? ref.substring('arc-center:'.length) : ref) + .filter(id => typeof id === 'string' && id) + ); + const sourceToDisplay = new Map(); + for (const raw of Array.from(this.selectedSketchEntities || [])) { + const mapped = mapPatternRefToSource(raw, cloneToSource); + if (typeof mapped === 'string' && mapped && !sourceToDisplay.has(mapped)) { + sourceToDisplay.set(mapped, raw); + } + } + for (const rawArcId of Array.from(this.selectedSketchArcCenters || [])) { + const raw = `arc-center:${rawArcId}`; + const mapped = mapPatternRefToSource(raw, cloneToSource); + if (typeof mapped === 'string' && mapped && !sourceToDisplay.has(mapped)) { + sourceToDisplay.set(mapped, raw); + } + } + const displayRefsFor = refs => (Array.isArray(refs) ? refs.map(ref => sourceToDisplay.get(ref) || ref) : []); + const selected = entities.filter(entity => selectedEntityIds.has(entity.id)); + const hasOriginSelected = selectedEntityIds.has(SKETCH_VIRTUAL_ORIGIN_ID); + const hasArcCenterSelected = selectedArcCenterIds.size > 0; + if (!selected.length && !hasOriginSelected && !hasArcCenterSelected) { + return false; + } + + const lines = selected.filter(entity => entity.type === 'line'); + const arcs = selected.filter(entity => entity.type === 'arc'); + const points = selected.filter(entity => entity.type === 'point'); + const entitiesById = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity])); + const arcCenters = Array.from(selectedArcCenterIds || []) + .map(id => entitiesById.get(id)) + .filter(entity => entity?.type === 'arc'); + const pointLikeRefs = []; + for (const point of points) pointLikeRefs.push(point.id); + for (const arc of arcCenters) pointLikeRefs.push(`arc-center:${arc.id}`); + if (hasOriginSelected) pointLikeRefs.push(SKETCH_VIRTUAL_ORIGIN_ID); + const specs = []; + + if (type === 'horizontal' || type === 'vertical') { + if (lines.length) { + for (const line of lines) { + specs.push({ type, refs: [line.id], displayRefs: displayRefsFor([line.id]) }); + } + } else if (pointLikeRefs.length === 2) { + const refs = [pointLikeRefs[0], pointLikeRefs[1]]; + specs.push({ type: `${type}_points`, refs, displayRefs: displayRefsFor(refs) }); + } else { + return false; + } + } else if (type === 'perpendicular') { + if (lines.length !== 2) { + return false; + } + { + const refs = [lines[0].id, lines[1].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } + } else if (type === 'equal') { + if (lines.length >= 2) { + const base = lines[0]; + for (let i = 1; i < lines.length; i++) { + const refs = [base.id, lines[i].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } + } else if (arcs.length >= 2) { + const base = arcs[0]; + for (let i = 1; i < arcs.length; i++) { + const refs = [base.id, arcs[i].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } + } else { + return false; + } + } else if (type === 'collinear') { + if (lines.length !== 2) { + return false; + } + { + const refs = [lines[0].id, lines[1].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } + } else if (type === 'tangent') { + if (lines.length === 1 && arcs.length === 1) { + const refs = [lines[0].id, arcs[0].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } else if (lines.length === 0 && arcs.length === 2) { + const refs = [arcs[0].id, arcs[1].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } else { + return false; + } + } else if (type === 'midpoint') { + if (points.length === 3) { + const refs = [points[0].id, points[1].id, points[2].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } else if (points.length === 1 && lines.length === 1) { + const line = lines[0]; + const aId = typeof line?.a === 'string' ? line.a : (typeof line?.p1_id === 'string' ? line.p1_id : null); + const bId = typeof line?.b === 'string' ? line.b : (typeof line?.p2_id === 'string' ? line.p2_id : null); + if (!aId || !bId) { + return false; + } + const refs = [points[0].id, aId, bId]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } else { + return false; + } + } else if (type === 'coincident') { + if (points.length === 2) { + const circleArc = this.findArcWithEndpoints(feature, points[0].id, points[1].id); + if (circleArc) { + const converted = this.convertArcToCircle(feature, circleArc.id, points[0].id, points[1].id); + if (converted) { + this.clearSketchSelection?.(); + } + return converted; + } + const refs = [points[0].id, points[1].id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } else if (points.length === 1 && lines.length === 1) { + const refs = [points[0].id, lines[0].id]; + specs.push({ type: 'point_on_line', refs, displayRefs: displayRefsFor(refs) }); + } else if (points.length === 1 && arcs.length === 1) { + const refs = [points[0].id, arcs[0].id]; + specs.push({ type: 'point_on_arc', refs, displayRefs: displayRefsFor(refs) }); + } else if (points.length === 1 && arcCenters.length === 1) { + const refs = [arcCenters[0].id, points[0].id]; + specs.push({ type: 'arc_center_coincident', refs, displayRefs: displayRefsFor(refs) }); + } else if (arcCenters.length === 1 && arcs.length === 1 && points.length === 0 && lines.length === 0) { + const sourceArcId = arcCenters[0].id; + const targetArcId = arcs[0].id; + if (!sourceArcId || !targetArcId || sourceArcId === targetArcId) { + return false; + } + const refs = [sourceArcId, targetArcId]; + specs.push({ type: 'arc_center_on_arc', refs, displayRefs: displayRefsFor(refs) }); + } else if (points.length === 1 && hasOriginSelected && lines.length === 0 && arcs.length === 0 && arcCenters.length === 0) { + const refs = [points[0].id]; + specs.push({ + type: 'fixed', + refs, + displayRefs: displayRefsFor(refs), + data: { anchors: { [points[0].id]: { x: 0, y: 0 } } } + }); + } else if (arcCenters.length === 1 && lines.length === 1 && points.length === 0 && arcs.length === 0) { + const refs = [arcCenters[0].id, lines[0].id]; + specs.push({ type: 'arc_center_on_line', refs, displayRefs: displayRefsFor(refs) }); + } else if (arcCenters.length === 1 && hasOriginSelected && points.length === 0 && lines.length === 0 && arcs.length === 0) { + const refs = [arcCenters[0].id]; + specs.push({ type: 'arc_center_fixed_origin', refs, displayRefs: displayRefsFor(refs) }); + } else { + return false; + } + } else if (type === 'dimension') { + let dimRefs = null; + if (lines.length === 1 && points.length === 0 && arcs.length === 0) { + dimRefs = [lines[0].id]; + } else if (arcs.length === 1 && lines.length === 0 && points.length === 0 && arcCenters.length === 0 && !hasOriginSelected) { + dimRefs = [arcs[0].id]; + } else if (pointLikeRefs.length === 2 && lines.length === 0 && arcs.length === 0) { + dimRefs = [pointLikeRefs[0], pointLikeRefs[1]]; + } else { + return false; + } + const normalized = this.normalizeConstraintRefs('dimension', dimRefs); + const current = this.findSketchConstraintInList?.(feature, 'dimension', normalized); + const currentValue = Number(current?.data?.value); + const measured = measureDimensionValue(entities, normalized); + const seed = Number.isFinite(currentValue) && currentValue > 0 + ? currentValue + : (Number.isFinite(measured) && measured > 0 ? measured : 10); + clearSketchTransientInputState(this); + const input = window.prompt('Dimension value', String(Number(seed.toFixed(4)))); + clearSketchTransientInputState(this); + if (input === null) { + return false; + } + const value = Number(input); + if (!Number.isFinite(value) || value <= 0) { + return false; + } + specs.push({ + type, + refs: normalized, + displayRefs: displayRefsFor(normalized), + data: { value, mode: getConstraintMode(current) } + }); + } else if (type === 'min_distance' || type === 'max_distance') { + let refs = null; + if (arcs.length === 1 && pointLikeRefs.length === 1 && lines.length === 0) { + refs = [arcs[0].id, pointLikeRefs[0]]; + } else if (arcs.length === 1 && lines.length === 1 && pointLikeRefs.length === 0) { + refs = [arcs[0].id, lines[0].id]; + } else if (arcs.length === 2 && lines.length === 0 && pointLikeRefs.length === 0) { + refs = [arcs[0].id, arcs[1].id]; + } else { + return false; + } + const normalized = this.normalizeConstraintRefs(type, refs); + const current = this.findSketchConstraintInList?.(feature, type, normalized); + const currentValue = Number(current?.data?.value); + const seed = Number.isFinite(currentValue) && currentValue > 0 ? currentValue : 1; + clearSketchTransientInputState(this); + const promptLabel = type === 'min_distance' ? 'Min distance value' : 'Max distance value'; + const input = window.prompt(promptLabel, String(Number(seed.toFixed(4)))); + clearSketchTransientInputState(this); + if (input === null) { + return false; + } + const value = Number(input); + if (!Number.isFinite(value) || value <= 0) { + return false; + } + specs.push({ + type, + refs: normalized, + displayRefs: displayRefsFor(normalized), + data: { value } + }); + } else if (type === 'fixed') { + for (const point of points) { + const refs = [point.id]; + specs.push({ type, refs, displayRefs: displayRefsFor(refs) }); + } + } else { + return false; + } + + if (!specs.length) { + return false; + } + + let changed = false; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + for (const spec of specs) { + if (this.toggleSketchConstraintInList(sketch, sketch.constraints, spec.type, spec.refs, spec.data || null, spec.displayRefs || null)) { + changed = true; + } + } + if (changed) { + // Apply-path should settle quickly without over-driving fallback. + enforceSketchConstraintsInPlace(sketch, { iterations: 48 }); + enforceSketchConstraintsInPlace(sketch, { + useFallback: true, + iterations: 48, + tangentAggressive: false + }); + } + }, { + opType: 'feature.update', + payload: { + field: 'constraints.apply', + type, + refs: specs.map(spec => spec.refs), + data: specs.map(spec => spec.data || null) + } + }); + + if (changed) { + this.clearSketchSelection?.(); + } + return changed; +} + +function editSketchDimensionConstraint(constraintId) { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + clearSketchTransientInputState(this); + const constraints = Array.isArray(feature.constraints) ? feature.constraints : []; + const found = constraints.find(c => c?.id === constraintId && c?.type === 'dimension'); + if (!found) return false; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const measured = measureDimensionValue(entities, Array.isArray(found.refs) ? found.refs : []); + const current = Number(found?.data?.value); + const seed = Number.isFinite(current) && current > 0 + ? current + : (Number.isFinite(measured) && measured > 0 ? measured : 10); + const input = window.prompt('Dimension value', String(Number(seed.toFixed(4)))); + clearSketchTransientInputState(this); + if (input === null) return false; + const value = Number(input); + if (!Number.isFinite(value) || value <= 0) return false; + + let changed = false; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'dimension'); + if (!c) return; + c.data = c.data || {}; + const prev = Number(c.data.value); + if (Math.abs(prev - value) < 1e-9) return; + c.data.value = value; + changed = true; + if (getConstraintMode(c) === 'driving') { + enforceSketchConstraintsInPlace(sketch, { iterations: 48 }); + enforceSketchConstraintsInPlace(sketch, { + useFallback: true, + iterations: 48, + tangentAggressive: false + }); + } + }, { + opType: 'feature.update', + payload: { field: 'constraints.dimension.value', id: constraintId, value } + }); + return changed; +} + +function toggleSketchDimensionMode(constraintId) { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + let changed = false; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'dimension'); + if (!c) return; + c.data = c.data || {}; + const prev = getConstraintMode(c); + const next = prev === 'driving' ? 'driven' : 'driving'; + if (next === prev) return; + if (next === 'driving') { + const measured = measureDimensionValue(sketch.entities, Array.isArray(c.refs) ? c.refs : []); + if (Number.isFinite(measured) && measured > 0) { + c.data.value = measured; + } + } + c.data.mode = next; + changed = true; + if (next === 'driving') { + enforceSketchConstraintsInPlace(sketch, { iterations: 48 }); + enforceSketchConstraintsInPlace(sketch, { + useFallback: true, + iterations: 48, + tangentAggressive: false + }); + } + }, { + opType: 'feature.update', + payload: { field: 'constraints.dimension.mode', id: constraintId } + }); + return changed; +} + +function findArcWithEndpoints(feature, p1Id, p2Id) { + return sketchCreate.findArcWithEndpoints.call(this, feature, p1Id, p2Id); +} + +function convertArcToCircle(feature, arcId, p1Id, p2Id) { + return sketchCreate.convertArcToCircle.call(this, feature, arcId, p1Id, p2Id); +} + +function findSketchConstraintInList(sketch, type, refs) { + const list = Array.isArray(sketch?.constraints) ? sketch.constraints : []; + const key = this.makeSketchConstraintKey(type, refs); + for (const existing of list) { + if (this.makeSketchConstraintKey(existing?.type, existing?.refs || []) === key) { + return existing; + } + } + return null; +} + +function toggleSketchConstraintInList(sketch, list, type, refs, dataIn = null, displayRefsIn = null) { + const key = this.makeSketchConstraintKey(type, refs); + for (let i = 0; i < list.length; i++) { + const existing = list[i]; + if (this.makeSketchConstraintKey(existing?.type, existing?.refs || []) === key) { + if (type === 'dimension') { + existing.data = existing.data || {}; + const prev = Number(existing.data.value); + const next = Number(dataIn?.value); + if (!Number.isFinite(next) || next <= 0) { + return false; + } + if (Math.abs(prev - next) < 1e-9) { + return false; + } + existing.data.value = next; + applyConstraintDisplayRefs(existing, displayRefsIn); + return true; + } + if (type === 'min_distance' || type === 'max_distance') { + existing.data = existing.data || {}; + const prev = Number(existing.data.value); + const next = Number(dataIn?.value); + if (!Number.isFinite(next) || next <= 0) { + return false; + } + if (Math.abs(prev - next) < 1e-9) { + return false; + } + existing.data.value = next; + applyConstraintDisplayRefs(existing, displayRefsIn); + return true; + } + if (type === 'fixed' && dataIn?.anchors && typeof dataIn.anchors === 'object') { + existing.data = existing.data || {}; + existing.data.anchors = existing.data.anchors || {}; + let changed = false; + for (const id of this.normalizeConstraintRefs(type, refs)) { + const anchor = dataIn.anchors[id]; + if (!anchor) continue; + const x = Number(anchor.x); + const y = Number(anchor.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + const prev = existing.data.anchors[id]; + if (!prev || Math.abs((prev.x || 0) - x) > 1e-9 || Math.abs((prev.y || 0) - y) > 1e-9) { + existing.data.anchors[id] = { x, y }; + changed = true; + } + } + if (changed) { + applyConstraintDisplayRefs(existing, displayRefsIn); + } + return changed; + } + list.splice(i, 1); + return true; + } + } + const data = {}; + if (type === 'fixed') { + const provided = dataIn?.anchors && typeof dataIn.anchors === 'object' ? dataIn.anchors : null; + data.anchors = {}; + if (provided) { + for (const id of this.normalizeConstraintRefs(type, refs)) { + const anchor = provided[id]; + if (!anchor) continue; + const x = Number(anchor.x); + const y = Number(anchor.y); + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; + data.anchors[id] = { x, y }; + } + } + if (!Object.keys(data.anchors).length) { + const entities = Array.isArray(sketch?.entities) ? sketch.entities : []; + const pointById = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e])); + for (const id of this.normalizeConstraintRefs(type, refs)) { + const p = pointById.get(id); + if (p) { + data.anchors[id] = { x: p.x || 0, y: p.y || 0 }; + } + } + } + } + const rec = { + id: this.newSketchEntityId('cst'), + type, + refs: this.normalizeConstraintRefs(type, refs), + data, + created_at: Date.now() + }; + applyConstraintDisplayRefs(rec, displayRefsIn); + if (type === 'dimension') { + const value = Number(dataIn?.value); + if (!Number.isFinite(value) || value <= 0) { + return false; + } + rec.data = { ...rec.data, value }; + } + if (type === 'min_distance' || type === 'max_distance') { + const value = Number(dataIn?.value); + if (!Number.isFinite(value) || value <= 0) { + return false; + } + rec.data = { ...rec.data, value }; + } + list.push(rec); + return true; +} + +function normalizeConstraintRefs(type, refs) { + const out = Array.from(new Set((refs || []).filter(Boolean))); + if (type === 'horizontal' || type === 'vertical' || type === 'fixed') { + return out.slice(0, 1); + } + if (type === 'horizontal_points' || type === 'vertical_points') { + return out.slice(0, 2).sort(); + } + if (type === 'point_on_line' || type === 'point_on_arc') { + return out.slice(0, 2).sort(); + } + if (type === 'min_distance' || type === 'max_distance') { + return out.slice(0, 2).sort(); + } + if (type === 'arc_center_on_line') { + return out.slice(0, 2).sort(); + } + if (type === 'arc_center_on_arc') { + return out.slice(0, 2).sort(); + } + if (type === 'arc_center_fixed_origin') { + return out.slice(0, 1); + } + if (type === 'midpoint') { + return out.slice(0, 3); + } + if (type === 'arc_center_coincident') { + return out.slice(0, 2); + } + if (type === 'dimension') { + if (out.length === 1) { + return out; + } + return out.slice(0, 2).sort(); + } + return out.sort(); +} + +function makeSketchConstraintKey(type, refs) { + return `${type}:${this.normalizeConstraintRefs(type, refs).join(',')}`; +} + +export { + deleteSelectedSketchConstraints, + deleteSelectedSketchEntities, + toggleSelectedConstruction, + applySketchConstraint, + editSketchDimensionConstraint, + toggleSketchDimensionMode, + findArcWithEndpoints, + convertArcToCircle, + findSketchConstraintInList, + toggleSketchConstraintInList, + normalizeConstraintRefs, + makeSketchConstraintKey +}; diff --git a/src/void/sketch/constraints_fallback.js b/src/void/sketch/constraints_fallback.js new file mode 100644 index 00000000..0e61b4fb --- /dev/null +++ b/src/void/sketch/constraints_fallback.js @@ -0,0 +1,2125 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { applyTangentConstraint } from './constraints_tangent.js'; +import { isCircleCurve, isThreePointCircle, markCircleThreePoint } from './curve.js'; +import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js'; + +const EPS = 1e-9; + +function enforceWithFallback(sketch, opts = {}) { + const entities = Array.isArray(sketch?.entities) ? sketch.entities : []; + const constraints = Array.isArray(sketch?.constraints) ? sketch.constraints : []; + if (!entities.length) { + return false; + } + + const points = new Map(); + const lines = new Map(); + const arcs = new Map(); + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + points.set(entity.id, entity); + } else if (entity?.type === 'line' && entity.id) { + lines.set(entity.id, entity); + } else if (entity?.type === 'arc' && entity.id) { + arcs.set(entity.id, entity); + } + } + + const fixed = captureFixedAnchors(constraints, points); + const refreshThreePointCircles = () => applyThreePointCircleDefinitions(arcs, points, fixed); + let changed = refreshThreePointCircles(); + if (!constraints.length) { + return changed; + } + const dragged = new Set(Array.isArray(opts?.draggedPointIds) ? opts.draggedPointIds : []); + const draggedArcs = new Set(Array.isArray(opts?.draggedArcIds) ? opts.draggedArcIds : []); + const tangentAggressive = !!opts?.tangentAggressive; + const iterations = Math.max(1, Math.min(64, opts.iterations || 12)); + for (let i = 0; i < iterations; i++) { + let iterChanged = false; + for (const c of constraints) { + if (!c?.type) continue; + switch (c.type) { + case 'fixed': + iterChanged = applyFixed(c, points, fixed) || iterChanged; + break; + case 'coincident': + iterChanged = applyCoincident(c, points, fixed) || iterChanged; + break; + case 'mirror_point': + iterChanged = applyMirrorPoint(c, points, lines, fixed, dragged) || iterChanged; + break; + case 'mirror_arc': + iterChanged = applyMirrorArc(c, points, lines, arcs, fixed, dragged, draggedArcs) || iterChanged; + break; + case 'point_on_line': + iterChanged = applyPointOnLine(c, points, lines, fixed) || iterChanged; + break; + case 'point_on_arc': + iterChanged = applyPointOnArc(c, points, arcs, fixed) || iterChanged; + break; + case 'polygon_pattern': + iterChanged = applyPolygonPattern(c, constraints, points, lines, arcs, fixed, dragged) || iterChanged; + break; + case 'circular_pattern': + iterChanged = applyCircularPattern(c, points, lines, arcs, fixed, dragged, draggedArcs) || iterChanged; + break; + case 'grid_pattern': + iterChanged = applyGridPattern(c, points, lines, arcs, fixed, dragged, draggedArcs) || iterChanged; + break; + case 'horizontal': + iterChanged = applyHorizontal(c, points, lines, fixed) || iterChanged; + break; + case 'horizontal_points': + iterChanged = applyHorizontalPoints(c, points, arcs, fixed) || iterChanged; + break; + case 'vertical': + iterChanged = applyVertical(c, points, lines, fixed) || iterChanged; + break; + case 'vertical_points': + iterChanged = applyVerticalPoints(c, points, arcs, fixed) || iterChanged; + break; + case 'perpendicular': + iterChanged = applyPerpendicular(c, points, lines, fixed) || iterChanged; + break; + case 'equal': + iterChanged = applyEqual(c, points, lines, arcs, fixed, constraints) || iterChanged; + break; + case 'collinear': + iterChanged = applyCollinear(c, points, lines, fixed) || iterChanged; + break; + case 'dimension': + iterChanged = applyDimension(c, points, lines, arcs, fixed) || iterChanged; + break; + case 'min_distance': + iterChanged = applyDistanceToConstraint(c, constraints, points, lines, arcs, fixed, 'min') || iterChanged; + break; + case 'max_distance': + iterChanged = applyDistanceToConstraint(c, constraints, points, lines, arcs, fixed, 'max') || iterChanged; + break; + case 'tangent': + iterChanged = applyTangent(c, points, lines, arcs, fixed, dragged, draggedArcs, tangentAggressive) || iterChanged; + break; + case 'arc_center_coincident': + iterChanged = applyArcCenterCoincident(c, points, lines, arcs, fixed) || iterChanged; + break; + case 'arc_center_on_line': + iterChanged = applyArcCenterOnLine(c, points, lines, arcs, fixed) || iterChanged; + break; + case 'arc_center_on_arc': + iterChanged = applyArcCenterOnArc(c, points, lines, arcs, fixed) || iterChanged; + break; + case 'arc_center_fixed_origin': + iterChanged = applyArcCenterFixedOrigin(c, points, lines, arcs, fixed) || iterChanged; + break; + case 'midpoint': + iterChanged = applyMidpoint(c, points, fixed, dragged) || iterChanged; + break; + default: + break; + } + } + iterChanged = refreshThreePointCircles() || iterChanged; + // Keep on-curve constraints "hard" at the end of each iteration so + // subsequent line-length adjustments do not leave vertices drifting + // off circles/arcs during drag. + iterChanged = applyPointOnArcConstraints(constraints, points, arcs, fixed) || iterChanged; + iterChanged = applyEqualConstraintGroups(constraints, points, lines, arcs, fixed, draggedArcs) || iterChanged; + changed = changed || iterChanged; + if (!iterChanged) break; + } + + return changed; +} + +function applyPolygonPattern(constraint, constraints, points, lines, arcs, fixed, dragged = new Set()) { + let changed = false; + const data = constraint?.data || {}; + const mode = data?.mode === 'circumscribed' ? 'circumscribed' : 'inscribed'; + const sides = Math.max(3, Math.min(128, Number(data?.sides || 0) || 0)); + const pointIds = Array.isArray(data?.pointIds) ? data.pointIds.filter(Boolean) : []; + const lineIds = Array.isArray(data?.lineIds) ? data.lineIds.filter(Boolean) : []; + if (!sides || pointIds.length < sides || lineIds.length < sides) return false; + + const circleId = (typeof data?.circleId === 'string' && arcs.has(data.circleId)) + ? data.circleId + : (Array.isArray(constraint?.refs) ? constraint.refs.find(id => arcs.has(id)) : null); + if (!circleId) return false; + const circle = arcs.get(circleId); + const circ = getArcCircleData(circle, points); + if (!circ || !Number.isFinite(circ.radius) || circ.radius < EPS) return false; + + const step = (Math.PI * 2) / sides; + let circleRadius = circ.radius; + const draggedPatternPoints = pointIds.filter(id => dragged?.has?.(id)).map(id => points.get(id)).filter(Boolean); + if (draggedPatternPoints.length) { + const avgDraggedDist = draggedPatternPoints.reduce((sum, p) => { + return sum + Math.hypot((p.x || 0) - circ.cx, (p.y || 0) - circ.cy); + }, 0) / draggedPatternPoints.length; + if (Number.isFinite(avgDraggedDist) && avgDraggedDist > EPS) { + circleRadius = mode === 'circumscribed' + ? avgDraggedDist * Math.cos(Math.PI / sides) + : avgDraggedDist; + if (Number.isFinite(circleRadius) && circleRadius > EPS) { + changed = setArcCenterAndMeta(circle, circ.cx, circ.cy, circleRadius, 0, Math.PI * 2, true) || changed; + changed = setArcControl(circle, circ.cx, circ.cy + circleRadius) || changed; + const a = points.get(getLineEndpointId(circle, 'a')); + const b = points.get(getLineEndpointId(circle, 'b')); + if (a && !isFixed(getLineEndpointId(circle, 'a'), fixed)) { + changed = setPoint(a, circ.cx + circleRadius, circ.cy) || changed; + } + if (b && !isFixed(getLineEndpointId(circle, 'b'), fixed)) { + changed = setPoint(b, circ.cx + circleRadius, circ.cy) || changed; + } + } + } + } + const polyRadius = mode === 'circumscribed' + ? (circleRadius / Math.cos(Math.PI / sides)) + : circleRadius; + if (!Number.isFinite(polyRadius) || polyRadius < EPS) return false; + + let base = derivePatternBaseFromPoints(pointIds, points, circ.cx, circ.cy, step); + const oriented = derivePatternBaseFromLineOrientation(lineIds, constraints, step, base); + if (Number.isFinite(oriented)) { + base = oriented; + } + + for (let i = 0; i < sides; i++) { + const id = pointIds[i]; + const p = points.get(id); + if (!p || isFixed(id, fixed)) continue; + const ang = base + i * step; + const tx = circ.cx + Math.cos(ang) * polyRadius; + const ty = circ.cy + Math.sin(ang) * polyRadius; + changed = setPoint(p, tx, ty) || changed; + } + return changed; +} + +function applyPolygonPatternConstraints(constraints, points, lines, arcs, fixed, dragged = new Set()) { + let changed = false; + for (const c of constraints || []) { + if (c?.type !== 'polygon_pattern') continue; + changed = applyPolygonPattern(c, constraints, points, lines, arcs, fixed, dragged) || changed; + } + return changed; +} + +function resolvePatternPointLike(ref, points, arcs) { + if (!ref) return null; + if (ref === SKETCH_VIRTUAL_ORIGIN_ID) return { x: 0, y: 0 }; + if (typeof ref === 'string' && ref.startsWith('arc-center:')) { + const arc = arcs.get(ref.substring('arc-center:'.length)); + const data = getArcCircleData(arc, points); + return data ? { x: data.cx, y: data.cy } : null; + } + const point = points.get(ref); + if (!point) return null; + return { x: point.x || 0, y: point.y || 0 }; +} + +function rotatePatternPointAround(point, center, angle) { + const dx = (point.x || 0) - (center.x || 0); + const dy = (point.y || 0) - (center.y || 0); + const ca = Math.cos(angle); + const sa = Math.sin(angle); + return { + x: (center.x || 0) + dx * ca - dy * sa, + y: (center.y || 0) + dx * sa + dy * ca + }; +} + +function applyCircularPattern(constraint, points, lines, arcs, fixed, dragged = new Set(), draggedArcs = new Set()) { + const data = constraint?.data || {}; + const centerRef = typeof data?.centerRef === 'string' + ? data.centerRef + : (Array.isArray(constraint?.refs) ? constraint.refs[0] : null); + const sourceIds = Array.isArray(data?.sourceIds) ? data.sourceIds.filter(Boolean) : []; + const copies = Array.isArray(data?.copies) ? data.copies : []; + const pointMaps = Array.isArray(data?.pointMaps) ? data.pointMaps : []; + const count = Math.max(2, Math.min(256, Number(data?.count || 0) || 0)); + if (!centerRef || !sourceIds.length || count < 2) return false; + const center = resolvePatternPointLike(centerRef, points, arcs); + if (!center) return false; + + let changed = false; + // Back-propagate drag from copies -> source so dragging any copy behaves + // like dragging the source, then forward-propagate source -> all copies. + for (let step = 1; step < count; step++) { + const angle = (Math.PI * 2 * step) / count; + const pointPairs = Array.isArray(pointMaps[step - 1]) ? pointMaps[step - 1] : []; + for (const pair of pointPairs) { + if (!Array.isArray(pair) || pair.length < 2) continue; + const srcId = pair[0]; + const dstId = pair[1]; + if (!dragged?.has?.(dstId) || dragged?.has?.(srcId) || isFixed(srcId, fixed)) continue; + const dst = points.get(dstId); + const src = points.get(srcId); + if (!src || !dst) continue; + const inv = rotatePatternPointAround(dst, center, -angle); + changed = setPoint(src, inv.x, inv.y) || changed; + dragged?.add?.(srcId); + } + const stepCopies = Array.isArray(copies[step - 1]) ? copies[step - 1] : []; + for (let i = 0; i < sourceIds.length; i++) { + const sourceId = sourceIds[i]; + const copyId = stepCopies[i]; + if (!sourceId || !copyId) continue; + if (!draggedArcs?.has?.(copyId) || draggedArcs?.has?.(sourceId)) continue; + const srcArc = arcs.get(sourceId); + const dstArc = arcs.get(copyId); + if (!srcArc || !dstArc) continue; + if (Number.isFinite(dstArc.cx) && Number.isFinite(dstArc.cy)) { + const invC = rotatePatternPointAround({ x: dstArc.cx, y: dstArc.cy }, center, -angle); + const sa = Number(dstArc.startAngle); + const ea = Number(dstArc.endAngle); + changed = setArcCenterAndMeta( + srcArc, + invC.x, + invC.y, + Number.isFinite(dstArc.radius) ? dstArc.radius : Number(srcArc.radius || 0), + Number.isFinite(sa) ? sa - angle : Number(srcArc.startAngle || 0), + Number.isFinite(ea) ? ea - angle : Number(srcArc.endAngle || 0), + dstArc.ccw === undefined ? true : dstArc.ccw + ) || changed; + draggedArcs?.add?.(sourceId); + } + if (Number.isFinite(dstArc.mx) && Number.isFinite(dstArc.my)) { + const invM = rotatePatternPointAround({ x: dstArc.mx, y: dstArc.my }, center, -angle); + changed = setArcControl(srcArc, invM.x, invM.y) || changed; + } + } + } + + for (let step = 1; step < count; step++) { + const angle = (Math.PI * 2 * step) / count; + const pointPairs = Array.isArray(pointMaps[step - 1]) ? pointMaps[step - 1] : []; + const pointMap = new Map(); + for (const pair of pointPairs) { + if (!Array.isArray(pair) || pair.length < 2) continue; + pointMap.set(pair[0], pair[1]); + } + for (const [srcId, dstId] of pointMap.entries()) { + const src = points.get(srcId); + const dst = points.get(dstId); + if (!src || !dst || isFixed(dstId, fixed)) continue; + const rot = rotatePatternPointAround(src, center, angle); + changed = setPoint(dst, rot.x, rot.y) || changed; + } + const stepCopies = Array.isArray(copies[step - 1]) ? copies[step - 1] : []; + for (let i = 0; i < sourceIds.length; i++) { + const sourceId = sourceIds[i]; + const copyId = stepCopies[i]; + if (!sourceId || !copyId) continue; + const srcLine = lines.get(sourceId); + const dstLine = lines.get(copyId); + if (srcLine && dstLine) continue; + const srcArc = arcs.get(sourceId); + const dstArc = arcs.get(copyId); + if (srcArc && dstArc) { + if (draggedArcs?.has?.(copyId)) continue; + if (Number.isFinite(srcArc.cx) && Number.isFinite(srcArc.cy)) { + const c = rotatePatternPointAround({ x: srcArc.cx, y: srcArc.cy }, center, angle); + const sa = Number(srcArc.startAngle); + const ea = Number(srcArc.endAngle); + changed = setArcCenterAndMeta( + dstArc, + c.x, + c.y, + Number.isFinite(srcArc.radius) ? srcArc.radius : Number(dstArc.radius || 0), + Number.isFinite(sa) ? sa + angle : Number(dstArc.startAngle || 0), + Number.isFinite(ea) ? ea + angle : Number(dstArc.endAngle || 0), + srcArc.ccw === undefined ? true : srcArc.ccw + ) || changed; + } + if (Number.isFinite(srcArc.mx) && Number.isFinite(srcArc.my)) { + const m = rotatePatternPointAround({ x: srcArc.mx, y: srcArc.my }, center, angle); + changed = setArcControl(dstArc, m.x, m.y) || changed; + } + continue; + } + const srcPoint = points.get(sourceId); + const dstPoint = points.get(copyId); + if (srcPoint && dstPoint && !isFixed(copyId, fixed)) { + const rot = rotatePatternPointAround(srcPoint, center, angle); + changed = setPoint(dstPoint, rot.x, rot.y) || changed; + } + } + } + return changed; +} + +function applyCircularPatternConstraints(constraints, points, lines, arcs, fixed, dragged = new Set(), draggedArcs = new Set()) { + let changed = false; + for (const c of constraints || []) { + if (c?.type !== 'circular_pattern') continue; + changed = applyCircularPattern(c, points, lines, arcs, fixed, dragged, draggedArcs) || changed; + } + return changed; +} + +function applyGridPattern(constraint, points, lines, arcs, fixed, dragged = new Set(), draggedArcs = new Set()) { + const data = constraint?.data || {}; + const centerPointId = typeof data?.centerPointId === 'string' ? data.centerPointId : null; + const sourceIds = Array.isArray(data?.sourceIds) ? data.sourceIds.filter(Boolean) : []; + const uLineId = typeof data?.uLineId === 'string' ? data.uLineId : null; + const vLineId = typeof data?.vLineId === 'string' ? data.vLineId : null; + const pointMaps = Array.isArray(data?.pointMaps) ? data.pointMaps : []; + const copies = Array.isArray(data?.copies) ? data.copies : []; + const countH = Math.max(1, Math.min(256, Number(data?.countH || 0) || 0)); + const countV = Math.max(1, Math.min(256, Number(data?.countV || 0) || 0)); + if (!centerPointId || !uLineId || !vLineId || !sourceIds.length || countH < 1 || countV < 1) return false; + const center = points.get(centerPointId); + const uLine = lines.get(uLineId); + const vLine = lines.get(vLineId); + if (!center || !uLine || !vLine) return false; + const uOtherId = uLine.a === centerPointId ? uLine.b : uLine.a; + const vOtherId = vLine.a === centerPointId ? vLine.b : vLine.a; + const uOther = points.get(uOtherId); + const vOther = points.get(vOtherId); + if (!uOther || !vOther) return false; + const ux = (uOther.x || 0) - (center.x || 0); + const uy = (uOther.y || 0) - (center.y || 0); + const vx = (vOther.x || 0) - (center.x || 0); + const vy = (vOther.y || 0) - (center.y || 0); + let changed = false; + + // Back-propagate dragged copy points/arcs to source. + for (const rec of pointMaps) { + const i = Number(rec?.i || 0); + const j = Number(rec?.j || 0); + const ox = i * ux + j * vx; + const oy = i * uy + j * vy; + for (const pair of (rec?.pairs || [])) { + if (!Array.isArray(pair) || pair.length < 2) continue; + const srcId = pair[0]; + const dstId = pair[1]; + if (!dragged?.has?.(dstId) || dragged?.has?.(srcId) || isFixed(srcId, fixed)) continue; + const src = points.get(srcId); + const dst = points.get(dstId); + if (!src || !dst) continue; + changed = setPoint(src, (dst.x || 0) - ox, (dst.y || 0) - oy) || changed; + dragged?.add?.(srcId); + } + } + for (const rec of copies) { + const i = Number(rec?.i || 0); + const j = Number(rec?.j || 0); + const ox = i * ux + j * vx; + const oy = i * uy + j * vy; + const ids = Array.isArray(rec?.ids) ? rec.ids : []; + for (let k = 0; k < sourceIds.length; k++) { + const srcId = sourceIds[k]; + const dstId = ids[k]; + if (!srcId || !dstId) continue; + if (!draggedArcs?.has?.(dstId) || draggedArcs?.has?.(srcId)) continue; + const srcArc = arcs.get(srcId); + const dstArc = arcs.get(dstId); + if (!srcArc || !dstArc) continue; + if (Number.isFinite(dstArc.cx) && Number.isFinite(dstArc.cy)) { + changed = setArcCenterAndMeta( + srcArc, + (dstArc.cx || 0) - ox, + (dstArc.cy || 0) - oy, + Number.isFinite(dstArc.radius) ? dstArc.radius : Number(srcArc.radius || 0), + Number(dstArc.startAngle || 0), + Number(dstArc.endAngle || 0), + dstArc.ccw === undefined ? true : dstArc.ccw + ) || changed; + draggedArcs?.add?.(srcId); + } + if (Number.isFinite(dstArc.mx) && Number.isFinite(dstArc.my)) { + changed = setArcControl(srcArc, (dstArc.mx || 0) - ox, (dstArc.my || 0) - oy) || changed; + } + } + } + + // Forward-propagate source entities -> copies. + for (const rec of pointMaps) { + const i = Number(rec?.i || 0); + const j = Number(rec?.j || 0); + const ox = i * ux + j * vx; + const oy = i * uy + j * vy; + for (const pair of (rec?.pairs || [])) { + if (!Array.isArray(pair) || pair.length < 2) continue; + const src = points.get(pair[0]); + const dst = points.get(pair[1]); + if (!src || !dst || isFixed(pair[1], fixed)) continue; + changed = setPoint(dst, (src.x || 0) + ox, (src.y || 0) + oy) || changed; + } + } + for (const rec of copies) { + const i = Number(rec?.i || 0); + const j = Number(rec?.j || 0); + const ox = i * ux + j * vx; + const oy = i * uy + j * vy; + const ids = Array.isArray(rec?.ids) ? rec.ids : []; + for (let k = 0; k < sourceIds.length; k++) { + const srcArc = arcs.get(sourceIds[k]); + const dstArc = arcs.get(ids[k]); + if (!srcArc || !dstArc) continue; + if (Number.isFinite(srcArc.cx) && Number.isFinite(srcArc.cy)) { + changed = setArcCenterAndMeta( + dstArc, + (srcArc.cx || 0) + ox, + (srcArc.cy || 0) + oy, + Number.isFinite(srcArc.radius) ? srcArc.radius : Number(dstArc.radius || 0), + Number(srcArc.startAngle || 0), + Number(srcArc.endAngle || 0), + srcArc.ccw === undefined ? true : srcArc.ccw + ) || changed; + } + if (Number.isFinite(srcArc.mx) && Number.isFinite(srcArc.my)) { + changed = setArcControl(dstArc, (srcArc.mx || 0) + ox, (srcArc.my || 0) + oy) || changed; + } + } + } + return changed; +} + +function applyGridPatternConstraints(constraints, points, lines, arcs, fixed, dragged = new Set(), draggedArcs = new Set()) { + let changed = false; + for (const c of constraints || []) { + if (c?.type !== 'grid_pattern') continue; + changed = applyGridPattern(c, points, lines, arcs, fixed, dragged, draggedArcs) || changed; + } + return changed; +} + +function derivePatternBaseFromPoints(pointIds, points, cx, cy, step) { + let sx = 0; + let sy = 0; + let count = 0; + for (let i = 0; i < pointIds.length; i++) { + const p = points.get(pointIds[i]); + if (!p) continue; + const ang = Math.atan2((p.y || 0) - cy, (p.x || 0) - cx); + const phase = ang - i * step; + sx += Math.cos(phase); + sy += Math.sin(phase); + count++; + } + if (!count) return 0; + return Math.atan2(sy, sx); +} + +function derivePatternBaseFromLineOrientation(lineIds, constraints, step, preferredBase = 0) { + const orientationByLine = new Map(); + for (const c of constraints || []) { + if (!c?.type) continue; + if (c.type !== 'horizontal' && c.type !== 'vertical') continue; + const lid = Array.isArray(c.refs) ? c.refs[0] : null; + if (!lid) continue; + orientationByLine.set(lid, c.type); + } + for (let i = 0; i < lineIds.length; i++) { + const type = orientationByLine.get(lineIds[i]); + if (!type) continue; + const target = type === 'vertical' ? (Math.PI * 0.5) : 0; + // Regular polygon edge i direction = base + i*step + step/2 + pi/2. + const base0 = target - (i * step) - (step * 0.5) - (Math.PI * 0.5); + // Horizontal/vertical do not constrain edge direction sign; avoid + // branch flips by choosing the orientation nearest to current pose. + const base1 = base0 + Math.PI; + return nearestAngle(preferredBase, base0, base1); + } + return NaN; +} + +function normAngle(a) { + let out = a % (Math.PI * 2); + if (out < 0) out += Math.PI * 2; + return out; +} + +function angleDist(a, b) { + const aa = normAngle(a); + const bb = normAngle(b); + let d = Math.abs(aa - bb); + if (d > Math.PI) d = (Math.PI * 2) - d; + return d; +} + +function nearestAngle(ref, a, b) { + return angleDist(ref, a) <= angleDist(ref, b) ? a : b; +} + +function applyThreePointCircleDefinitions(arcs, points, fixed) { + let changed = false; + if (!(arcs instanceof Map) || !(points instanceof Map)) return false; + for (const arc of arcs.values()) { + if (!arc) continue; + const ids = Array.isArray(arc?.data?.threePointIds) ? arc.data.threePointIds.filter(Boolean) : []; + if (!isThreePointCircle(arc) && ids.length < 3) continue; + if (ids.length < 3) continue; + const p1 = points.get(ids[0]); + const p2 = points.get(ids[1]); + const p3 = points.get(ids[2]); + if (!p1 || !p2 || !p3) continue; + const circle = computeArcGeometry( + { x: p1.x || 0, y: p1.y || 0 }, + { x: p2.x || 0, y: p2.y || 0 }, + { x: p3.x || 0, y: p3.y || 0 } + ); + if (!circle) continue; + const radius = Math.hypot((p1.x || 0) - circle.cx, (p1.y || 0) - circle.cy); + if (!Number.isFinite(radius) || radius < EPS) continue; + const a = points.get(getLineEndpointId(arc, 'a')); + const b = points.get(getLineEndpointId(arc, 'b')); + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + if (a && !isFixed(aId, fixed)) { + changed = setPoint(a, p1.x || 0, p1.y || 0) || changed; + } + if (b && !isFixed(bId, fixed)) { + changed = setPoint(b, p1.x || 0, p1.y || 0) || changed; + } + changed = setArcCenterAndMeta(arc, circle.cx, circle.cy, radius, 0, Math.PI * 2, true) || changed; + changed = setArcControl(arc, circle.cx, circle.cy + radius) || changed; + changed = markCircleThreePoint(arc) || changed; + } + return changed; +} + +function captureFixedAnchors(constraints, points) { + const fixed = new Map(); + for (const c of constraints) { + if (c?.type !== 'fixed') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + const anchors = c.data?.anchors || {}; + for (const id of refs) { + const p = points.get(id); + if (!p) continue; + const a = anchors[id]; + if (a && Number.isFinite(a.x) && Number.isFinite(a.y)) fixed.set(id, { x: a.x, y: a.y }); + else fixed.set(id, { x: p.x || 0, y: p.y || 0 }); + } + } + return fixed; +} + +function isFixed(id, fixed) { + return !!(id && fixed.has(id)); +} + +function setPoint(point, x, y) { + const nx = Number.isFinite(x) ? x : (point.x || 0); + const ny = Number.isFinite(y) ? y : (point.y || 0); + const dx = nx - (point.x || 0); + const dy = ny - (point.y || 0); + if (Math.abs(dx) < EPS && Math.abs(dy) < EPS) return false; + point.x = nx; + point.y = ny; + return true; +} + +function getLineEndpoints(line, points) { + const a = points.get(getLineEndpointId(line, 'a')) || null; + const b = points.get(getLineEndpointId(line, 'b')) || null; + return [a, b]; +} + +function getLineEndpointId(line, which) { + if (!line || (which !== 'a' && which !== 'b')) return null; + const legacy = which === 'a' ? line.a : line.b; + const alt = which === 'a' ? line.p1_id : line.p2_id; + if (typeof legacy === 'string') return legacy; + if (typeof alt === 'string') return alt; + return null; +} + +function applyFixed(constraint, points, fixed) { + let changed = false; + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + for (const id of refs) { + const p = points.get(id); + const a = fixed.get(id); + if (!p || !a) continue; + changed = setPoint(p, a.x, a.y) || changed; + } + return changed; +} + +function applyCoincident(constraint, points, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const pa = points.get(refs[0]); + const pb = points.get(refs[1]); + if (!pa || !pb) return false; + const fa = isFixed(refs[0], fixed); + const fb = isFixed(refs[1], fixed); + if (fa && fb) return false; + if (fa) return setPoint(pb, pa.x || 0, pa.y || 0); + if (fb) return setPoint(pa, pb.x || 0, pb.y || 0); + const mx = ((pa.x || 0) + (pb.x || 0)) * 0.5; + const my = ((pa.y || 0) + (pb.y || 0)) * 0.5; + const ca = setPoint(pa, mx, my); + const cb = setPoint(pb, mx, my); + return ca || cb; +} + +function applyHorizontal(constraint, points, lines, fixed) { + const lineId = Array.isArray(constraint?.refs) ? constraint.refs[0] : null; + const line = lines.get(lineId); + if (!line) return false; + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) return false; + const fa = isFixed(getLineEndpointId(line, 'a'), fixed); + const fb = isFixed(getLineEndpointId(line, 'b'), fixed); + if (fa && fb) return false; + const y = fa ? (a.y || 0) : (fb ? (b.y || 0) : (((a.y || 0) + (b.y || 0)) * 0.5)); + if (fa) return setPoint(b, b.x || 0, y); + if (fb) return setPoint(a, a.x || 0, y); + const ca = setPoint(a, a.x || 0, y); + const cb = setPoint(b, b.x || 0, y); + return ca || cb; +} + +function getPointLikeRef(ref, points, arcs) { + if (!ref) return null; + if (ref === SKETCH_VIRTUAL_ORIGIN_ID) { + return { x: 0, y: 0, virtual: true, ref }; + } + const p = points.get(ref); + if (p) { + return { x: p.x || 0, y: p.y || 0, point: p, ref }; + } + if (typeof ref === 'string' && ref.startsWith('arc-center:')) { + const arcId = ref.substring('arc-center:'.length); + const arc = arcs.get(arcId); + if (!arc) return null; + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy, arc, arcId, ref }; + } + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return null; + const center = getArcCenter(arc, a, b); + if (!center) return null; + return { x: center.x, y: center.y, arc, arcId, ref }; + } + return null; +} + +function setPointLikeRef(pointLike, x, y, points, fixed) { + if (!pointLike) return false; + if (pointLike.virtual) return false; + if (pointLike.point) { + if (isFixed(pointLike.ref, fixed)) return false; + return setPoint(pointLike.point, x, y); + } + if (pointLike.arc) { + const arc = pointLike.arc; + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(arc, a, b, x, y, fa, fb); + } + return false; +} + +function applyHorizontalPoints(constraint, points, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const a = getPointLikeRef(refs[0], points, arcs); + const b = getPointLikeRef(refs[1], points, arcs); + if (!a || !b) return false; + const aFixed = a.virtual || (a.point && isFixed(refs[0], fixed)); + const bFixed = b.virtual || (b.point && isFixed(refs[1], fixed)); + if (aFixed && bFixed) return false; + const y = aFixed ? (a.y || 0) : (bFixed ? (b.y || 0) : (((a.y || 0) + (b.y || 0)) * 0.5)); + if (aFixed) return setPointLikeRef(b, b.x || 0, y, points, fixed); + if (bFixed) return setPointLikeRef(a, a.x || 0, y, points, fixed); + return setPointLikeRef(a, a.x || 0, y, points, fixed) || setPointLikeRef(b, b.x || 0, y, points, fixed); +} + +function applyVertical(constraint, points, lines, fixed) { + const lineId = Array.isArray(constraint?.refs) ? constraint.refs[0] : null; + const line = lines.get(lineId); + if (!line) return false; + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) return false; + const fa = isFixed(getLineEndpointId(line, 'a'), fixed); + const fb = isFixed(getLineEndpointId(line, 'b'), fixed); + if (fa && fb) return false; + const x = fa ? (a.x || 0) : (fb ? (b.x || 0) : (((a.x || 0) + (b.x || 0)) * 0.5)); + if (fa) return setPoint(b, x, b.y || 0); + if (fb) return setPoint(a, x, a.y || 0); + const ca = setPoint(a, x, a.y || 0); + const cb = setPoint(b, x, b.y || 0); + return ca || cb; +} + +function applyVerticalPoints(constraint, points, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const a = getPointLikeRef(refs[0], points, arcs); + const b = getPointLikeRef(refs[1], points, arcs); + if (!a || !b) return false; + const aFixed = a.virtual || (a.point && isFixed(refs[0], fixed)); + const bFixed = b.virtual || (b.point && isFixed(refs[1], fixed)); + if (aFixed && bFixed) return false; + const x = aFixed ? (a.x || 0) : (bFixed ? (b.x || 0) : (((a.x || 0) + (b.x || 0)) * 0.5)); + if (aFixed) return setPointLikeRef(b, x, b.y || 0, points, fixed); + if (bFixed) return setPointLikeRef(a, x, a.y || 0, points, fixed); + return setPointLikeRef(a, x, a.y || 0, points, fixed) || setPointLikeRef(b, x, b.y || 0, points, fixed); +} + +function applyPerpendicular(constraint, points, lines, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const l1 = lines.get(refs[0]); + const l2 = lines.get(refs[1]); + if (!l1 || !l2) return false; + const [a, b] = getLineEndpoints(l1, points); + const [c, d] = getLineEndpoints(l2, points); + if (!a || !b || !c || !d) return false; + + const ux = (b.x || 0) - (a.x || 0); + const uy = (b.y || 0) - (a.y || 0); + const ulen = Math.hypot(ux, uy); + if (ulen < EPS) return false; + const nx = -uy / ulen; + const ny = ux / ulen; + + const vx = (d.x || 0) - (c.x || 0); + const vy = (d.y || 0) - (c.y || 0); + const vlen = Math.max(EPS, Math.hypot(vx, vy)); + const dot = vx * nx + vy * ny; + const sx = dot >= 0 ? nx : -nx; + const sy = dot >= 0 ? ny : -ny; + + const fc = isFixed(getLineEndpointId(l2, 'a'), fixed); + const fd = isFixed(getLineEndpointId(l2, 'b'), fixed); + if (fc && fd) return false; + if (fc) return setPoint(d, (c.x || 0) + sx * vlen, (c.y || 0) + sy * vlen); + if (fd) return setPoint(c, (d.x || 0) - sx * vlen, (d.y || 0) - sy * vlen); + + const mx = ((c.x || 0) + (d.x || 0)) * 0.5; + const my = ((c.y || 0) + (d.y || 0)) * 0.5; + const hx = sx * vlen * 0.5; + const hy = sy * vlen * 0.5; + const cc = setPoint(c, mx - hx, my - hy); + const cd = setPoint(d, mx + hx, my + hy); + return cc || cd; +} + +function applyEqual(constraint, points, lines, arcs, fixed, constraints = []) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const l1 = lines.get(refs[0]); + const l2 = lines.get(refs[1]); + const a1 = arcs.get(refs[0]); + const a2 = arcs.get(refs[1]); + if (a1 && a2) { + const c1 = getArcCircleData(a1, points); + const c2 = getArcCircleData(a2, points); + if (!c1 || !c2) return false; + const r1Driving = getDrivingArcRadiusFromConstraints(constraints, refs[0]); + const r2Driving = getDrivingArcRadiusFromConstraints(constraints, refs[1]); + if (Number.isFinite(r1Driving) && Number.isFinite(r2Driving)) { + if (Math.abs(r1Driving - r2Driving) <= EPS) return false; + return false; + } + if (Number.isFinite(r1Driving) && !Number.isFinite(r2Driving)) { + return applyArcRadiusTarget(a2, points, r1Driving, fixed); + } + if (Number.isFinite(r2Driving) && !Number.isFinite(r1Driving)) { + return applyArcRadiusTarget(a1, points, r2Driving, fixed); + } + const target = (c1.radius + c2.radius) * 0.5; + const p2a = getLineEndpointId(a2, 'a'); + const p2b = getLineEndpointId(a2, 'b'); + const fa = isFixed(p2a, fixed); + const fb = isFixed(p2b, fixed); + if (fa && fb) { + const p1a = getLineEndpointId(a1, 'a'); + const p1b = getLineEndpointId(a1, 'b'); + const f1a = isFixed(p1a, fixed); + const f1b = isFixed(p1b, fixed); + if (f1a && f1b) return false; + return applyArcRadiusTarget(a1, points, target, fixed); + } + return applyArcRadiusTarget(a2, points, target, fixed); + } + if (!l1 || !l2) return false; + const [a, b] = getLineEndpoints(l1, points); + const [c, d] = getLineEndpoints(l2, points); + if (!a || !b || !c || !d) return false; + + const len1 = Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + const len2 = Math.hypot((d.x || 0) - (c.x || 0), (d.y || 0) - (c.y || 0)); + if (len1 < EPS || len2 < EPS) return false; + const target = (len1 + len2) * 0.5; + + const cId = getLineEndpointId(l2, 'a'); + const dId = getLineEndpointId(l2, 'b'); + const fc = isFixed(cId, fixed); + const fd = isFixed(dId, fixed); + if (fc && fd) { + const aId = getLineEndpointId(l1, 'a'); + const bId = getLineEndpointId(l1, 'b'); + const fa = isFixed(aId, fixed); + const fb = isFixed(bId, fixed); + if (fa && fb) return false; + const ux1 = ((b.x || 0) - (a.x || 0)) / len1; + const uy1 = ((b.y || 0) - (a.y || 0)) / len1; + if (fa) return setPoint(b, (a.x || 0) + ux1 * len2, (a.y || 0) + uy1 * len2); + if (fb) return setPoint(a, (b.x || 0) - ux1 * len2, (b.y || 0) - uy1 * len2); + const mx = ((a.x || 0) + (b.x || 0)) * 0.5; + const my = ((a.y || 0) + (b.y || 0)) * 0.5; + const hx = ux1 * len2 * 0.5; + const hy = uy1 * len2 * 0.5; + let changed = false; + changed = setPoint(a, mx - hx, my - hy) || changed; + changed = setPoint(b, mx + hx, my + hy) || changed; + return changed; + } + + const ux = ((d.x || 0) - (c.x || 0)) / len2; + const uy = ((d.y || 0) - (c.y || 0)) / len2; + if (fc) return setPoint(d, (c.x || 0) + ux * target, (c.y || 0) + uy * target); + if (fd) return setPoint(c, (d.x || 0) - ux * target, (d.y || 0) - uy * target); + const mx = ((c.x || 0) + (d.x || 0)) * 0.5; + const my = ((c.y || 0) + (d.y || 0)) * 0.5; + const hx = ux * target * 0.5; + const hy = uy * target * 0.5; + let changed = false; + changed = setPoint(c, mx - hx, my - hy) || changed; + changed = setPoint(d, mx + hx, my + hy) || changed; + return changed; +} + +function getDrivingArcRadiusFromConstraints(constraints, arcId) { + if (!arcId) return NaN; + for (const c of constraints || []) { + if (c?.type !== 'dimension') continue; + if (c?.data?.mode === 'driven') continue; + const refs = Array.isArray(c?.refs) ? c.refs : []; + if (refs.length !== 1 || refs[0] !== arcId) continue; + const v = Number(c?.data?.value); + if (Number.isFinite(v) && v > EPS) return v * 0.5; + } + return NaN; +} + +function applyEqualConstraintGroups(constraints, points, lines, arcs, fixed, draggedArcs = new Set()) { + const equalLinePairs = []; + const equalArcPairs = []; + for (const c of constraints) { + if (c?.type !== 'equal') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + if (refs.length < 2) continue; + if (lines.has(refs[0]) && lines.has(refs[1])) { + equalLinePairs.push([refs[0], refs[1]]); + continue; + } + if (arcs.has(refs[0]) && arcs.has(refs[1])) { + equalArcPairs.push([refs[0], refs[1]]); + continue; + } + } + if (!equalLinePairs.length && !equalArcPairs.length) return false; + + const parent = new Map(); + const find = id => { + if (!parent.has(id)) parent.set(id, id); + let p = parent.get(id); + while (p !== parent.get(p)) p = parent.get(p); + let n = id; + while (parent.get(n) !== p) { + const next = parent.get(n); + parent.set(n, p); + n = next; + } + return p; + }; + const union = (a, b) => { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(rb, ra); + }; + let changed = false; + for (const [a, b] of equalLinePairs) union(a, b); + const lineGroups = new Map(); + for (const [a, b] of equalLinePairs) { + const ids = [a, b]; + for (const id of ids) { + const r = find(id); + if (!lineGroups.has(r)) lineGroups.set(r, new Set()); + lineGroups.get(r).add(id); + } + } + for (const ids of lineGroups.values()) { + const linesInGroup = Array.from(ids).map(id => lines.get(id)).filter(Boolean); + if (linesInGroup.length < 2) continue; + let sum = 0; + let count = 0; + for (const line of linesInGroup) { + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) continue; + const len = Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + if (len > EPS) { + sum += len; + count++; + } + } + if (!count) continue; + const target = sum / count; + for (const line of linesInGroup) { + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) continue; + const aId = getLineEndpointId(line, 'a'); + const bId = getLineEndpointId(line, 'b'); + const fa = isFixed(aId, fixed); + const fb = isFixed(bId, fixed); + if (fa && fb) continue; + const vx = (b.x || 0) - (a.x || 0); + const vy = (b.y || 0) - (a.y || 0); + const len = Math.hypot(vx, vy); + if (len < EPS) continue; + const ux = vx / len; + const uy = vy / len; + if (fa) { + changed = setPoint(b, (a.x || 0) + ux * target, (a.y || 0) + uy * target) || changed; + } else if (fb) { + changed = setPoint(a, (b.x || 0) - ux * target, (b.y || 0) - uy * target) || changed; + } else { + const mx = ((a.x || 0) + (b.x || 0)) * 0.5; + const my = ((a.y || 0) + (b.y || 0)) * 0.5; + const hx = ux * target * 0.5; + const hy = uy * target * 0.5; + changed = setPoint(a, mx - hx, my - hy) || changed; + changed = setPoint(b, mx + hx, my + hy) || changed; + } + } + } + + const aparent = new Map(); + const afind = id => { + if (!aparent.has(id)) aparent.set(id, id); + let p = aparent.get(id); + while (p !== aparent.get(p)) p = aparent.get(p); + let n = id; + while (aparent.get(n) !== p) { + const next = aparent.get(n); + aparent.set(n, p); + n = next; + } + return p; + }; + const aunion = (a, b) => { + const ra = afind(a); + const rb = afind(b); + if (ra !== rb) aparent.set(rb, ra); + }; + for (const [a, b] of equalArcPairs) aunion(a, b); + const arcGroups = new Map(); + for (const [a, b] of equalArcPairs) { + const ids = [a, b]; + for (const id of ids) { + const r = afind(id); + if (!arcGroups.has(r)) arcGroups.set(r, new Set()); + arcGroups.get(r).add(id); + } + } + for (const ids of arcGroups.values()) { + const arcIds = Array.from(ids).filter(id => arcs.has(id)); + if (arcIds.length < 2) continue; + let target = NaN; + for (const aid of arcIds) { + const dv = getDrivingArcRadiusFromConstraints(constraints, aid); + if (Number.isFinite(dv) && dv > EPS) { + target = dv; + break; + } + } + if (!Number.isFinite(target)) { + const dragged = arcIds.filter(id => draggedArcs?.has?.(id)); + if (dragged.length) { + let sum = 0; + let count = 0; + for (const aid of dragged) { + const c = getArcCircleData(arcs.get(aid), points); + if (!c) continue; + sum += c.radius; + count++; + } + if (count) target = sum / count; + } + } + if (!Number.isFinite(target)) { + let sum = 0; + let count = 0; + for (const aid of arcIds) { + const c = getArcCircleData(arcs.get(aid), points); + if (!c) continue; + sum += c.radius; + count++; + } + if (!count) continue; + target = sum / count; + } + for (const aid of arcIds) { + const arc = arcs.get(aid); + if (!arc) continue; + changed = applyArcRadiusTarget(arc, points, target, fixed) || changed; + } + } + + return changed; +} + +function applyCollinear(constraint, points, lines, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const l1 = lines.get(refs[0]); + const l2 = lines.get(refs[1]); + if (!l1 || !l2) return false; + const changedParallel = applyParallelLike(l1, l2, points, fixed); + const changedPointOn = projectPointToLine(getLineEndpointId(l2, 'a'), l1, points, fixed); + return changedParallel || changedPointOn; +} + +function applyDimension(constraint, points, lines, arcs, fixed) { + if (constraint?.data?.mode === 'driven') return false; + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + const target = Number(constraint?.data?.value); + if (!Number.isFinite(target) || target <= EPS) return false; + + let p1Id = null; + let p2Id = null; + if (refs.length === 1 && lines.has(refs[0])) { + const line = lines.get(refs[0]); + p1Id = getLineEndpointId(line, 'a'); + p2Id = getLineEndpointId(line, 'b'); + } else if (refs.length === 1 && arcs.has(refs[0])) { + const arc = arcs.get(refs[0]); + return applyArcRadiusTarget(arc, points, target * 0.5, fixed); + } else if (refs.length >= 2 && points.has(refs[0]) && points.has(refs[1])) { + p1Id = refs[0]; + p2Id = refs[1]; + } else if (refs.length >= 2) { + const aRef = getPointLikeRef(refs[0], points, arcs); + const bRef = getPointLikeRef(refs[1], points, arcs); + if (!aRef || !bRef) return false; + const aFixed = aRef.virtual || (aRef.point && isFixed(refs[0], fixed)); + const bFixed = bRef.virtual || (bRef.point && isFixed(refs[1], fixed)); + if (aFixed && bFixed) return false; + let vx = (bRef.x || 0) - (aRef.x || 0); + let vy = (bRef.y || 0) - (aRef.y || 0); + let len = Math.hypot(vx, vy); + if (len < EPS) { + vx = 1; vy = 0; len = 1; + } + const ux = vx / len; + const uy = vy / len; + if (aFixed) { + return setPointLikeRef(bRef, (aRef.x || 0) + ux * target, (aRef.y || 0) + uy * target, points, fixed); + } + if (bFixed) { + return setPointLikeRef(aRef, (bRef.x || 0) - ux * target, (bRef.y || 0) - uy * target, points, fixed); + } + const mx = ((aRef.x || 0) + (bRef.x || 0)) * 0.5; + const my = ((aRef.y || 0) + (bRef.y || 0)) * 0.5; + const hx = ux * target * 0.5; + const hy = uy * target * 0.5; + let changed = false; + changed = setPointLikeRef(aRef, mx - hx, my - hy, points, fixed) || changed; + changed = setPointLikeRef(bRef, mx + hx, my + hy, points, fixed) || changed; + return changed; + } + if (!p1Id || !p2Id) return false; + const a = points.get(p1Id); + const b = points.get(p2Id); + if (!a || !b) return false; + const fa = isFixed(p1Id, fixed); + const fb = isFixed(p2Id, fixed); + if (fa && fb) return false; + + let vx = (b.x || 0) - (a.x || 0); + let vy = (b.y || 0) - (a.y || 0); + let len = Math.hypot(vx, vy); + if (len < EPS) { + vx = 1; + vy = 0; + len = 1; + } + const ux = vx / len; + const uy = vy / len; + if (fa) { + return setPoint(b, (a.x || 0) + ux * target, (a.y || 0) + uy * target); + } + if (fb) { + return setPoint(a, (b.x || 0) - ux * target, (b.y || 0) - uy * target); + } + const mx = ((a.x || 0) + (b.x || 0)) * 0.5; + const my = ((a.y || 0) + (b.y || 0)) * 0.5; + const hx = ux * target * 0.5; + const hy = uy * target * 0.5; + let changed = false; + changed = setPoint(a, mx - hx, my - hy) || changed; + changed = setPoint(b, mx + hx, my + hy) || changed; + return changed; +} + +function applyDistanceToConstraint(constraint, constraints, points, lines, arcs, fixed, mode = 'min') { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + const target = Number(constraint?.data?.value); + if (refs.length < 2 || !Number.isFinite(target) || target <= EPS) return false; + const arcId = refs.find(ref => arcs.has(ref)) || null; + if (!arcId) return false; + const arc = arcs.get(arcId); + if (!arc) return false; + const circle = getArcCircleData(arc, points); + if (!circle) return false; + const currentRadius = Number(circle.radius); + if (!Number.isFinite(currentRadius) || currentRadius <= EPS) return false; + + const targetRef = refs.find(ref => ref !== arcId) || null; + if (!targetRef) return false; + + let centerDistance = null; + let targetRadius = null; + let targetPoint = null; + let lineAnchor = null; + let lineDir = null; + let otherCenter = null; + const pointLike = getPointLikeRef(targetRef, points, arcs); + const isArcTarget = arcs.has(targetRef); + const isLineTarget = lines.has(targetRef); + if (pointLike) { + targetPoint = { x: pointLike.x || 0, y: pointLike.y || 0 }; + centerDistance = Math.hypot((pointLike.x || 0) - circle.cx, (pointLike.y || 0) - circle.cy); + } else if (isLineTarget) { + const line = lines.get(targetRef); + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) return false; + const nearest = nearestPointOnInfiniteLine(circle.cx, circle.cy, a.x || 0, a.y || 0, b.x || 0, b.y || 0); + lineAnchor = { x: nearest.x, y: nearest.y }; + lineDir = { x: (b.x || 0) - (a.x || 0), y: (b.y || 0) - (a.y || 0) }; + centerDistance = Math.hypot((nearest.x || 0) - circle.cx, (nearest.y || 0) - circle.cy); + } else if (isArcTarget) { + const other = arcs.get(targetRef); + const otherCircle = getArcCircleData(other, points); + if (!otherCircle) return false; + otherCenter = { x: otherCircle.cx || 0, y: otherCircle.cy || 0 }; + centerDistance = Math.hypot((otherCircle.cx || 0) - circle.cx, (otherCircle.cy || 0) - circle.cy); + targetRadius = Number(otherCircle.radius); + if (!Number.isFinite(targetRadius) || targetRadius <= EPS) return false; + } else { + return false; + } + if (!Number.isFinite(centerDistance)) return false; + const r2 = Number.isFinite(targetRadius) ? targetRadius : 0; + const [pa, pb] = getLineEndpoints(arc, points); + if (!pa || !pb) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = isFixed(aId, fixed); + const fb = isFixed(bId, fixed); + const radiusDriven = Number.isFinite(getDrivingArcRadiusFromConstraints(constraints, arcId)); + + // min distance = nearest boundary distance + // max distance = farthest boundary distance + const currentMin = Math.max(0, Math.max(centerDistance - (currentRadius + r2), Math.abs(currentRadius - r2) - centerDistance)); + const currentMax = centerDistance + currentRadius + r2; + if (mode === 'min' && Math.abs(currentMin - target) <= 1e-6) return false; + if (mode === 'max' && Math.abs(currentMax - target) <= 1e-6) return false; + + const candidates = []; + const centerTargets = []; + if (mode === 'max') { + const d = target - currentRadius - r2; + if (Number.isFinite(d) && d > EPS) centerTargets.push(d); + } else if (mode === 'min') { + const ext = currentRadius + r2 + target; + if (Number.isFinite(ext) && ext > EPS) centerTargets.push(ext); + // For point/line targets, using only external branch avoids drag-time + // branch flipping and keeps resizing smooth against fixed references. + if (isArcTarget) { + const containsOther = currentRadius - r2 - target; + if (Number.isFinite(containsOther) && containsOther > EPS) centerTargets.push(containsOther); + const insideOther = r2 - currentRadius - target; + if (Number.isFinite(insideOther) && insideOther > EPS) centerTargets.push(insideOther); + } + } else { + return false; + } + if (centerTargets.length && !(fa && fb)) { + let dTarget = centerTargets[0]; + let dDelta = Math.abs(dTarget - centerDistance); + for (let i = 1; i < centerTargets.length; i++) { + const d = centerTargets[i]; + const delta = Math.abs(d - centerDistance); + if (delta < dDelta) { + dTarget = d; + dDelta = delta; + } + } + let moved = false; + if (targetPoint) { + let vx = circle.cx - targetPoint.x; + let vy = circle.cy - targetPoint.y; + let vl = Math.hypot(vx, vy); + if (vl < EPS) { vx = 1; vy = 0; vl = 1; } + const nx = vx / vl; + const ny = vy / vl; + moved = enforceArcFromCenter(arc, pa, pb, targetPoint.x + nx * dTarget, targetPoint.y + ny * dTarget, fa, fb); + } else if (lineAnchor && lineDir) { + const lx = lineDir.x || 0; + const ly = lineDir.y || 0; + const ll = Math.hypot(lx, ly); + if (ll < EPS) return false; + const nx = -ly / ll; + const ny = lx / ll; + const sx = circle.cx - lineAnchor.x; + const sy = circle.cy - lineAnchor.y; + const signed = sx * nx + sy * ny; + constraint.data = constraint.data || {}; + let side = Number(constraint.data.line_side_sign); + if (!(side === 1 || side === -1)) { + side = signed < 0 ? -1 : 1; + constraint.data.line_side_sign = side; + } + moved = enforceArcFromCenter(arc, pa, pb, lineAnchor.x + nx * side * dTarget, lineAnchor.y + ny * side * dTarget, fa, fb); + } else if (otherCenter) { + let vx = circle.cx - otherCenter.x; + let vy = circle.cy - otherCenter.y; + let vl = Math.hypot(vx, vy); + if (vl < EPS) { vx = 1; vy = 0; vl = 1; } + const nx = vx / vl; + const ny = vy / vl; + moved = enforceArcFromCenter(arc, pa, pb, otherCenter.x + nx * dTarget, otherCenter.y + ny * dTarget, fa, fb); + } + if (moved) return true; + } + + if (mode === 'max') { + const radius = target - centerDistance - r2; + if (Number.isFinite(radius) && radius > EPS) candidates.push(radius); + } else if (mode === 'min') { + const external = centerDistance - r2 - target; + if (Number.isFinite(external) && external > EPS && centerDistance >= external + r2 - EPS) candidates.push(external); + if (isArcTarget) { + const containsOther = target + centerDistance + r2; + if (Number.isFinite(containsOther) && containsOther > EPS && containsOther >= centerDistance + r2 - EPS) candidates.push(containsOther); + const insideOther = r2 - centerDistance - target; + if (Number.isFinite(insideOther) && insideOther > EPS && r2 >= centerDistance + insideOther - EPS) candidates.push(insideOther); + } + } + if (!candidates.length) return false; + + let best = candidates[0]; + let bestDelta = Math.abs(best - currentRadius); + for (let i = 1; i < candidates.length; i++) { + const c = candidates[i]; + const d = Math.abs(c - currentRadius); + if (d < bestDelta) { + best = c; + bestDelta = d; + } + } + if (radiusDriven) return false; + return applyArcRadiusTarget(arc, points, best, fixed); +} + +function applyArcRadiusTarget(arc, points, target, fixed) { + if (!arc || !Number.isFinite(target) || target <= EPS) return false; + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const center = getArcCenter(arc, a, b) || (Number.isFinite(arc?.cx) && Number.isFinite(arc?.cy) ? { x: arc.cx, y: arc.cy } : null); + if (!center) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = isFixed(aId, fixed); + const fb = isFixed(bId, fixed); + if (fa && fb) return false; + let changed = false; + if (isCircleCurve(arc)) { + let ang = Math.atan2((a.y || 0) - center.y, (a.x || 0) - center.x); + if (!Number.isFinite(ang)) ang = 0; + const px = center.x + Math.cos(ang) * target; + const py = center.y + Math.sin(ang) * target; + if (!fa) changed = setPoint(a, px, py) || changed; + if (!fb) changed = setPoint(b, px, py) || changed; + changed = setArcCenterAndMeta(arc, center.x, center.y, target, 0, Math.PI * 2, true) || changed; + changed = setArcControl(arc, center.x, center.y + target) || changed; + return changed; + } + const angA = Math.atan2((a.y || 0) - center.y, (a.x || 0) - center.x); + const angB = Math.atan2((b.y || 0) - center.y, (b.x || 0) - center.x); + if (!fa) changed = setPoint(a, center.x + Math.cos(angA) * target, center.y + Math.sin(angA) * target) || changed; + if (!fb) changed = setPoint(b, center.x + Math.cos(angB) * target, center.y + Math.sin(angB) * target) || changed; + const sa = Math.atan2((a.y || 0) - center.y, (a.x || 0) - center.x); + const ea = Math.atan2((b.y || 0) - center.y, (b.x || 0) - center.x); + const ccw = arc?.ccw !== false; + const tau = Math.PI * 2; + let mid; + if (ccw) { + const sweep = (ea - sa + tau) % tau; + mid = sa + sweep * 0.5; + } else { + const sweep = (sa - ea + tau) % tau; + mid = sa - sweep * 0.5; + } + changed = setArcCenterAndMeta(arc, center.x, center.y, target, sa, ea, ccw) || changed; + changed = setArcControl(arc, center.x + Math.cos(mid) * target, center.y + Math.sin(mid) * target) || changed; + return changed; +} + +function applyParallelLike(l1, l2, points, fixed) { + const [a, b] = getLineEndpoints(l1, points); + const [c, d] = getLineEndpoints(l2, points); + if (!a || !b || !c || !d) return false; + const ux = (b.x || 0) - (a.x || 0); + const uy = (b.y || 0) - (a.y || 0); + const ulen = Math.hypot(ux, uy); + if (ulen < EPS) return false; + const vx = (d.x || 0) - (c.x || 0); + const vy = (d.y || 0) - (c.y || 0); + const vlen = Math.hypot(vx, vy); + if (vlen < EPS) return false; + const dirx = ux / ulen; + const diry = uy / ulen; + const dot = vx * dirx + vy * diry; + const sx = dot >= 0 ? dirx : -dirx; + const sy = dot >= 0 ? diry : -diry; + const cId = getLineEndpointId(l2, 'a'); + const dId = getLineEndpointId(l2, 'b'); + const fc = isFixed(cId, fixed); + const fd = isFixed(dId, fixed); + if (fc && fd) return false; + if (fc) return setPoint(d, (c.x || 0) + sx * vlen, (c.y || 0) + sy * vlen); + if (fd) return setPoint(c, (d.x || 0) - sx * vlen, (d.y || 0) - sy * vlen); + const mx = ((c.x || 0) + (d.x || 0)) * 0.5; + const my = ((c.y || 0) + (d.y || 0)) * 0.5; + const hx = sx * vlen * 0.5; + const hy = sy * vlen * 0.5; + return setPoint(c, mx - hx, my - hy) || setPoint(d, mx + hx, my + hy); +} + +function projectPointToLine(pointId, line, points, fixed) { + if (!pointId || isFixed(pointId, fixed)) return false; + const p = points.get(pointId); + if (!p) return false; + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) return false; + const abx = (b.x || 0) - (a.x || 0); + const aby = (b.y || 0) - (a.y || 0); + const abLenSq = abx * abx + aby * aby; + if (abLenSq < EPS) return false; + const apx = (p.x || 0) - (a.x || 0); + const apy = (p.y || 0) - (a.y || 0); + const t = (apx * abx + apy * aby) / abLenSq; + return setPoint(p, (a.x || 0) + abx * t, (a.y || 0) + aby * t); +} + +function applyPointOnLine(constraint, points, lines, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const pointId = points.has(refs[0]) ? refs[0] : (points.has(refs[1]) ? refs[1] : null); + const line = lines.get(lines.has(refs[0]) ? refs[0] : (lines.has(refs[1]) ? refs[1] : null)); + if (!pointId) return false; + if (!line) return false; + return projectPointToLine(pointId, line, points, fixed); +} + +function applyPointOnArc(constraint, points, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const pointId = points.has(refs[0]) ? refs[0] : (points.has(refs[1]) ? refs[1] : null); + const arc = arcs.get(arcs.has(refs[0]) ? refs[0] : (arcs.has(refs[1]) ? refs[1] : null)); + if (!pointId || !arc || isFixed(pointId, fixed)) return false; + const p = points.get(pointId); + if (!p) return false; + + const circ = getArcCircleData(arc, points); + if (!circ) return false; + const px = p.x || 0; + const py = p.y || 0; + const vx = px - circ.cx; + const vy = py - circ.cy; + const vlen = Math.hypot(vx, vy); + if (!Number.isFinite(vlen) || vlen < EPS) return false; + + if (isCircleCurve(arc)) { + return setPoint(p, circ.cx + (vx / vlen) * circ.radius, circ.cy + (vy / vlen) * circ.radius); + } + + // For arc segments, project onto sampled arc polyline. + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const samples = sampleArcPolylineForConstraint(arc, a, b, 64); + if (samples.length < 2) return false; + let best = null; + for (let i = 0; i < samples.length - 1; i++) { + const p1 = samples[i]; + const p2 = samples[i + 1]; + const cand = nearestPointOnSegment(px, py, p1.x, p1.y, p2.x, p2.y); + if (!best || cand.d2 < best.d2) { + best = cand; + } + } + if (!best) return false; + return setPoint(p, best.x, best.y); +} + +function applyPointOnArcConstraints(constraints, points, arcs, fixed) { + let changed = false; + for (const c of constraints) { + if (c?.type !== 'point_on_arc') continue; + changed = applyPointOnArc(c, points, arcs, fixed) || changed; + } + return changed; +} + +function nearestPointOnSegment(px, py, ax, ay, bx, by) { + const abx = bx - ax; + const aby = by - ay; + const abLenSq = abx * abx + aby * aby; + if (abLenSq < EPS) { + const dx = px - ax; + const dy = py - ay; + return { x: ax, y: ay, d2: dx * dx + dy * dy }; + } + const apx = px - ax; + const apy = py - ay; + const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq)); + const x = ax + abx * t; + const y = ay + aby * t; + const dx = px - x; + const dy = py - y; + return { x, y, d2: dx * dx + dy * dy }; +} + +function nearestPointOnInfiniteLine(px, py, ax, ay, bx, by) { + const abx = bx - ax; + const aby = by - ay; + const abLenSq = abx * abx + aby * aby; + if (abLenSq < EPS) { + return { x: ax, y: ay, d2: (px - ax) * (px - ax) + (py - ay) * (py - ay) }; + } + const apx = px - ax; + const apy = py - ay; + const t = (apx * abx + apy * aby) / abLenSq; + const x = ax + abx * t; + const y = ay + aby * t; + const dx = px - x; + const dy = py - y; + return { x, y, d2: dx * dx + dy * dy }; +} + +function sampleArcPolylineForConstraint(arc, a, b, segments = 48) { + if (isCircleCurve(arc) && Number.isFinite(arc?.cx) && Number.isFinite(arc?.cy) && Number.isFinite(arc?.radius)) { + const count = Math.max(24, segments); + const pts = []; + const start = Math.atan2((a.y || 0) - (arc.cy || 0), (a.x || 0) - (arc.cx || 0)); + for (let i = 0; i <= count; i++) { + const t = i / count; + const ang = start + t * Math.PI * 2; + pts.push({ + x: (arc.cx || 0) + Math.cos(ang) * (arc.radius || 0), + y: (arc.cy || 0) + Math.sin(ang) * (arc.radius || 0) + }); + } + return pts; + } + const center = getArcCenter(arc, a, b); + if (!center) return []; + const radius = Math.hypot((a.x || 0) - center.x, (a.y || 0) - center.y); + if (radius < EPS) return []; + const startAngle = Number.isFinite(arc?.startAngle) ? arc.startAngle : Math.atan2((a.y || 0) - center.y, (a.x || 0) - center.x); + const endAngle = Number.isFinite(arc?.endAngle) ? arc.endAngle : Math.atan2((b.y || 0) - center.y, (b.x || 0) - center.x); + const ccw = arc?.ccw !== false; + const tau = Math.PI * 2; + let sweep; + if (ccw) { + sweep = (endAngle - startAngle) % tau; + if (sweep < 0) sweep += tau; + } else { + sweep = (startAngle - endAngle) % tau; + if (sweep < 0) sweep += tau; + sweep = -sweep; + } + const count = Math.max(8, segments); + const pts = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const ang = startAngle + sweep * t; + pts.push({ + x: center.x + Math.cos(ang) * radius, + y: center.y + Math.sin(ang) * radius + }); + } + pts[0] = { x: a.x || 0, y: a.y || 0 }; + pts[pts.length - 1] = { x: b.x || 0, y: b.y || 0 }; + return pts; +} + +function applyArcCenterCoincident(constraint, points, lines, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const arc = arcs.get(refs[0]); + const target = points.get(refs[1]); + if (!arc || !target) return false; + + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + + const center = getArcCenter(arc, a, b); + if (!center) return false; + const tx = target.x || 0; + const ty = target.y || 0; + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(arc, a, b, tx, ty, fa, fb); +} + +function applyArcCenterCoincidentConstraints(constraints, points, lines, arcs, fixed) { + let changed = false; + for (const c of constraints) { + if (c?.type !== 'arc_center_coincident') continue; + changed = applyArcCenterCoincident(c, points, lines, arcs, fixed) || changed; + } + return changed; +} + +function applyArcCenterOnLine(constraint, points, lines, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const arcId = refs.find(id => arcs.has(id)) || null; + const lineId = refs.find(id => lines.has(id)) || null; + if (!arcId || !lineId) return false; + const arc = arcs.get(arcId); + const line = lines.get(lineId); + if (!arc || !line) return false; + const [a, b] = getLineEndpoints(arc, points); + const [l1, l2] = getLineEndpoints(line, points); + if (!a || !b || !l1 || !l2) return false; + const center = getArcCenter(arc, a, b); + if (!center) return false; + + const abx = (l2.x || 0) - (l1.x || 0); + const aby = (l2.y || 0) - (l1.y || 0); + const len2 = abx * abx + aby * aby; + if (len2 < EPS) return false; + const t = (((center.x || 0) - (l1.x || 0)) * abx + ((center.y || 0) - (l1.y || 0)) * aby) / len2; + const tx = (l1.x || 0) + abx * t; + const ty = (l1.y || 0) + aby * t; + + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(arc, a, b, tx, ty, fa, fb); +} + +function applyArcCenterOnArc(constraint, points, lines, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const sourceArcId = refs.find(id => arcs.has(id)) || null; + const targetArcId = refs.find(id => id !== sourceArcId && arcs.has(id)) || null; + if (!sourceArcId || !targetArcId) return false; + const sourceArc = arcs.get(sourceArcId); + const targetArc = arcs.get(targetArcId); + if (!sourceArc || !targetArc) return false; + + const [sa, sb] = getLineEndpoints(sourceArc, points); + const [ta, tb] = getLineEndpoints(targetArc, points); + if (!sa || !sb || !ta || !tb) return false; + const sourceCenter = getArcCenter(sourceArc, sa, sb); + if (!sourceCenter) return false; + + const targetCirc = getArcCircleData(targetArc, points); + if (!targetCirc) return false; + + let tx; + let ty; + if (isCircleCurve(targetArc)) { + const vx = (sourceCenter.x || 0) - targetCirc.cx; + const vy = (sourceCenter.y || 0) - targetCirc.cy; + const vlen = Math.hypot(vx, vy); + if (!Number.isFinite(vlen) || vlen < EPS) { + tx = targetCirc.cx + targetCirc.radius; + ty = targetCirc.cy; + } else { + tx = targetCirc.cx + (vx / vlen) * targetCirc.radius; + ty = targetCirc.cy + (vy / vlen) * targetCirc.radius; + } + } else { + const samples = sampleArcPolylineForConstraint(targetArc, ta, tb, 64); + if (samples.length < 2) return false; + let best = null; + for (let i = 0; i < samples.length - 1; i++) { + const p1 = samples[i]; + const p2 = samples[i + 1]; + const cand = nearestPointOnSegment( + sourceCenter.x || 0, + sourceCenter.y || 0, + p1.x || 0, + p1.y || 0, + p2.x || 0, + p2.y || 0 + ); + if (!best || cand.d2 < best.d2) best = cand; + } + if (!best) return false; + tx = best.x; + ty = best.y; + } + + const aId = getLineEndpointId(sourceArc, 'a'); + const bId = getLineEndpointId(sourceArc, 'b'); + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(sourceArc, sa, sb, tx, ty, fa, fb); +} + +function applyArcCenterFixedOrigin(constraint, points, lines, arcs, fixed) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + const arcId = refs.find(id => arcs.has(id)) || null; + if (!arcId) return false; + const arc = arcs.get(arcId); + if (!arc) return false; + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(arc, a, b, 0, 0, fa, fb); +} + +function applyTangent(constraint, points, lines, arcs, fixed, dragged = new Set(), draggedArcs = new Set(), tangentAggressive = false) { + return applyTangentConstraint(constraint, { + points, + lines, + arcs, + fixed, + dragged, + draggedArcs, + tangentAggressive, + deps: { + getLineEndpoints, + getLineEndpointId, + isFixed, + setPoint, + getArcCircleData, + moveArcCenterBy + } + }); +} + +function moveArcCenterBy(arc, points, fixed, dx, dy) { + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return false; + const center = getArcCenter(arc, a, b); + if (!center) return false; + const aId = getLineEndpointId(arc, 'a'); + const bId = getLineEndpointId(arc, 'b'); + const fa = !!(aId && fixed.has(aId)); + const fb = !!(bId && fixed.has(bId)); + return enforceArcFromCenter(arc, a, b, center.x + dx, center.y + dy, fa, fb); +} + +function applyTangentConstraints(constraints, points, lines, arcs, fixed) { + let changed = false; + for (const c of constraints) { + if (c?.type !== 'tangent') continue; + changed = applyTangent(c, points, lines, arcs, fixed) || changed; + } + return changed; +} + +function applyMidpoint(constraint, points, fixed, dragged = new Set()) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 3) return false; + const mid = points.get(refs[0]); + const a = points.get(refs[1]); + const b = points.get(refs[2]); + if (!mid || !a || !b) return false; + const fm = isFixed(refs[0], fixed); + const fa = isFixed(refs[1], fixed); + const fb = isFixed(refs[2], fixed); + const midDragged = !!dragged?.has?.(refs[0]); + const aDragged = !!dragged?.has?.(refs[1]); + const bDragged = !!dragged?.has?.(refs[2]); + if (fm && fa && fb) return false; + + if (fm && fa) { + return setPoint(b, 2 * (mid.x || 0) - (a.x || 0), 2 * (mid.y || 0) - (a.y || 0)); + } + if (fm && fb) { + return setPoint(a, 2 * (mid.x || 0) - (b.x || 0), 2 * (mid.y || 0) - (b.y || 0)); + } + if (fa && fb) { + return setPoint(mid, ((a.x || 0) + (b.x || 0)) * 0.5, ((a.y || 0) + (b.y || 0)) * 0.5); + } + // When midpoint is fixed and one endpoint is user-dragged, reflect the opposite + // endpoint across the midpoint. This avoids shearing/translation artifacts. + if (fm && aDragged && !fb) { + return setPoint(b, 2 * (mid.x || 0) - (a.x || 0), 2 * (mid.y || 0) - (a.y || 0)); + } + if (fm && bDragged && !fa) { + return setPoint(a, 2 * (mid.x || 0) - (b.x || 0), 2 * (mid.y || 0) - (b.y || 0)); + } + const mx = ((a.x || 0) + (b.x || 0)) * 0.5; + const my = ((a.y || 0) + (b.y || 0)) * 0.5; + if (midDragged && !fm) { + if (fa && fb) { + return setPoint(mid, mx, my); + } + const tx = (mid.x || 0) - mx; + const ty = (mid.y || 0) - my; + let moved = false; + if (!fa) moved = setPoint(a, (a.x || 0) + tx, (a.y || 0) + ty) || moved; + if (!fb) moved = setPoint(b, (b.x || 0) + tx, (b.y || 0) + ty) || moved; + return moved; + } + if ((aDragged || bDragged) && !fm) { + return setPoint(mid, mx, my); + } + if (!fm) { + return setPoint(mid, mx, my); + } + // midpoint fixed: move both endpoints symmetrically to preserve center + const tx = (mid.x || 0) - mx; + const ty = (mid.y || 0) - my; + let changed = false; + if (!fa) changed = setPoint(a, (a.x || 0) + tx, (a.y || 0) + ty) || changed; + if (!fb) changed = setPoint(b, (b.x || 0) + tx, (b.y || 0) + ty) || changed; + return changed; +} + +function applyMidpointConstraints(constraints, points, fixed, dragged = new Set()) { + let changed = false; + for (const c of constraints) { + if (c?.type !== 'midpoint') continue; + changed = applyMidpoint(c, points, fixed, dragged) || changed; + } + return changed; +} + +function setArcControl(arc, x, y) { + const nx = Number.isFinite(x) ? x : (arc.mx || 0); + const ny = Number.isFinite(y) ? y : (arc.my || 0); + const dx = nx - (arc.mx || 0); + const dy = ny - (arc.my || 0); + if (Math.abs(dx) < EPS && Math.abs(dy) < EPS) return false; + arc.mx = nx; + arc.my = ny; + return true; +} + +function setArcCenterAndMeta(arc, cx, cy, radius, startAngle, endAngle, ccw) { + let changed = false; + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(radius)) { + return false; + } + if (Math.abs((arc.cx || 0) - cx) > EPS) { + arc.cx = cx; + changed = true; + } + if (Math.abs((arc.cy || 0) - cy) > EPS) { + arc.cy = cy; + changed = true; + } + if (Math.abs((arc.radius || 0) - radius) > EPS) { + arc.radius = radius; + changed = true; + } + if (Number.isFinite(startAngle) && Math.abs((arc.startAngle || 0) - startAngle) > EPS) { + arc.startAngle = startAngle; + changed = true; + } + if (Number.isFinite(endAngle) && Math.abs((arc.endAngle || 0) - endAngle) > EPS) { + arc.endAngle = endAngle; + changed = true; + } + if (typeof ccw === 'boolean' && arc.ccw !== ccw) { + arc.ccw = ccw; + changed = true; + } + return changed; +} + +function mirrorPointAcrossAxisLocal(point, axisA, axisB) { + const ax = axisA?.x || 0; + const ay = axisA?.y || 0; + const bx = axisB?.x || 0; + const by = axisB?.y || 0; + const px = point?.x || 0; + const py = point?.y || 0; + const dx = bx - ax; + const dy = by - ay; + const den = dx * dx + dy * dy; + if (!Number.isFinite(den) || den < EPS) { + return { x: px, y: py }; + } + const t = ((px - ax) * dx + (py - ay) * dy) / den; + const qx = ax + t * dx; + const qy = ay + t * dy; + return { x: qx * 2 - px, y: qy * 2 - py }; +} + +function resolveMirrorAxis(constraint, lines, points) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 1) return null; + const axisLine = lines.get(refs[0]); + if (!axisLine) return null; + const [a, b] = getLineEndpoints(axisLine, points); + if (!a || !b) return null; + const len = Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + if (!Number.isFinite(len) || len < EPS) return null; + return { a, b }; +} + +function applyMirrorPoint(constraint, points, lines, fixed, dragged = new Set()) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 3) return false; + const axis = resolveMirrorAxis(constraint, lines, points); + if (!axis) return false; + const srcId = refs[1]; + const dstId = refs[2]; + const src = points.get(srcId); + const dst = points.get(dstId); + if (!src || !dst) return false; + const srcDragged = !!dragged?.has?.(srcId); + const dstDragged = !!dragged?.has?.(dstId); + const srcFixed = isFixed(srcId, fixed); + const dstFixed = isFixed(dstId, fixed); + if (!srcDragged && !dstDragged && !srcFixed && !dstFixed) { + const reflectedDst = mirrorPointAcrossAxisLocal(dst, axis.a, axis.b); + const sx = ((src.x || 0) + (reflectedDst.x || 0)) * 0.5; + const sy = ((src.y || 0) + (reflectedDst.y || 0)) * 0.5; + const mirrored = mirrorPointAcrossAxisLocal({ x: sx, y: sy }, axis.a, axis.b); + let changed = false; + changed = setPoint(src, sx, sy) || changed; + changed = setPoint(dst, mirrored.x, mirrored.y) || changed; + return changed; + } + let primary = src; + let primaryId = srcId; + let secondary = dst; + let secondaryId = dstId; + if (dstDragged && !srcDragged) { + primary = dst; + primaryId = dstId; + secondary = src; + secondaryId = srcId; + } else if (srcFixed && !dstFixed) { + primary = src; + primaryId = srcId; + secondary = dst; + secondaryId = dstId; + } else if (dstFixed && !srcFixed) { + primary = dst; + primaryId = dstId; + secondary = src; + secondaryId = srcId; + } + if (isFixed(secondaryId, fixed) && !isFixed(primaryId, fixed)) { + return false; + } + const mirrored = mirrorPointAcrossAxisLocal(primary, axis.a, axis.b); + return setPoint(secondary, mirrored.x, mirrored.y); +} + +function applyMirrorArc(constraint, points, lines, arcs, fixed, draggedPoints = new Set(), draggedArcs = new Set()) { + // Disabled by design: mirrored arc/circle behavior is driven by mirrored + // point pairs. Keeping this path inactive avoids unstable center-drag + // interactions until we add a dedicated robust arc mirror relation. + return false; +} + +function applyMirrorConstraints(constraints, points, lines, arcs, fixed, draggedPoints = new Set(), draggedArcs = new Set()) { + let changed = false; + for (const c of constraints || []) { + if (c?.type === 'mirror_point') { + changed = applyMirrorPoint(c, points, lines, fixed, draggedPoints) || changed; + } else if (c?.type === 'mirror_arc') { + changed = applyMirrorArc(c, points, lines, arcs, fixed, draggedPoints, draggedArcs) || changed; + } + } + return changed; +} + +function normalizeAngle(a) { + let out = a % (Math.PI * 2); + if (out < 0) out += Math.PI * 2; + return out; +} + +function enforceArcFromCenter(arc, a, b, cx, cy, fa, fb) { + if (isCircleCurve(arc)) { + const ax = a.x || 0; + const ay = a.y || 0; + const bx = b.x || 0; + const by = b.y || 0; + let radius = Number(arc?.radius); + if (!Number.isFinite(radius) || radius < EPS) { + const ra = Math.hypot(ax - cx, ay - cy); + const rb = Math.hypot(bx - cx, by - cy); + radius = Math.max(ra, rb, 1); + } + const base = Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) + ? Math.atan2((arc.my || 0) - cy, (arc.mx || 0) - cx) + : (Math.atan2(ay - cy, ax - cx) || 0); + + let changed = false; + const px = cx + Math.cos(base) * radius; + const py = cy + Math.sin(base) * radius; + if (!fa) changed = setPoint(a, px, py) || changed; + if (!fb) changed = setPoint(b, px, py) || changed; + const mx = cx + Math.cos(base + Math.PI / 2) * radius; + const my = cy + Math.sin(base + Math.PI / 2) * radius; + changed = setArcControl(arc, mx, my) || changed; + changed = setArcCenterAndMeta(arc, cx, cy, radius, 0, Math.PI * 2, true) || changed; + return changed; + } + + const ax = a.x || 0; + const ay = a.y || 0; + const bx = b.x || 0; + const by = b.y || 0; + let ra = Math.hypot(ax - cx, ay - cy); + let rb = Math.hypot(bx - cx, by - cy); + if (ra < EPS && rb < EPS) { + return false; + } + + const aa = Math.atan2(ay - cy, ax - cx); + const ab = Math.atan2(by - cy, bx - cx); + + let radius; + if (fa && fb) { + radius = ra; + } else if (fa) { + radius = ra; + } else if (fb) { + radius = rb; + } else { + radius = (ra + rb) * 0.5; + } + if (!Number.isFinite(radius) || radius < EPS) { + radius = Math.max(ra, rb, 1); + } + + let changed = false; + if (!fa) { + changed = setPoint(a, cx + Math.cos(aa) * radius, cy + Math.sin(aa) * radius) || changed; + } + if (!fb) { + changed = setPoint(b, cx + Math.cos(ab) * radius, cy + Math.sin(ab) * radius) || changed; + } + + const startAngle = Math.atan2((a.y || 0) - cy, (a.x || 0) - cx); + const endAngle = Math.atan2((b.y || 0) - cy, (b.x || 0) - cx); + + let ccw = arc?.ccw !== false; + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my)) { + const g = computeArcGeometry( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (g) { + ccw = g.ccw; + } + } + + const sa = normalizeAngle(startAngle); + const ea = normalizeAngle(endAngle); + let mid; + if (ccw) { + const sweep = (ea - sa + Math.PI * 2) % (Math.PI * 2); + mid = sa + sweep * 0.5; + } else { + const sweep = (sa - ea + Math.PI * 2) % (Math.PI * 2); + mid = sa - sweep * 0.5; + } + const mx = cx + Math.cos(mid) * radius; + const my = cy + Math.sin(mid) * radius; + changed = setArcControl(arc, mx, my) || changed; + changed = setArcCenterAndMeta(arc, cx, cy, radius, startAngle, endAngle, ccw) || changed; + return changed; +} + +function getArcCenter(arc, a, b) { + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my)) { + const g = computeArcGeometry( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (g) return { x: g.cx, y: g.cy }; + } + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + return null; +} + +function getArcCircleData(arc, points) { + const [a, b] = getLineEndpoints(arc, points); + if (!a || !b) return null; + if (isCircleCurve(arc) && Number.isFinite(arc?.cx) && Number.isFinite(arc?.cy)) { + let r = Number(arc?.radius); + if (!Number.isFinite(r) || r < EPS) { + r = Math.hypot((a.x || 0) - (arc.cx || 0), (a.y || 0) - (arc.cy || 0)); + } + if (!Number.isFinite(r) || r < EPS) return null; + return { cx: Number(arc.cx), cy: Number(arc.cy), radius: r }; + } + const c = getArcCenter(arc, a, b); + if (!c) return null; + const r = Math.hypot((a.x || 0) - c.x, (a.y || 0) - c.y); + if (!Number.isFinite(r) || r < EPS) return null; + return { cx: c.x, cy: c.y, radius: r }; +} + +function computeArcGeometry(start, end, onArc) { + if (!start || !end || !onArc) return null; + const x1 = start.x || 0; + const y1 = start.y || 0; + const x2 = end.x || 0; + const y2 = end.y || 0; + const x3 = onArc.x || 0; + const y3 = onArc.y || 0; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) return null; + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const cx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const cy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + return { cx, cy }; +} + + +export { + enforceWithFallback, + applyThreePointCircleDefinitions, + captureFixedAnchors, + getLineEndpointId, + applyPolygonPattern, + applyPolygonPatternConstraints, + applyCircularPatternConstraints, + applyGridPatternConstraints, + applyPointOnArcConstraints, + applyArcCenterCoincidentConstraints, + applyMidpointConstraints, + applyTangentConstraints, + applyMirrorConstraints +}; diff --git a/src/void/sketch/constraints_tangent.js b/src/void/sketch/constraints_tangent.js new file mode 100644 index 00000000..28dca842 --- /dev/null +++ b/src/void/sketch/constraints_tangent.js @@ -0,0 +1,172 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function applyTangentConstraint(constraint, ctx) { + const { + points, + lines, + arcs, + fixed, + dragged = new Set(), + draggedArcs = new Set(), + tangentAggressive = false, + deps + } = ctx; + const { + getLineEndpoints, + getLineEndpointId, + isFixed, + setPoint, + getArcCircleData, + moveArcCenterBy + } = deps; + + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length < 2) return false; + const line = lines.get(lines.has(refs[0]) ? refs[0] : (lines.has(refs[1]) ? refs[1] : null)); + const arc = arcs.get(arcs.has(refs[0]) ? refs[0] : (arcs.has(refs[1]) ? refs[1] : null)); + if (!line && !arc) return false; + if (!line && arc) { + const a1 = arcs.get(refs[0]); + const a2 = arcs.get(refs[1]); + if (!a1 || !a2) return false; + return applyArcArcTangent(constraint, a1, a2, ctx, deps); + } + if (!line || !arc) return false; + const [a, b] = getLineEndpoints(line, points); + if (!a || !b) return false; + const circ = getArcCircleData(arc, points); + if (!circ) return false; + + const x1 = a.x || 0; + const y1 = a.y || 0; + const x2 = b.x || 0; + const y2 = b.y || 0; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy); + if (len < 1e-9) return false; + const nx = -dy / len; + const ny = dx / len; + const dist = ((circ.cx - x1) * nx + (circ.cy - y1) * ny); + const sign = dist >= 0 ? 1 : -1; + const target = sign * circ.radius; + const err = dist - target; + if (Math.abs(err) < 1e-5) return false; + const relax = 0.35; + const maxStep = Math.max(0.25, len * 0.2); + const corr = Math.max(-maxStep, Math.min(maxStep, err * relax)); + + const aId = getLineEndpointId(line, 'a'); + const bId = getLineEndpointId(line, 'b'); + const fa = isFixed(aId, fixed); + const fb = isFixed(bId, fixed); + if (fa && fb) return false; + if (!fa && !fb) { + const mx = corr * nx; + const my = corr * ny; + let changed = false; + changed = setPoint(a, x1 + mx, y1 + my) || changed; + changed = setPoint(b, x2 + mx, y2 + my) || changed; + return changed; + } + if (!fa) { + return setPoint(a, x1 + corr * nx, y1 + corr * ny); + } + return setPoint(b, x2 + corr * nx, y2 + corr * ny); +} + +function applyArcArcTangent(constraint, arc1, arc2, ctx, deps) { + const { + points, + fixed, + dragged = new Set(), + draggedArcs = new Set(), + tangentAggressive = false + } = ctx; + const { + getLineEndpointId, + isFixed, + getArcCircleData, + moveArcCenterBy + } = deps; + + const c1 = getArcCircleData(arc1, points); + const c2 = getArcCircleData(arc2, points); + if (!c1 || !c2) return false; + + let dx = c2.cx - c1.cx; + let dy = c2.cy - c1.cy; + let dist = Math.hypot(dx, dy); + if (!Number.isFinite(dist) || dist < 1e-9) { + dx = 1; + dy = 0; + dist = 1; + } + const ux = dx / dist; + const uy = dy / dist; + + const ext = c1.radius + c2.radius; + const intl = Math.abs(c1.radius - c2.radius); + const mode = resolveArcArcTangentMode(constraint, dist, ext, intl); + const target = mode === 'internal' ? intl : ext; + const err = dist - target; + if (Math.abs(err) < 1e-6) return false; + + const a2id = getLineEndpointId(arc2, 'a'); + const b2id = getLineEndpointId(arc2, 'b'); + const a2f = isFixed(a2id, fixed); + const b2f = isFixed(b2id, fixed); + const a1id = getLineEndpointId(arc1, 'a'); + const b1id = getLineEndpointId(arc1, 'b'); + const a1f = isFixed(a1id, fixed); + const b1f = isFixed(b1id, fixed); + + const arc1Dragged = draggedArcs?.has?.(arc1.id); + const arc2Dragged = draggedArcs?.has?.(arc2.id); + const a1Dragged = dragged?.has?.(a1id) || dragged?.has?.(b1id); + const a2Dragged = dragged?.has?.(a2id) || dragged?.has?.(b2id); + + if (arc1Dragged && !arc2Dragged && !(a2f && b2f)) { + return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy); + } + if (arc2Dragged && !arc1Dragged && !(a1f && b1f)) { + return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy); + } + if (a1Dragged && !a2Dragged && !(a2f && b2f)) { + return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy); + } + if (a2Dragged && !a1Dragged && !(a1f && b1f)) { + return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy); + } + if (!(a2f && b2f)) { + return moveArcCenterTowardTarget(c1, c2, target, ux, uy, arc2, points, fixed, tangentAggressive, moveArcCenterBy); + } + if (!(a1f && b1f)) { + return moveArcCenterTowardTarget(c2, c1, target, -ux, -uy, arc1, points, fixed, tangentAggressive, moveArcCenterBy); + } + return false; +} + +function resolveArcArcTangentMode(constraint, dist, ext, intl) { + const data = (constraint && typeof constraint === 'object') ? (constraint.data || (constraint.data = {})) : {}; + if (data.arc_arc_mode === 'external' || data.arc_arc_mode === 'internal') { + return data.arc_arc_mode; + } + const mode = Math.abs(dist - ext) <= Math.abs(dist - intl) ? 'external' : 'internal'; + data.arc_arc_mode = mode; + return mode; +} + +function moveArcCenterTowardTarget(anchor, moving, targetDist, ux, uy, moveArc, points, fixed, aggressive = false, moveArcCenterBy) { + const tx = anchor.cx + ux * targetDist; + const ty = anchor.cy + uy * targetDist; + let dx = tx - moving.cx; + let dy = ty - moving.cy; + if (!aggressive) { + dx *= 0.4; + dy *= 0.4; + } + return moveArcCenterBy(moveArc, points, fixed, dx, dy); +} + +export { applyTangentConstraint }; diff --git a/src/void/sketch/create.js b/src/void/sketch/create.js new file mode 100644 index 00000000..cec10416 --- /dev/null +++ b/src/void/sketch/create.js @@ -0,0 +1,2132 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from '../api.js'; +import { enforceSketchConstraintsInPlace } from './constraints.js'; +import { + isCircleCurve, + isThreePointCircle, + markArcThreePoint, + markArcCenterPoint, + markArcTangent, + markCircleCenterPoint, + markCircleThreePoint +} from './curve.js'; +import { + SKETCH_MIN_LINE_LENGTH, + SKETCH_POINT_MERGE_EPS +} from './constants.js'; + +function findArcWithEndpoints(feature, p1Id, p2Id) { + if (!feature || !p1Id || !p2Id || p1Id === p2Id) return null; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + for (const entity of entities) { + if (entity?.type !== 'arc' || !entity.id) continue; + const a = typeof entity.a === 'string' ? entity.a : null; + const b = typeof entity.b === 'string' ? entity.b : null; + if (!a || !b) continue; + if ((a === p1Id && b === p2Id) || (a === p2Id && b === p1Id)) { + return entity; + } + } + return null; +} + +function convertArcToCircle(feature, arcId, p1Id, p2Id) { + let changed = false; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const byId = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const arc = byId.get(arcId); + const p1 = byId.get(p1Id); + const p2 = byId.get(p2Id); + if (!arc || arc.type !== 'arc' || !p1 || !p2) { + return; + } + const center = this.getArcCenterLocalFromEntity(arc, byId) + || (Number.isFinite(arc.cx) && Number.isFinite(arc.cy) ? { x: arc.cx, y: arc.cy } : null); + if (!center) return; + + if (Math.abs((p2.x || 0) - (p1.x || 0)) > 1e-9 || Math.abs((p2.y || 0) - (p1.y || 0)) > 1e-9) { + p2.x = p1.x || 0; + p2.y = p1.y || 0; + changed = true; + } + + const radius = Math.hypot((p1.x || 0) - center.x, (p1.y || 0) - center.y); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + return; + } + + changed = markCircleThreePoint(arc) || changed; + if (Math.abs((arc.cx || 0) - center.x) > 1e-9) { + arc.cx = center.x; + changed = true; + } + if (Math.abs((arc.cy || 0) - center.y) > 1e-9) { + arc.cy = center.y; + changed = true; + } + if (Math.abs((arc.radius || 0) - radius) > 1e-9) { + arc.radius = radius; + changed = true; + } + const angle = Math.atan2((p1.y || 0) - center.y, (p1.x || 0) - center.x); + const mx = center.x + Math.cos(angle + Math.PI / 2) * radius; + const my = center.y + Math.sin(angle + Math.PI / 2) * radius; + if (!Number.isFinite(arc.mx) || Math.abs((arc.mx || 0) - mx) > 1e-9) { + arc.mx = mx; + changed = true; + } + if (!Number.isFinite(arc.my) || Math.abs((arc.my || 0) - my) > 1e-9) { + arc.my = my; + changed = true; + } + if (arc.startAngle !== 0) { + arc.startAngle = 0; + changed = true; + } + if (arc.endAngle !== Math.PI * 2) { + arc.endAngle = Math.PI * 2; + changed = true; + } + if (arc.ccw !== true) { + arc.ccw = true; + changed = true; + } + }, { + opType: 'feature.update', + payload: { field: 'entities.update', entity: 'arc', id: arcId } + }); + return changed; +} + +function createSketchPoint(feature, local) { + const existing = this.findPointByCoord(feature, local, SKETCH_POINT_MERGE_EPS); + if (existing) { + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(existing.id); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return; + } + const id = this.newSketchEntityId('point'); + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.entities.push({ + id, + type: 'point', + x: local.x, + y: local.y, + fixed: false + }); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'point' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); +} + +function createSketchLine(feature, a, b, options = {}) { + const dx = (b.x || 0) - (a.x || 0); + const dy = (b.y || 0) - (a.y || 0); + if (Math.hypot(dx, dy) < SKETCH_MIN_LINE_LENGTH) { + return null; + } + + const id = this.newSketchEntityId('line'); + let createdStartPointId = null; + let createdEndPointId = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + + const parseArcCenterRef = ref => { + if (typeof ref !== 'string') return null; + if (!ref.startsWith('arc-center:')) return null; + const arcId = ref.substring('arc-center:'.length); + return arcId || null; + }; + + const pa = { + id: this.newSketchEntityId('point'), + type: 'point', + x: a.x, + y: a.y, + fixed: false + }; + const pb = { + id: this.newSketchEntityId('point'), + type: 'point', + x: b.x, + y: b.y, + fixed: false + }; + createdStartPointId = pa.id; + createdEndPointId = pb.id; + sketch.entities.push(pa, pb); + + if (options.startRefId) { + const arcId = parseArcCenterRef(options.startRefId); + if (arcId) { + sketch.constraints.push({ + id: this.newSketchEntityId('constraint'), + type: 'arc_center_coincident', + refs: [arcId, pa.id] + }); + } else { + addCoincidentConstraintIfMissing.call(this, sketch, pa.id, options.startRefId); + } + } + if (options.endRefId) { + const arcId = parseArcCenterRef(options.endRefId); + if (arcId) { + sketch.constraints.push({ + id: this.newSketchEntityId('constraint'), + type: 'arc_center_coincident', + refs: [arcId, pb.id] + }); + } else { + addCoincidentConstraintIfMissing.call(this, sketch, pb.id, options.endRefId); + } + } + + sketch.entities.push({ + id, + type: 'line', + construction: false, + a: pa.id, + b: pb.id + }); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'line' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.sketchLinePreview = null; + this.updateSketchInteractionVisuals(); + return { lineId: id, startPointId: createdStartPointId, endPointId: createdEndPointId }; +} + +function createSketchArc(feature, start, end, onArc, options = {}) { + const geom = this.computeArcGeometry(start, end, onArc); + if (!geom) { + return null; + } + const id = this.newSketchEntityId('arc'); + let createdStartPointId = null; + let createdEndPointId = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + + const pa = { + id: this.newSketchEntityId('point'), + type: 'point', + x: start.x, + y: start.y, + fixed: false + }; + const pb = { + id: this.newSketchEntityId('point'), + type: 'point', + x: end.x, + y: end.y, + fixed: false + }; + createdStartPointId = pa.id; + createdEndPointId = pb.id; + sketch.entities.push(pa, pb); + + if (options.startRefId) { + addCoincidentConstraintIfMissing.call(this, sketch, pa.id, options.startRefId); + } + if (options.endRefId) { + addCoincidentConstraintIfMissing.call(this, sketch, pb.id, options.endRefId); + } + + const arcEntity = { + id, + type: 'arc', + construction: false, + a: pa.id, + b: pb.id, + mx: onArc.x, + my: onArc.y, + cx: geom.cx, + cy: geom.cy, + radius: geom.radius, + startAngle: geom.startAngle, + endAngle: geom.endAngle, + ccw: geom.ccw + }; + if (options?.variant === 'arc-center') { + markArcCenterPoint(arcEntity); + } else if (options?.variant === 'arc-tangent') { + markArcTangent(arcEntity); + } else { + markArcThreePoint(arcEntity); + } + sketch.entities.push(arcEntity); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'arc' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return { arcId: id, startPointId: createdStartPointId, endPointId: createdEndPointId }; +} + +function createSketchArcFromCenter(feature, center, start, endRaw, options = {}) { + const geom = computeArcGeometryFromCenter(center, start, endRaw); + if (!geom) { + return null; + } + return createSketchArc.call(this, feature, geom.start, geom.end, geom.onArc, { + ...options, + variant: 'arc-center' + }); +} + +function createSketchCircle(feature, center, edge, options = {}) { + const radius = Math.hypot((edge.x || 0) - (center.x || 0), (edge.y || 0) - (center.y || 0)); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + return null; + } + const angle = Math.atan2((edge.y || 0) - (center.y || 0), (edge.x || 0) - (center.x || 0)); + const edgePt = { + x: center.x + Math.cos(angle) * radius, + y: center.y + Math.sin(angle) * radius + }; + const id = this.newSketchEntityId('arc'); + let p1Id = null; + let p2Id = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const p1 = { + id: this.newSketchEntityId('point'), + type: 'point', + x: edgePt.x, + y: edgePt.y, + fixed: false + }; + const p2 = { + id: this.newSketchEntityId('point'), + type: 'point', + x: edgePt.x, + y: edgePt.y, + fixed: false + }; + p1Id = p1.id; + p2Id = p2.id; + sketch.entities.push(p1, p2); + if (options.centerRefId) { + sketch.constraints.push({ + id: this.newSketchEntityId('cst'), + type: 'arc_center_coincident', + refs: [id, options.centerRefId], + data: {}, + created_at: Date.now() + }); + } + const circleEntity = { + id, + type: 'arc', + construction: false, + a: p1.id, + b: p2.id, + cx: center.x, + cy: center.y, + radius, + mx: center.x + Math.cos(angle + Math.PI / 2) * radius, + my: center.y + Math.sin(angle + Math.PI / 2) * radius, + startAngle: 0, + endAngle: Math.PI * 2, + ccw: true + }; + if (options?.circleVariant === 'three-point') { + markCircleThreePoint(circleEntity); + } else { + markCircleCenterPoint(circleEntity); + } + sketch.entities.push(circleEntity); + addCoincidentConstraintIfMissing.call(this, sketch, p1.id, p2.id); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'circle' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return { circleId: id, pointIds: [p1Id, p2Id] }; +} + +function createSketchCircle3Point(feature, a, b, c, options = {}) { + const circle = computeCircleFromThreePoints(a, b, c); + if (!circle) { + return null; + } + const id = this.newSketchEntityId('arc'); + const pRefIds = []; + let hiddenAId = null; + let hiddenBId = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const pointById = new Map(sketch.entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e])); + const refIds = Array.isArray(options?.pointRefIds) ? options.pointRefIds : []; + const resolvePointId = (refId, local) => { + if (typeof refId === 'string' && pointById.has(refId)) { + return refId; + } + const p = { id: this.newSketchEntityId('point'), type: 'point', x: local.x, y: local.y, fixed: false }; + sketch.entities.push(p); + pointById.set(p.id, p); + return p.id; + }; + const p1Id = resolvePointId(refIds[0], a); + const p2Id = resolvePointId(refIds[1], b); + const p3Id = resolvePointId(refIds[2], c); + const p1 = pointById.get(p1Id); + const h1 = { id: this.newSketchEntityId('point'), type: 'point', x: p1?.x ?? a.x, y: p1?.y ?? a.y, fixed: false }; + const h2 = { id: this.newSketchEntityId('point'), type: 'point', x: p1?.x ?? a.x, y: p1?.y ?? a.y, fixed: false }; + pRefIds.push(p1Id, p2Id, p3Id); + hiddenAId = h1.id; + hiddenBId = h2.id; + sketch.entities.push(h1, h2); + const circleEntity = { + id, + type: 'arc', + construction: false, + a: h1.id, + b: h2.id, + cx: circle.cx, + cy: circle.cy, + radius: circle.radius, + mx: circle.cx, + my: circle.cy + circle.radius, + startAngle: 0, + endAngle: Math.PI * 2, + ccw: true, + data: { + ...(options?.data || {}), + threePointIds: [p1Id, p2Id, p3Id] + } + }; + markCircleThreePoint(circleEntity); + sketch.entities.push(circleEntity); + addCoincidentConstraintIfMissing.call(this, sketch, h1.id, h2.id); + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'circle-3pt' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + for (const pid of pRefIds) { + this.selectedSketchEntities.add(pid); + } + this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return { circleId: id, pointIds: pRefIds, hiddenIds: [hiddenAId, hiddenBId] }; +} + +function makeSketchRectPreview(start, end, centerMode = false) { + const corners = this.getRectangleCorners(start, end, centerMode); + if (!corners) return null; + return { + mode: centerMode ? 'center' : 'corner', + corners + }; +} + +function getRectangleCorners(start, end, centerMode = false) { + if (!start || !end) return null; + const sx = Number(start.x || 0); + const sy = Number(start.y || 0); + const ex = Number(end.x || 0); + const ey = Number(end.y || 0); + let p1, p2, p3, p4; + if (centerMode) { + const dx = ex - sx; + const dy = ey - sy; + p1 = { x: sx - dx, y: sy - dy }; + p3 = { x: sx + dx, y: sy + dy }; + p2 = { x: p3.x, y: p1.y }; + p4 = { x: p1.x, y: p3.y }; + } else { + p1 = { x: sx, y: sy }; + p3 = { x: ex, y: ey }; + p2 = { x: p3.x, y: p1.y }; + p4 = { x: p1.x, y: p3.y }; + } + if (Math.abs(p3.x - p1.x) < SKETCH_MIN_LINE_LENGTH || Math.abs(p3.y - p1.y) < SKETCH_MIN_LINE_LENGTH) { + return null; + } + return [p1, p2, p3, p4]; +} + +function createSketchRectangle(feature, start, end, options = {}) { + const corners = this.getRectangleCorners(start, end, !!options.centerMode); + if (!corners) { + return null; + } + const [c1, c2, c3, c4] = corners; + const ids = { + p1: this.newSketchEntityId('point'), + p2: this.newSketchEntityId('point'), + p3: this.newSketchEntityId('point'), + p4: this.newSketchEntityId('point'), + pc: options.centerMode ? this.newSketchEntityId('point') : null, + l1: this.newSketchEntityId('line'), + l2: this.newSketchEntityId('line'), + l3: this.newSketchEntityId('line'), + l4: this.newSketchEntityId('line') + }; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + + const pts = [ + { id: ids.p1, type: 'point', x: c1.x, y: c1.y, fixed: false }, + { id: ids.p2, type: 'point', x: c2.x, y: c2.y, fixed: false }, + { id: ids.p3, type: 'point', x: c3.x, y: c3.y, fixed: false }, + { id: ids.p4, type: 'point', x: c4.x, y: c4.y, fixed: false } + ]; + if (ids.pc) { + pts.push({ + id: ids.pc, + type: 'point', + x: ((c1.x || 0) + (c3.x || 0)) * 0.5, + y: ((c1.y || 0) + (c3.y || 0)) * 0.5, + fixed: false + }); + } + sketch.entities.push(...pts); + + if (options.startRefId) { + addCoincidentConstraintIfMissing.call(this, sketch, ids.p1, options.startRefId); + } + if (options.endRefId) { + addCoincidentConstraintIfMissing.call(this, sketch, ids.p3, options.endRefId); + } + + sketch.entities.push( + { id: ids.l1, type: 'line', construction: false, a: ids.p1, b: ids.p2 }, + { id: ids.l2, type: 'line', construction: false, a: ids.p2, b: ids.p3 }, + { id: ids.l3, type: 'line', construction: false, a: ids.p3, b: ids.p4 }, + { id: ids.l4, type: 'line', construction: false, a: ids.p4, b: ids.p1 } + ); + + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'horizontal', [ids.l1]); + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'horizontal', [ids.l3]); + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'vertical', [ids.l2]); + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'vertical', [ids.l4]); + if (options.centerMode && ids.pc) { + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'midpoint', [ids.pc, ids.p1, ids.p3]); + } + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: options.centerMode ? 'rect-center' : 'rect' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(ids.l1); + this.selectedSketchEntities.add(ids.l2); + this.selectedSketchEntities.add(ids.l3); + this.selectedSketchEntities.add(ids.l4); + this.hoveredSketchEntityId = null; + this.sketchRectPreview = null; + this.updateSketchInteractionVisuals(); + + return ids; +} + +function createSketchPolygonFromSelectedCircle(mode = 'inscribed') { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const circle = this.getSelectedSketchCircle(feature); + if (!circle) return false; + + const raw = window.prompt('Number of sides', '6'); + if (raw === null) return false; + const sides = Math.max(3, Math.min(64, Math.round(Number(raw)))); + if (!Number.isFinite(sides) || sides < 3) return false; + + const data = this.getCircleData(feature, circle); + if (!data) return false; + const { cx, cy, radius, startAngle } = data; + const isCircumscribed = mode === 'circumscribed'; + const step = (Math.PI * 2) / sides; + const base = isCircumscribed ? startAngle + (Math.PI / sides) : startAngle; + const polyRadius = isCircumscribed ? (radius / Math.cos(Math.PI / sides)) : radius; + if (!Number.isFinite(polyRadius) || polyRadius <= SKETCH_MIN_LINE_LENGTH) return false; + + const pointIds = []; + const lineIds = []; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + + for (let i = 0; i < sides; i++) { + const ang = base + i * step; + const pid = this.newSketchEntityId('point'); + pointIds.push(pid); + sketch.entities.push({ + id: pid, + type: 'point', + x: cx + Math.cos(ang) * polyRadius, + y: cy + Math.sin(ang) * polyRadius, + fixed: false + }); + } + for (let i = 0; i < sides; i++) { + const lid = this.newSketchEntityId('line'); + lineIds.push(lid); + sketch.entities.push({ + id: lid, + type: 'line', + construction: false, + a: pointIds[i], + b: pointIds[(i + 1) % sides] + }); + } + + sketch.constraints.push({ + id: this.newSketchEntityId('cst'), + type: 'polygon_pattern', + refs: [circle.id, ...pointIds, ...lineIds], + data: { + mode: isCircumscribed ? 'circumscribed' : 'inscribed', + sides, + circleId: circle.id, + pointIds: [...pointIds], + lineIds: [...lineIds] + }, + created_at: Date.now() + }); + enforceSketchConstraintsInPlace(sketch, { + useFallback: true, + iterations: 96 + }); + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: isCircumscribed ? 'polygon-circumscribed' : 'polygon-inscribed' } + }); + + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + for (const id of lineIds) this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function mirrorLocalPointAcrossLine(local, axisA, axisB) { + const ax = axisA?.x || 0; + const ay = axisA?.y || 0; + const bx = axisB?.x || 0; + const by = axisB?.y || 0; + const px = local?.x || 0; + const py = local?.y || 0; + const dx = bx - ax; + const dy = by - ay; + const den = dx * dx + dy * dy; + if (!Number.isFinite(den) || den < 1e-12) { + return { x: px, y: py }; + } + const t = ((px - ax) * dx + (py - ay) * dy) / den; + const qx = ax + t * dx; + const qy = ay + t * dy; + return { + x: qx * 2 - px, + y: qy * 2 - py + }; +} + +function pointDistanceToLine(local, axisA, axisB) { + const ax = axisA?.x || 0; + const ay = axisA?.y || 0; + const bx = axisB?.x || 0; + const by = axisB?.y || 0; + const px = local?.x || 0; + const py = local?.y || 0; + const dx = bx - ax; + const dy = by - ay; + const den = dx * dx + dy * dy; + if (!Number.isFinite(den) || den < 1e-12) return Infinity; + const t = ((px - ax) * dx + (py - ay) * dy) / den; + const qx = ax + t * dx; + const qy = ay + t * dy; + return Math.hypot(px - qx, py - qy); +} + +function isLikelyCircleArcEntity(entity, byId) { + if (!entity || entity.type !== 'arc') return false; + if (isCircleCurve(entity)) return true; + const cx = Number(entity?.cx); + const cy = Number(entity?.cy); + const radius = Number(entity?.radius); + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(radius) || radius <= SKETCH_MIN_LINE_LENGTH) { + return false; + } + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + const a = byId?.get?.(aId); + const b = byId?.get?.(bId); + if (!a || !b) { + return true; + } + const da = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy); + const db = Math.hypot((b.x || 0) - cx, (b.y || 0) - cy); + const ab = Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)); + const tol = Math.max(1e-5, radius * 1e-4); + if (ab <= tol) return true; + if (Math.abs(da - radius) <= tol && Math.abs(db - radius) <= tol && Math.abs(da - db) <= tol) { + const sa = Number(entity?.startAngle); + const ea = Number(entity?.endAngle); + if (Number.isFinite(sa) && Number.isFinite(ea)) { + const span = Math.abs(ea - sa); + const tau = Math.PI * 2; + if (Math.abs(span - tau) <= 1e-3 || Math.abs((span % tau) - tau) <= 1e-3) { + return true; + } + } + } + return false; +} + +function mirrorSelectedSketchGeometry(options = {}) { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const selectedIds = new Set(this.selectedSketchEntities || []); + const sourceIdsOpt = Array.isArray(options?.sourceIds) ? options.sourceIds.filter(id => typeof id === 'string' && id) : null; + const keepResultSelected = options?.keepResultSelected !== false; + let axis = null; + if (typeof options?.axisId === 'string' && options.axisId) { + const axisEntity = byId.get(options.axisId); + if (axisEntity?.type === 'line') { + axis = axisEntity; + } + } + if (!axis) { + const selectedLines = entities.filter(e => e?.type === 'line' && selectedIds.has(e.id)); + if (selectedLines.length !== 1) { + return false; + } + axis = selectedLines[0]; + } + const axisAId = typeof axis?.a === 'string' ? axis.a : (typeof axis?.p1_id === 'string' ? axis.p1_id : null); + const axisBId = typeof axis?.b === 'string' ? axis.b : (typeof axis?.p2_id === 'string' ? axis.p2_id : null); + const axisA = byId.get(axisAId); + const axisB = byId.get(axisBId); + if (!axisA || !axisB) return false; + if (Math.hypot((axisB.x || 0) - (axisA.x || 0), (axisB.y || 0) - (axisA.y || 0)) < SKETCH_MIN_LINE_LENGTH) { + return false; + } + + const selectedPoints = []; + const selectedCurveIds = []; + const sourceIds = sourceIdsOpt ? new Set(sourceIdsOpt) : selectedIds; + for (const ent of entities) { + if (!sourceIds.has(ent.id)) continue; + if (ent.id === axis.id) continue; + if (ent.type === 'point') selectedPoints.push(ent.id); + if (ent.type === 'line' || ent.type === 'arc') selectedCurveIds.push(ent.id); + } + if (!selectedPoints.length && !selectedCurveIds.length) { + return false; + } + + const pointIdsToMirror = new Set(selectedPoints); + for (const cid of selectedCurveIds) { + const ent = byId.get(cid); + if (!ent) continue; + const aId = typeof ent?.a === 'string' ? ent.a : (typeof ent?.p1_id === 'string' ? ent.p1_id : null); + const bId = typeof ent?.b === 'string' ? ent.b : (typeof ent?.p2_id === 'string' ? ent.p2_id : null); + if (aId) pointIdsToMirror.add(aId); + if (bId) pointIdsToMirror.add(bId); + if (ent.type === 'arc') { + const tps = Array.isArray(ent?.data?.threePointIds) ? ent.data.threePointIds : []; + for (const pid of tps) { + if (typeof pid === 'string') pointIdsToMirror.add(pid); + } + } + } + + const axisIds = new Set([axisAId, axisBId]); + const pointMap = new Map(); + const createdPointIds = []; + const createdCurveIds = []; + const mirrorPointPairs = []; + const mirrorLinePairs = []; + const skipMirrorPointPairKeys = new Set(); + + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const mapById = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const hasMirrorConstraint = (type, refs) => sketch.constraints.some(c => { + if (c?.type !== type) return false; + const crefs = Array.isArray(c?.refs) ? c.refs : []; + return crefs.length === refs.length && refs.every((ref, i) => String(crefs[i] || '') === String(ref || '')); + }); + const addMirrorConstraint = (type, refs) => { + if (!refs.every(ref => typeof ref === 'string' && ref)) return; + if (hasMirrorConstraint(type, refs)) return; + sketch.constraints.push({ + id: this.newSketchEntityId('cst'), + type, + refs: [...refs], + data: {}, + created_at: Date.now() + }); + }; + const ensureArcCenterPoint = arcId => { + if (!arcId) return null; + for (const c of sketch.constraints) { + if (c?.type !== 'arc_center_coincident') continue; + const refs = Array.isArray(c?.refs) ? c.refs : []; + if (refs[0] !== arcId) continue; + const pid = refs[1]; + if (typeof pid === 'string' && mapById.get(pid)?.type === 'point') { + return pid; + } + } + const arc = mapById.get(arcId); + if (!arc || arc.type !== 'arc') return null; + const cx = Number(arc.cx); + const cy = Number(arc.cy); + if (!Number.isFinite(cx) || !Number.isFinite(cy)) return null; + const pid = this.newSketchEntityId('point'); + const p = { id: pid, type: 'point', x: cx, y: cy, fixed: false }; + sketch.entities.push(p); + mapById.set(pid, p); + sketch.constraints.push({ + id: this.newSketchEntityId('cst'), + type: 'arc_center_coincident', + refs: [arcId, pid], + data: {}, + created_at: Date.now() + }); + return pid; + }; + const axisLine = mapById.get(axis.id); + if (!axisLine || axisLine.type !== 'line') return; + const aAxisId = typeof axisLine?.a === 'string' ? axisLine.a : (typeof axisLine?.p1_id === 'string' ? axisLine.p1_id : null); + const bAxisId = typeof axisLine?.b === 'string' ? axisLine.b : (typeof axisLine?.p2_id === 'string' ? axisLine.p2_id : null); + const pAxisA = mapById.get(aAxisId); + const pAxisB = mapById.get(bAxisId); + if (!pAxisA || !pAxisB) return; + const axisALocal = { x: pAxisA.x || 0, y: pAxisA.y || 0 }; + const axisBLocal = { x: pAxisB.x || 0, y: pAxisB.y || 0 }; + + for (const pid of pointIdsToMirror) { + const p = mapById.get(pid); + if (!p || p.type !== 'point') continue; + if (axisIds.has(pid)) { + pointMap.set(pid, pid); + continue; + } + const d = pointDistanceToLine(p, axisALocal, axisBLocal); + if (Number.isFinite(d) && d <= SKETCH_POINT_MERGE_EPS) { + pointMap.set(pid, pid); + continue; + } + const mp = mirrorLocalPointAcrossLine(p, axisALocal, axisBLocal); + const nid = this.newSketchEntityId('point'); + const fixed = p.fixed === true; + sketch.entities.push({ + id: nid, + type: 'point', + x: mp.x, + y: mp.y, + fixed + }); + mapById.set(nid, sketch.entities[sketch.entities.length - 1]); + pointMap.set(pid, nid); + createdPointIds.push(nid); + mirrorPointPairs.push([pid, nid]); + } + + for (const cid of selectedCurveIds) { + const src = mapById.get(cid); + if (!src || (src.type !== 'line' && src.type !== 'arc')) continue; + const aId = typeof src?.a === 'string' ? src.a : (typeof src?.p1_id === 'string' ? src.p1_id : null); + const bId = typeof src?.b === 'string' ? src.b : (typeof src?.p2_id === 'string' ? src.p2_id : null); + const na = pointMap.get(aId) || aId; + const nb = pointMap.get(bId) || bId; + if (!na || !nb) continue; + + if (src.type === 'line') { + const lid = this.newSketchEntityId('line'); + sketch.entities.push({ + id: lid, + type: 'line', + construction: src.construction === true, + a: na, + b: nb + }); + mapById.set(lid, sketch.entities[sketch.entities.length - 1]); + createdCurveIds.push(lid); + mirrorLinePairs.push([src.id, lid]); + continue; + } + + const aid = this.newSketchEntityId('arc'); + const arc = { + id: aid, + type: 'arc', + construction: src.construction === true, + a: na, + b: nb, + ccw: src.ccw === undefined ? true : src.ccw + }; + if (Number.isFinite(src.cx) && Number.isFinite(src.cy)) { + const mc = mirrorLocalPointAcrossLine({ x: src.cx, y: src.cy }, axisALocal, axisBLocal); + arc.cx = mc.x; + arc.cy = mc.y; + } + if (Number.isFinite(src.mx) && Number.isFinite(src.my)) { + const mm = mirrorLocalPointAcrossLine({ x: src.mx, y: src.my }, axisALocal, axisBLocal); + arc.mx = mm.x; + arc.my = mm.y; + } + if (src.data && typeof src.data === 'object') { + const data = JSON.parse(JSON.stringify(src.data)); + if (Array.isArray(data.threePointIds)) { + data.threePointIds = data.threePointIds.map(pid => pointMap.get(pid) || pid); + } + arc.data = data; + } + if (src.startAngle !== undefined) arc.startAngle = src.startAngle; + if (src.endAngle !== undefined) arc.endAngle = src.endAngle; + if (Number.isFinite(src.radius)) arc.radius = src.radius; + const srcLooksCircle = isLikelyCircleArcEntity(src, mapById); + if (srcLooksCircle) { + // Keep circle orientation canonical; circle is orientation-invariant. + arc.ccw = true; + if (Number.isFinite(arc.cx) && Number.isFinite(arc.cy)) { + const p1 = mapById.get(na); + if (p1) { + const r = Math.hypot((p1.x || 0) - arc.cx, (p1.y || 0) - arc.cy); + if (Number.isFinite(r) && r > SKETCH_MIN_LINE_LENGTH) { + arc.radius = r; + } + } + } + if (isThreePointCircle(src)) { + markCircleThreePoint(arc); + } else { + markCircleCenterPoint(arc); + } + } else { + // Reflection flips winding. + arc.ccw = !(src.ccw === false); + if (Number.isFinite(arc.cx) && Number.isFinite(arc.cy)) { + const pa = mapById.get(na); + const pb = mapById.get(nb); + if (pa && pb) { + arc.startAngle = Math.atan2((pa.y || 0) - arc.cy, (pa.x || 0) - arc.cx); + arc.endAngle = Math.atan2((pb.y || 0) - arc.cy, (pb.x || 0) - arc.cx); + arc.radius = Math.hypot((pa.x || 0) - arc.cx, (pa.y || 0) - arc.cy); + } + } + } + sketch.entities.push(arc); + mapById.set(aid, arc); + if (srcLooksCircle) { + const pna = mapById.get(na); + const pnb = mapById.get(nb); + if (pna && pnb) { + pnb.x = pna.x || 0; + pnb.y = pna.y || 0; + } + addCoincidentConstraintIfMissing.call(this, sketch, na, nb); + const srcAId = typeof src?.a === 'string' ? src.a : (typeof src?.p1_id === 'string' ? src.p1_id : null); + const srcBId = typeof src?.b === 'string' ? src.b : (typeof src?.p2_id === 'string' ? src.p2_id : null); + if (srcAId && na) skipMirrorPointPairKeys.add(`${srcAId}|${na}`); + if (srcBId && nb) skipMirrorPointPairKeys.add(`${srcBId}|${nb}`); + const srcCenterPointId = ensureArcCenterPoint(src.id); + const dstCenterPointId = ensureArcCenterPoint(aid); + if (srcCenterPointId && dstCenterPointId) { + mirrorPointPairs.push([srcCenterPointId, dstCenterPointId]); + } + addMirrorConstraint('equal', [src.id, aid]); + } + createdCurveIds.push(aid); + } + + for (const [srcPointId, dstPointId] of mirrorPointPairs) { + if (skipMirrorPointPairKeys.has(`${srcPointId}|${dstPointId}`)) { + continue; + } + addMirrorConstraint('mirror_point', [axis.id, srcPointId, dstPointId]); + } + for (const [srcLineId, dstLineId] of mirrorLinePairs) { + addMirrorConstraint('mirror_line', [axis.id, srcLineId, dstLineId]); + } + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { + field: 'entities.add', + entity: 'mirror', + axis: axis.id + } + }); + + if (!createdCurveIds.length && !createdPointIds.length) { + return false; + } + if (keepResultSelected) { + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + for (const id of createdCurveIds) this.selectedSketchEntities.add(id); + for (const id of createdPointIds) this.selectedSketchEntities.add(id); + this.hoveredSketchEntityId = null; + } + this.updateSketchInteractionVisuals(); + return true; +} + +function resolvePatternCenterLocalFromRef(ref, byId) { + if (!ref) return null; + if (ref === '__sketch-origin__') return { x: 0, y: 0 }; + if (typeof ref === 'string' && ref.startsWith('arc-center:')) { + const arcId = ref.substring('arc-center:'.length); + const arc = byId.get(arcId); + if (!arc || arc.type !== 'arc') return null; + const cx = Number(arc.cx); + const cy = Number(arc.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) return { x: cx, y: cy }; + const aId = typeof arc?.a === 'string' ? arc.a : (typeof arc?.p1_id === 'string' ? arc.p1_id : null); + const bId = typeof arc?.b === 'string' ? arc.b : (typeof arc?.p2_id === 'string' ? arc.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + const mx = Number(arc?.mx); + const my = Number(arc?.my); + if (!a || !b || !Number.isFinite(mx) || !Number.isFinite(my)) return null; + const geom = computeArcGeometry.call(this, { x: a.x || 0, y: a.y || 0 }, { x: b.x || 0, y: b.y || 0 }, { x: mx, y: my }); + if (!geom) return null; + return { x: geom.cx, y: geom.cy }; + } + const p = byId.get(ref); + if (p?.type === 'point') return { x: p.x || 0, y: p.y || 0 }; + return null; +} + +function rotateLocalAroundCenter(local, center, angle) { + const dx = (local?.x || 0) - (center?.x || 0); + const dy = (local?.y || 0) - (center?.y || 0); + const ca = Math.cos(angle); + const sa = Math.sin(angle); + return { + x: (center?.x || 0) + dx * ca - dy * sa, + y: (center?.y || 0) + dx * sa + dy * ca + }; +} + +function rebuildCircularPatternConstraintInSketch(sketch, constraint, countIn = null) { + if (!constraint || constraint.type !== 'circular_pattern') return false; + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + constraint.data = constraint.data || {}; + const data = constraint.data; + const sourceIds = Array.isArray(data.sourceIds) ? data.sourceIds.filter(id => typeof id === 'string' && id) : []; + const centerRef = typeof data.centerRef === 'string' ? data.centerRef : (Array.isArray(constraint.refs) ? constraint.refs[0] : null); + const count = Math.max(2, Math.min(256, Number(countIn ?? data.count) || 0)); + if (!sourceIds.length || !centerRef) return false; + const entitiesById = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const center = resolvePatternCenterLocalFromRef.call(this, centerRef, entitiesById); + if (!center) return false; + + const removeIds = new Set(); + for (const refs of data.pointMaps || []) { + for (const pair of refs || []) { + if (Array.isArray(pair) && typeof pair[1] === 'string') removeIds.add(pair[1]); + } + } + for (const refs of data.copies || []) { + for (const id of refs || []) { + if (typeof id === 'string') removeIds.add(id); + } + } + if (removeIds.size) { + sketch.entities = sketch.entities.filter(entity => !removeIds.has(entity?.id)); + sketch.constraints = sketch.constraints.filter(c => { + if (!c || c === constraint) return true; + const refs = Array.isArray(c?.refs) ? c.refs : []; + return !refs.some(ref => removeIds.has(ref)); + }); + } + + const refreshedById = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const sourceEntities = sourceIds.map(id => refreshedById.get(id)).filter(Boolean); + if (!sourceEntities.length) return false; + const sourcePointIds = new Set(); + for (const entity of sourceEntities) { + if (entity.type === 'point') { + sourcePointIds.add(entity.id); + continue; + } + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if (aId) sourcePointIds.add(aId); + if (bId) sourcePointIds.add(bId); + if (entity.type === 'arc') { + for (const pid of (entity?.data?.threePointIds || [])) { + if (typeof pid === 'string') sourcePointIds.add(pid); + } + } + } + + const stepAngle = (Math.PI * 2) / count; + const copyRefs = []; + const pointMapRefs = []; + for (let step = 1; step < count; step++) { + const angle = step * stepAngle; + const pointMap = new Map(); + const pointPairs = []; + for (const srcPointId of sourcePointIds) { + const srcPoint = refreshedById.get(srcPointId); + if (!srcPoint || srcPoint.type !== 'point') continue; + const pos = rotateLocalAroundCenter({ x: srcPoint.x || 0, y: srcPoint.y || 0 }, center, angle); + const id = this.newSketchEntityId('point'); + const point = { + id, + type: 'point', + x: pos.x, + y: pos.y, + fixed: srcPoint.fixed === true + }; + sketch.entities.push(point); + refreshedById.set(id, point); + pointMap.set(srcPointId, id); + pointPairs.push([srcPointId, id]); + } + const stepCopyIds = []; + for (const src of sourceEntities) { + if (!src?.id) continue; + if (src.type === 'point') { + const id = pointMap.get(src.id); + if (id) stepCopyIds.push(id); + continue; + } + if (src.type === 'line') { + const aId = pointMap.get(src.a); + const bId = pointMap.get(src.b); + if (!aId || !bId) continue; + const id = this.newSketchEntityId('line'); + const line = { + id, + type: 'line', + construction: src.construction === true, + a: aId, + b: bId + }; + sketch.entities.push(line); + refreshedById.set(id, line); + stepCopyIds.push(id); + continue; + } + if (src.type === 'arc') { + const aId = pointMap.get(src.a); + const bId = pointMap.get(src.b); + if (!aId || !bId) continue; + const id = this.newSketchEntityId('arc'); + const arc = { + id, + type: 'arc', + construction: src.construction === true, + a: aId, + b: bId, + ccw: src.ccw === undefined ? true : src.ccw + }; + if (Number.isFinite(src.cx) && Number.isFinite(src.cy)) { + const c = rotateLocalAroundCenter({ x: src.cx, y: src.cy }, center, angle); + arc.cx = c.x; + arc.cy = c.y; + } + if (Number.isFinite(src.mx) && Number.isFinite(src.my)) { + const m = rotateLocalAroundCenter({ x: src.mx, y: src.my }, center, angle); + arc.mx = m.x; + arc.my = m.y; + } + if (src.data && typeof src.data === 'object') { + arc.data = JSON.parse(JSON.stringify(src.data)); + if (Array.isArray(arc.data?.threePointIds)) { + arc.data.threePointIds = arc.data.threePointIds.map(pid => pointMap.get(pid) || pid); + } + } + if (src.startAngle !== undefined) arc.startAngle = src.startAngle + angle; + if (src.endAngle !== undefined) arc.endAngle = src.endAngle + angle; + if (Number.isFinite(src.radius)) arc.radius = src.radius; + if (src.curveType) arc.curveType = src.curveType; + if (src.curveDef) arc.curveDef = src.curveDef; + if (src.circle !== undefined) arc.circle = src.circle; + sketch.entities.push(arc); + refreshedById.set(id, arc); + stepCopyIds.push(id); + } + } + pointMapRefs.push(pointPairs); + copyRefs.push(stepCopyIds); + } + + constraint.refs = [centerRef, ...sourceIds]; + data.centerRef = centerRef; + data.count = count; + data.sourceIds = sourceIds; + data.pointMaps = pointMapRefs; + data.copies = copyRefs; + return true; +} + +function circularPatternSelectedSketchGeometry(options = {}) { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const selectedIds = new Set(this.selectedSketchEntities || []); + const centerRef = typeof options?.centerRef === 'string' ? options.centerRef : null; + const count = Math.max(2, Math.min(256, Number(options?.count) || 3)); + const keepResultSelected = options?.keepResultSelected !== false; + if (!centerRef || !resolvePatternCenterLocalFromRef.call(this, centerRef, byId)) { + return false; + } + const sourceIdsOpt = Array.isArray(options?.sourceIds) ? options.sourceIds.filter(id => typeof id === 'string' && id) : null; + const sourceIds = (sourceIdsOpt || Array.from(selectedIds)) + .filter(id => typeof id === 'string' && id !== centerRef && !id.startsWith('arc-center:')); + const sourceEntities = sourceIds.map(id => byId.get(id)).filter(entity => entity && (entity.type === 'point' || entity.type === 'line' || entity.type === 'arc')); + if (!sourceEntities.length) return false; + const normalizedSourceIds = sourceEntities.map(entity => entity.id); + let changed = false; + let constraintId = null; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const constraint = { + id: this.newSketchEntityId('cst'), + type: 'circular_pattern', + refs: [centerRef, ...normalizedSourceIds], + data: { + centerRef, + count, + sourceIds: normalizedSourceIds, + pointMaps: [], + copies: [] + }, + ui: { offset_px: { x: 0, y: -30 } }, + created_at: Date.now() + }; + const ok = rebuildCircularPatternConstraintInSketch.call(this, sketch, constraint, count); + if (!ok) return; + sketch.constraints.push(constraint); + constraintId = constraint.id; + changed = true; + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { + field: 'entities.add', + entity: 'circular-pattern', + count + } + }); + if (!changed) return false; + if (!keepResultSelected) { + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + } else if (constraintId) { + this.selectedSketchConstraints?.clear?.(); + this.selectedSketchConstraints?.add?.(constraintId); + } + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function updateCircularPatternConstraintCopies(constraintId, count) { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + const nextCount = Math.max(2, Math.min(256, Math.floor(Number(count) || 0))); + if (!Number.isFinite(nextCount) || nextCount < 2) return false; + let updated = false; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'circular_pattern'); + if (!c) return; + const ok = rebuildCircularPatternConstraintInSketch.call(this, sketch, c, nextCount); + if (!ok) return; + updated = true; + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'constraints.circular_pattern.rebuild', id: constraintId, count: nextCount } + }); + if (!updated) return false; + this.updateSketchInteractionVisuals(); + return true; +} + +function getPatternLineDirection(sketch, centerPointId, lineId, fallback) { + const entities = Array.isArray(sketch?.entities) ? sketch.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const line = byId.get(lineId); + const center = byId.get(centerPointId); + if (!line || line.type !== 'line' || !center || center.type !== 'point') return fallback; + const a = byId.get(line.a); + const b = byId.get(line.b); + if (!a || !b) return fallback; + const other = line.a === centerPointId ? b : line.b === centerPointId ? a : b; + const dx = (other.x || 0) - (center.x || 0); + const dy = (other.y || 0) - (center.y || 0); + if (Math.hypot(dx, dy) < SKETCH_MIN_LINE_LENGTH) return fallback; + return { x: dx, y: dy }; +} + +function rebuildGridPatternConstraintInSketch(sketch, constraint, axis = null, axisCount = null) { + if (!constraint || constraint.type !== 'grid_pattern') return false; + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + constraint.data = constraint.data || {}; + const data = constraint.data; + const centerPointId = typeof data.centerPointId === 'string' ? data.centerPointId : null; + const sourceIds = Array.isArray(data.sourceIds) ? data.sourceIds.filter(id => typeof id === 'string' && id) : []; + const countH = Math.max(1, Math.min(256, Number((axis === 'h' ? axisCount : data.countH) || 0) || 3)); + const countV = Math.max(1, Math.min(256, Number((axis === 'v' ? axisCount : data.countV) || 0) || 3)); + if (!centerPointId || !sourceIds.length) return false; + + const removeIds = new Set(); + for (const rec of (data.pointMaps || [])) { + for (const pair of (rec?.pairs || [])) { + if (Array.isArray(pair) && typeof pair[1] === 'string') removeIds.add(pair[1]); + } + } + for (const rec of (data.copies || [])) { + for (const id of (rec?.ids || [])) { + if (typeof id === 'string') removeIds.add(id); + } + } + if (removeIds.size) { + sketch.entities = sketch.entities.filter(entity => !removeIds.has(entity?.id)); + sketch.constraints = sketch.constraints.filter(c => { + if (!c || c === constraint) return true; + const refs = Array.isArray(c?.refs) ? c.refs : []; + return !refs.some(ref => removeIds.has(ref)); + }); + } + + const byId = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const center = byId.get(centerPointId); + if (!center || center.type !== 'point') return false; + const sourceEntities = sourceIds.map(id => byId.get(id)).filter(Boolean); + if (!sourceEntities.length) return false; + const sourcePointIds = new Set(); + for (const entity of sourceEntities) { + if (entity.type === 'point') sourcePointIds.add(entity.id); + if (entity.type === 'line' || entity.type === 'arc') { + if (typeof entity.a === 'string') sourcePointIds.add(entity.a); + if (typeof entity.b === 'string') sourcePointIds.add(entity.b); + for (const pid of (entity?.data?.threePointIds || [])) { + if (typeof pid === 'string') sourcePointIds.add(pid); + } + } + } + + const u = getPatternLineDirection(sketch, centerPointId, data.uLineId, { x: 20, y: 0 }); + const v = getPatternLineDirection(sketch, centerPointId, data.vLineId, { x: 0, y: 20 }); + const pointMaps = []; + const copies = []; + for (let i = 0; i < countH; i++) { + for (let j = 0; j < countV; j++) { + if (i === 0 && j === 0) continue; + const ox = i * (u.x || 0) + j * (v.x || 0); + const oy = i * (u.y || 0) + j * (v.y || 0); + const pointMap = new Map(); + const pairs = []; + for (const srcPointId of sourcePointIds) { + const srcPoint = byId.get(srcPointId); + if (!srcPoint || srcPoint.type !== 'point') continue; + const id = this.newSketchEntityId('point'); + const point = { + id, + type: 'point', + x: (srcPoint.x || 0) + ox, + y: (srcPoint.y || 0) + oy, + fixed: srcPoint.fixed === true + }; + sketch.entities.push(point); + byId.set(id, point); + pointMap.set(srcPointId, id); + pairs.push([srcPointId, id]); + } + const ids = []; + for (const src of sourceEntities) { + if (!src?.id) continue; + if (src.type === 'point') { + const id = pointMap.get(src.id); + if (id) ids.push(id); + continue; + } + if (src.type === 'line') { + const aId = pointMap.get(src.a); + const bId = pointMap.get(src.b); + if (!aId || !bId) continue; + const id = this.newSketchEntityId('line'); + const line = { id, type: 'line', construction: src.construction === true, a: aId, b: bId }; + sketch.entities.push(line); + byId.set(id, line); + ids.push(id); + continue; + } + if (src.type === 'arc') { + const aId = pointMap.get(src.a); + const bId = pointMap.get(src.b); + if (!aId || !bId) continue; + const id = this.newSketchEntityId('arc'); + const arc = { + id, + type: 'arc', + construction: src.construction === true, + a: aId, + b: bId, + ccw: src.ccw === undefined ? true : src.ccw + }; + if (Number.isFinite(src.cx) && Number.isFinite(src.cy)) { + arc.cx = (src.cx || 0) + ox; + arc.cy = (src.cy || 0) + oy; + } + if (Number.isFinite(src.mx) && Number.isFinite(src.my)) { + arc.mx = (src.mx || 0) + ox; + arc.my = (src.my || 0) + oy; + } + if (src.data && typeof src.data === 'object') { + arc.data = JSON.parse(JSON.stringify(src.data)); + if (Array.isArray(arc.data?.threePointIds)) { + arc.data.threePointIds = arc.data.threePointIds.map(pid => pointMap.get(pid) || pid); + } + } + if (src.startAngle !== undefined) arc.startAngle = src.startAngle; + if (src.endAngle !== undefined) arc.endAngle = src.endAngle; + if (Number.isFinite(src.radius)) arc.radius = src.radius; + if (src.curveType) arc.curveType = src.curveType; + if (src.curveDef) arc.curveDef = src.curveDef; + if (src.circle !== undefined) arc.circle = src.circle; + sketch.entities.push(arc); + byId.set(id, arc); + ids.push(id); + } + } + pointMaps.push({ i, j, pairs }); + copies.push({ i, j, ids }); + } + } + + data.countH = countH; + data.countV = countV; + data.pointMaps = pointMaps; + data.copies = copies; + constraint.refs = [centerPointId, data.uLineId, data.vLineId, ...sourceIds]; + return true; +} + +function gridPatternSelectedSketchGeometry(options = {}) { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const selectedIds = new Set(this.selectedSketchEntities || []); + const centerRef = typeof options?.centerRef === 'string' ? options.centerRef : null; + if (!centerRef || !byId.get(centerRef) || byId.get(centerRef)?.type !== 'point') return false; + const sourceIdsOpt = Array.isArray(options?.sourceIds) ? options.sourceIds.filter(id => typeof id === 'string' && id) : null; + const sourceIds = (sourceIdsOpt || Array.from(selectedIds)) + .filter(id => typeof id === 'string' && id !== centerRef && !id.startsWith('arc-center:')); + const sourceEntities = sourceIds.map(id => byId.get(id)).filter(entity => entity && (entity.type === 'point' || entity.type === 'line' || entity.type === 'arc')); + if (!sourceEntities.length) return false; + const normalizedSourceIds = sourceEntities.map(entity => entity.id); + const center = byId.get(centerRef); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const entity of sourceEntities) { + if (entity.type === 'point') { + minX = Math.min(minX, entity.x || 0); maxX = Math.max(maxX, entity.x || 0); + minY = Math.min(minY, entity.y || 0); maxY = Math.max(maxY, entity.y || 0); + continue; + } + for (const pid of [entity.a, entity.b]) { + const p = byId.get(pid); + if (!p) continue; + minX = Math.min(minX, p.x || 0); maxX = Math.max(maxX, p.x || 0); + minY = Math.min(minY, p.y || 0); maxY = Math.max(maxY, p.y || 0); + } + } + const stepX = Math.max(10, Number.isFinite(maxX - minX) ? (maxX - minX) : 20); + const stepY = Math.max(10, Number.isFinite(maxY - minY) ? (maxY - minY) : 20); + + let changed = false; + let constraintId = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const hu = this.newSketchEntityId('point'); + const hv = this.newSketchEntityId('point'); + const lu = this.newSketchEntityId('line'); + const lv = this.newSketchEntityId('line'); + sketch.entities.push({ id: hu, type: 'point', x: (center.x || 0) + stepX, y: center.y || 0, fixed: false }); + sketch.entities.push({ id: hv, type: 'point', x: center.x || 0, y: (center.y || 0) + stepY, fixed: false }); + sketch.entities.push({ id: lu, type: 'line', construction: true, a: centerRef, b: hu }); + sketch.entities.push({ id: lv, type: 'line', construction: true, a: centerRef, b: hv }); + const constraint = { + id: this.newSketchEntityId('cst'), + type: 'grid_pattern', + refs: [centerRef, lu, lv, ...normalizedSourceIds], + data: { + centerPointId: centerRef, + sourceIds: normalizedSourceIds, + countH: 3, + countV: 3, + uLineId: lu, + vLineId: lv, + pointMaps: [], + copies: [] + }, + ui: { offset_px: { x: 0, y: -24 } }, + created_at: Date.now() + }; + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'horizontal', [lu]); + this.toggleSketchConstraintInList(sketch, sketch.constraints, 'vertical', [lv]); + const ok = rebuildGridPatternConstraintInSketch.call(this, sketch, constraint); + if (!ok) return; + sketch.constraints.push(constraint); + constraintId = constraint.id; + changed = true; + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { + field: 'entities.add', + entity: 'grid-pattern' + } + }); + if (!changed) return false; + this.selectedSketchConstraints?.clear?.(); + if (constraintId) this.selectedSketchConstraints?.add?.(constraintId); + this.updateSketchInteractionVisuals(); + return true; +} + +function updateGridPatternConstraintCopies(constraintId, axis = 'h', count = 3) { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + const nextCount = Math.max(1, Math.min(256, Math.floor(Number(count) || 0))); + if (!Number.isFinite(nextCount) || nextCount < 1) return false; + let updated = false; + api.features.update(feature.id, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const c = sketch.constraints.find(k => k?.id === constraintId && k?.type === 'grid_pattern'); + if (!c) return; + const ok = rebuildGridPatternConstraintInSketch.call(this, sketch, c, axis, nextCount); + if (!ok) return; + updated = true; + enforceSketchConstraintsInPlace(sketch); + }, { + opType: 'feature.update', + payload: { field: 'constraints.grid_pattern.rebuild', id: constraintId, axis, count: nextCount } + }); + if (!updated) return false; + this.updateSketchInteractionVisuals(); + return true; +} + +function getSelectedSketchCircle(feature) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const selected = entities.filter(entity => this.selectedSketchEntities.has(entity.id)); + const circles = selected.filter(entity => entity?.type === 'arc' && isCircleCurve(entity)); + if (circles.length !== 1) return null; + return circles[0]; +} + +function getCircleData(feature, circle) { + if (!circle || circle.type !== 'arc' || !isCircleCurve(circle)) return null; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const [a] = this.getArcEndpoints(circle, byId); + const cx = Number(circle.cx); + const cy = Number(circle.cy); + const radius = Number(circle.radius); + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(radius) || radius <= SKETCH_MIN_LINE_LENGTH) { + return null; + } + const startAngle = a + ? Math.atan2((a.y || 0) - cy, (a.x || 0) - cx) + : 0; + return { cx, cy, radius, startAngle }; +} + +function computeArcGeometry(start, end, onArc) { + if (!start || !end || !onArc) { + return null; + } + const x1 = start.x || 0; + const y1 = start.y || 0; + const x2 = end.x || 0; + const y2 = end.y || 0; + const x3 = onArc.x || 0; + const y3 = onArc.y || 0; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) { + return null; + } + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const cx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const cy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + const radius = Math.hypot(x1 - cx, y1 - cy); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + return null; + } + const startAngle = Math.atan2(y1 - cy, x1 - cx); + const endAngle = Math.atan2(y2 - cy, x2 - cx); + const midAngle = Math.atan2(y3 - cy, x3 - cx); + const normalize = a => { + let out = a % (Math.PI * 2); + if (out < 0) out += Math.PI * 2; + return out; + }; + const sa = normalize(startAngle); + const ea = normalize(endAngle); + const ma = normalize(midAngle); + const ccwSpan = (ea - sa + Math.PI * 2) % (Math.PI * 2); + const ccwMid = (ma - sa + Math.PI * 2) % (Math.PI * 2); + const ccw = ccwMid <= ccwSpan; + return { cx, cy, radius, startAngle, endAngle, ccw }; +} + +function computeCircleFromThreePoints(a, b, c) { + if (!a || !b || !c) return null; + const x1 = a.x || 0; + const y1 = a.y || 0; + const x2 = b.x || 0; + const y2 = b.y || 0; + const x3 = c.x || 0; + const y3 = c.y || 0; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) { + return null; + } + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const cx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const cy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + const radius = Math.hypot(x1 - cx, y1 - cy); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + return null; + } + return { cx, cy, radius }; +} + +function computeArcGeometryFromCenter(center, start, endRaw) { + if (!center || !start || !endRaw) return null; + const cx = center.x || 0; + const cy = center.y || 0; + const sx = start.x || 0; + const sy = start.y || 0; + const radius = Math.hypot(sx - cx, sy - cy); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + return null; + } + const exv = (endRaw.x || 0) - cx; + const eyv = (endRaw.y || 0) - cy; + const evl = Math.hypot(exv, eyv); + if (!Number.isFinite(evl) || evl < 1e-9) { + return null; + } + const end = { + x: cx + (exv / evl) * radius, + y: cy + (eyv / evl) * radius + }; + const startAngle = Math.atan2(sy - cy, sx - cx); + const endAngle = Math.atan2(end.y - cy, end.x - cx); + const cross = (sx - cx) * (end.y - cy) - (sy - cy) * (end.x - cx); + const ccw = cross >= 0; + const tau = Math.PI * 2; + const norm = a => { + let out = a % tau; + if (out < 0) out += tau; + return out; + }; + const sa = norm(startAngle); + const ea = norm(endAngle); + let mid; + if (ccw) { + const sweep = (ea - sa + tau) % tau; + mid = sa + sweep * 0.5; + } else { + const sweep = (sa - ea + tau) % tau; + mid = sa - sweep * 0.5; + } + const onArc = { + x: cx + Math.cos(mid) * radius, + y: cy + Math.sin(mid) * radius + }; + return { + start: { x: sx, y: sy }, + end, + onArc + }; +} + +function addCoincidentConstraintIfMissing(sketch, aId, bId) { + if (!aId || !bId || aId === bId) return; + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const refs = [aId, bId].sort(); + const key = `coincident:${refs.join(',')}`; + for (const c of sketch.constraints) { + if (this.makeSketchConstraintKey(c?.type, c?.refs || []) === key) { + return; + } + } + sketch.constraints.push({ + id: this.newSketchEntityId('cst'), + type: 'coincident', + refs, + data: {}, + created_at: Date.now() + }); + this.convertArcToCircleInSketch?.(sketch, aId, bId); +} + +function convertArcToCircleInSketch(sketch, p1Id, p2Id) { + if (!sketch || !p1Id || !p2Id || p1Id === p2Id) { + return false; + } + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const byId = new Map(sketch.entities.filter(e => e?.id).map(e => [e.id, e])); + const p1 = byId.get(p1Id); + const p2 = byId.get(p2Id); + if (!p1 || !p2) return false; + let changed = false; + for (const arc of sketch.entities) { + if (arc?.type !== 'arc' || !arc.id) continue; + const a = typeof arc.a === 'string' ? arc.a : null; + const b = typeof arc.b === 'string' ? arc.b : null; + if (!a || !b) continue; + const match = (a === p1Id && b === p2Id) || (a === p2Id && b === p1Id); + if (!match) continue; + + const center = this.getArcCenterLocalFromEntity(arc, byId) + || (Number.isFinite(arc.cx) && Number.isFinite(arc.cy) ? { x: arc.cx, y: arc.cy } : null); + if (!center) continue; + const rx = (p1.x || 0) - center.x; + const ry = (p1.y || 0) - center.y; + const radius = Math.hypot(rx, ry); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + continue; + } + if (Math.abs((p2.x || 0) - (p1.x || 0)) > 1e-9 || Math.abs((p2.y || 0) - (p1.y || 0)) > 1e-9) { + p2.x = p1.x || 0; + p2.y = p1.y || 0; + changed = true; + } + const angle = Math.atan2(ry, rx); + markCircleThreePoint(arc); + arc.cx = center.x; + arc.cy = center.y; + arc.radius = radius; + arc.mx = center.x + Math.cos(angle + Math.PI / 2) * radius; + arc.my = center.y + Math.sin(angle + Math.PI / 2) * radius; + arc.startAngle = 0; + arc.endAngle = Math.PI * 2; + arc.ccw = true; + changed = true; + } + return changed; +} + +function createDerivedSketchPoint(feature, local, source = {}) { + if (!feature || !local) return null; + let createdId = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const existing = this.findPointByCoord(sketch, local, SKETCH_POINT_MERGE_EPS); + if (existing) { + createdId = existing.id; + existing.derived = true; + existing.fixed = true; + existing.source = source || null; + return; + } + const point = { + id: this.newSketchEntityId('point'), + type: 'point', + x: local.x, + y: local.y, + fixed: true, + derived: true, + source: source || null + }; + sketch.entities.push(point); + createdId = point.id; + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'derived-point' } + }); + if (!createdId) return null; + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(createdId); + return createdId; +} + +function createDerivedSketchLine(feature, candidate) { + if (!feature || !candidate?.aLocal || !candidate?.bLocal) return null; + const source = candidate.source || {}; + let created = null; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const entities = sketch.entities; + for (const line of entities) { + if (line?.type !== 'line' || !line?.derived || line?.source?.type !== 'solid-edge') continue; + const ls = line.source || {}; + if (String(ls.solid_id || '') === String(source.solid_id || '') + && String(ls.solid_feature_id || '') === String(source.solid_feature_id || '') + && ls?.a && ls?.b && source?.a && source?.b) { + const sameA = Math.hypot((ls.a.x || 0) - (source.a.x || 0), (ls.a.y || 0) - (source.a.y || 0), (ls.a.z || 0) - (source.a.z || 0)) < 1e-6; + const sameB = Math.hypot((ls.b.x || 0) - (source.b.x || 0), (ls.b.y || 0) - (source.b.y || 0), (ls.b.z || 0) - (source.b.z || 0)) < 1e-6; + const swapA = Math.hypot((ls.a.x || 0) - (source.b.x || 0), (ls.a.y || 0) - (source.b.y || 0), (ls.a.z || 0) - (source.b.z || 0)) < 1e-6; + const swapB = Math.hypot((ls.b.x || 0) - (source.a.x || 0), (ls.b.y || 0) - (source.a.y || 0), (ls.b.z || 0) - (source.a.z || 0)) < 1e-6; + if ((sameA && sameB) || (swapA && swapB)) { + created = { lineId: line.id }; + return; + } + } + } + const p1 = { + id: this.newSketchEntityId('point'), + type: 'point', + x: candidate.aLocal.x || 0, + y: candidate.aLocal.y || 0, + fixed: true, + derived: true, + source: null + }; + const p2 = { + id: this.newSketchEntityId('point'), + type: 'point', + x: candidate.bLocal.x || 0, + y: candidate.bLocal.y || 0, + fixed: true, + derived: true, + source: null + }; + const line = { + id: this.newSketchEntityId('line'), + type: 'line', + a: p1.id, + b: p2.id, + construction: false, + fixed: true, + derived: true, + source: source || null + }; + sketch.entities.push(p1, p2, line); + created = { lineId: line.id, p1: p1.id, p2: p2.id }; + }, { + opType: 'feature.update', + payload: { field: 'entities.add', entity: 'derived-line' } + }); + if (!created?.lineId) return null; + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchEntities.add(created.lineId); + return created; +} + +function deriveSelectionsAtomic(feature, selection = {}) { + if (!feature || feature.type !== 'sketch') return false; + const edges = Array.isArray(selection?.edges) ? selection.edges : []; + const points = Array.isArray(selection?.points) ? selection.points : []; + const faces = Array.isArray(selection?.faces) ? selection.faces : []; + const basis = this.getSketchBasis(feature); + if (!basis) return false; + let added = 0; + const createdIds = []; + api.features.update(feature.id, sketch => { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const entities = sketch.entities; + const pointById = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const ensurePoint = (local, source = null) => { + const existing = this.findPointByCoord(sketch, local, SKETCH_POINT_MERGE_EPS); + if (existing) { + existing.derived = true; + existing.fixed = true; + if (source) existing.source = source; + return existing; + } + const point = { + id: this.newSketchEntityId('point'), + type: 'point', + x: local.x || 0, + y: local.y || 0, + fixed: true, + derived: true, + source: source || null + }; + entities.push(point); + pointById.set(point.id, point); + added++; + createdIds.push(point.id); + return point; + }; + const hasDerivedLine = (source = null) => { + if (!source?.a || !source?.b) return null; + for (const line of entities) { + if (line?.type !== 'line' || !line?.derived || line?.source?.type !== 'solid-edge') continue; + const ls = line.source || {}; + if (String(ls.solid_id || '') !== String(source.solid_id || '')) continue; + if (String(ls.solid_feature_id || '') !== String(source.solid_feature_id || '')) continue; + if (!ls?.a || !ls?.b) continue; + const sameA = Math.hypot((ls.a.x || 0) - (source.a.x || 0), (ls.a.y || 0) - (source.a.y || 0), (ls.a.z || 0) - (source.a.z || 0)) < 1e-6; + const sameB = Math.hypot((ls.b.x || 0) - (source.b.x || 0), (ls.b.y || 0) - (source.b.y || 0), (ls.b.z || 0) - (source.b.z || 0)) < 1e-6; + const swapA = Math.hypot((ls.a.x || 0) - (source.b.x || 0), (ls.a.y || 0) - (source.b.y || 0), (ls.a.z || 0) - (source.b.z || 0)) < 1e-6; + const swapB = Math.hypot((ls.b.x || 0) - (source.a.x || 0), (ls.b.y || 0) - (source.a.y || 0), (ls.b.z || 0) - (source.a.z || 0)) < 1e-6; + if ((sameA && sameB) || (swapA && swapB)) return line; + } + return null; + }; + const ensureLine = (aLocal, bLocal, source = null) => { + if (!aLocal || !bLocal) return null; + if (source) { + const existing = hasDerivedLine(source); + if (existing) return existing; + } + // Endpoint points are often shared by multiple derived edges. + // Keep them unsourced so refresh uses line sources only. + const p1 = ensurePoint(aLocal, null); + const p2 = ensurePoint(bLocal, null); + if (!p1 || !p2 || p1.id === p2.id) return null; + const line = { + id: this.newSketchEntityId('line'), + type: 'line', + a: p1.id, + b: p2.id, + construction: false, + fixed: true, + derived: true, + source: source || null + }; + entities.push(line); + added++; + createdIds.push(line.id); + return line; + }; + + for (const p of points) { + const local = p?.local || null; + if (!local) continue; + ensurePoint(local, p?.source || null); + } + for (const e of edges) { + ensureLine(e?.aLocal || null, e?.bLocal || null, e?.source || null); + } + for (const faceKey of faces) { + const segs = api.solids?.getFaceBoundarySegments?.(faceKey) || []; + const solidId = String(faceKey || '').split(':').slice(0, -1).join(':'); + const faceIdRaw = String(faceKey || '').split(':').slice(-1)[0]; + const faceId = Number(faceIdRaw); + const faceTarget = api.solids?.getSketchTargetForFaceKey?.(faceKey) || null; + const faceFrame = faceTarget?.frame || null; + const faceBasis = (() => { + if (!faceFrame?.origin || !faceFrame?.normal || !faceFrame?.x_axis) return null; + const origin = { + x: Number(faceFrame.origin.x || 0), + y: Number(faceFrame.origin.y || 0), + z: Number(faceFrame.origin.z || 0) + }; + const normal = { + x: Number(faceFrame.normal.x || 0), + y: Number(faceFrame.normal.y || 0), + z: Number(faceFrame.normal.z || 1) + }; + const xAxis = { + x: Number(faceFrame.x_axis.x || 1), + y: Number(faceFrame.x_axis.y || 0), + z: Number(faceFrame.x_axis.z || 0) + }; + const nx = normal.x, ny = normal.y, nz = normal.z; + const nlen = Math.hypot(nx, ny, nz) || 1; + const n = { x: nx / nlen, y: ny / nlen, z: nz / nlen }; + let xx = xAxis.x, xy = xAxis.y, xz = xAxis.z; + const xdotn = xx * n.x + xy * n.y + xz * n.z; + xx -= n.x * xdotn; xy -= n.y * xdotn; xz -= n.z * xdotn; + const xlen = Math.hypot(xx, xy, xz) || 1; + const x = { x: xx / xlen, y: xy / xlen, z: xz / xlen }; + const y = { + x: n.y * x.z - n.z * x.y, + y: n.z * x.x - n.x * x.z, + z: n.x * x.y - n.y * x.x + }; + return { origin, x, y }; + })(); + const worldToFaceLocal = world => { + if (!faceBasis || !world) return null; + const rx = (world.x || 0) - faceBasis.origin.x; + const ry = (world.y || 0) - faceBasis.origin.y; + const rz = (world.z || 0) - faceBasis.origin.z; + return { + x: rx * faceBasis.x.x + ry * faceBasis.x.y + rz * faceBasis.x.z, + y: rx * faceBasis.y.x + ry * faceBasis.y.y + rz * faceBasis.y.z + }; + }; + for (let segIndex = 0; segIndex < segs.length; segIndex++) { + const seg = segs[segIndex]; + if (!seg?.a || !seg?.b) continue; + const aLocal = this.worldToSketchLocal(seg.a, basis); + const bLocal = this.worldToSketchLocal(seg.b, basis); + if (!aLocal || !bLocal) continue; + ensureLine(aLocal, bLocal, { + type: 'solid-edge', + entity: { + kind: 'boundary-segment', + id: `segment:faceedge:${faceKey}:${segIndex}` + }, + solid_id: solidId, + solid_feature_id: faceTarget?.source?.solid_feature_id || null, + face_id: Number.isFinite(faceId) ? faceId : null, + face_frame: faceFrame || null, + face_key: faceKey, + boundary_segment_id: `faceedge:${faceKey}:${segIndex}`, + local_a: worldToFaceLocal(seg.a) || null, + local_b: worldToFaceLocal(seg.b) || null, + edge_index: segIndex, + a: { x: seg.a.x, y: seg.a.y, z: seg.a.z }, + b: { x: seg.b.x, y: seg.b.y, z: seg.b.z } + }); + } + } + }, { + opType: 'feature.update', + payload: { + field: 'entities.add', + entity: 'derived-batch', + counts: { edges: edges.length, points: points.length, faces: faces.length } + } + }); + if (!added) return false; + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + for (const id of createdIds) this.selectedSketchEntities.add(id); + return true; +} + +function refreshDerivedSketchGeometry(feature) { + if (!feature || feature.type !== 'sketch') return false; + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const derivedLines = entities.filter(e => e?.type === 'line' && e?.derived && e?.source?.type === 'solid-edge'); + const derivedPoints = entities.filter(e => e?.type === 'point' && e?.derived && e?.source?.type === 'solid-edge'); + if (!derivedLines.length && !derivedPoints.length) return false; + const basis = this.getSketchBasis(feature); + if (!basis) return false; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + let changed = false; + + const updatePointFromSource = (point, source) => { + const world = api.solids?.resolvePointFromSource?.(source) || null; + if (!world) return; + const local = this.worldToSketchLocal(world, basis); + if (!local) return; + if (Math.abs((point.x || 0) - local.x) > 1e-6 || Math.abs((point.y || 0) - local.y) > 1e-6) { + point.x = local.x; + point.y = local.y; + changed = true; + } + }; + + for (const point of derivedPoints) { + updatePointFromSource(point, point.source || null); + } + for (const line of derivedLines) { + const seg = api.solids?.resolveEdgeFromSource?.(line.source || null); + if (!seg) continue; + const p1 = byId.get(line.a); + const p2 = byId.get(line.b); + if (!p1 || !p2) continue; + const aLocal = this.worldToSketchLocal(seg.aWorld, basis); + const bLocal = this.worldToSketchLocal(seg.bWorld, basis); + if (!aLocal || !bLocal) continue; + if (Math.abs((p1.x || 0) - aLocal.x) > 1e-6 || Math.abs((p1.y || 0) - aLocal.y) > 1e-6) { + p1.x = aLocal.x; + p1.y = aLocal.y; + changed = true; + } + if (Math.abs((p2.x || 0) - bLocal.x) > 1e-6 || Math.abs((p2.y || 0) - bLocal.y) > 1e-6) { + p2.x = bLocal.x; + p2.y = bLocal.y; + changed = true; + } + } + return changed; +} + +export { + findArcWithEndpoints, + convertArcToCircle, + createSketchPoint, + createSketchLine, + createSketchArc, + createSketchArcFromCenter, + createSketchCircle, + createSketchCircle3Point, + makeSketchRectPreview, + getRectangleCorners, + createSketchRectangle, + createSketchPolygonFromSelectedCircle, + mirrorSelectedSketchGeometry, + getSelectedSketchCircle, + getCircleData, + computeArcGeometry, + computeArcGeometryFromCenter, + computeCircleFromThreePoints, + addCoincidentConstraintIfMissing, + convertArcToCircleInSketch, + createDerivedSketchPoint, + createDerivedSketchLine, + deriveSelectionsAtomic, + refreshDerivedSketchGeometry, + circularPatternSelectedSketchGeometry, + updateCircularPatternConstraintCopies, + gridPatternSelectedSketchGeometry, + updateGridPatternConstraintCopies +}; diff --git a/src/void/sketch/curve.js b/src/void/sketch/curve.js new file mode 100644 index 00000000..68c94fd1 --- /dev/null +++ b/src/void/sketch/curve.js @@ -0,0 +1,112 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +const CURVE_TYPE = { + ARC: 'arc', + CIRCLE: 'circle' +}; + +const CURVE_DEF = { + ARC_THREE_POINT: 'three-point', + ARC_CENTER_POINT: 'center-point', + ARC_TANGENT: 'tangent', + CIRCLE_CENTER_POINT: 'center-point', + CIRCLE_THREE_POINT: 'three-point' +}; + +function isArcEntity(entity) { + return entity?.type === 'arc' || entity?.curveType === CURVE_TYPE.ARC || entity?.curveType === CURVE_TYPE.CIRCLE || entity?.circle === true; +} + +function getCurveType(entity) { + if (!entity || typeof entity !== 'object') return null; + if (entity?.curveType === CURVE_TYPE.CIRCLE) return CURVE_TYPE.CIRCLE; + if (entity?.curveType === CURVE_TYPE.ARC) return CURVE_TYPE.ARC; + if (entity?.circle === true) return CURVE_TYPE.CIRCLE; + if (entity?.type === 'arc') return CURVE_TYPE.ARC; + return null; +} + +function isCircleCurve(entity) { + return getCurveType(entity) === CURVE_TYPE.CIRCLE; +} + +function getCurveDefinition(entity) { + if (!isArcEntity(entity)) return null; + if (typeof entity?.curveDefinition === 'string' && entity.curveDefinition) { + return entity.curveDefinition; + } + if (isCircleCurve(entity)) { + return CURVE_DEF.CIRCLE_CENTER_POINT; + } + if (Number.isFinite(entity?.mx) && Number.isFinite(entity?.my)) { + return CURVE_DEF.ARC_THREE_POINT; + } + return CURVE_DEF.ARC_CENTER_POINT; +} + +function applyCurveSchema(entity, curveType, curveDefinition) { + if (!isArcEntity(entity)) return false; + let changed = false; + if (entity.curveType !== curveType) { + entity.curveType = curveType; + changed = true; + } + if (entity.curveDefinition !== curveDefinition) { + entity.curveDefinition = curveDefinition; + changed = true; + } + const legacyCircle = curveType === CURVE_TYPE.CIRCLE; + if (entity.circle !== legacyCircle) { + entity.circle = legacyCircle; + changed = true; + } + return changed; +} + +function markArcThreePoint(entity) { + return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_THREE_POINT); +} + +function markArcCenterPoint(entity) { + return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_CENTER_POINT); +} + +function markArcTangent(entity) { + return applyCurveSchema(entity, CURVE_TYPE.ARC, CURVE_DEF.ARC_TANGENT); +} + +function markCircleCenterPoint(entity) { + return applyCurveSchema(entity, CURVE_TYPE.CIRCLE, CURVE_DEF.CIRCLE_CENTER_POINT); +} + +function markCircleThreePoint(entity) { + return applyCurveSchema(entity, CURVE_TYPE.CIRCLE, CURVE_DEF.CIRCLE_THREE_POINT); +} + +function isCenterPointCircle(entity) { + return isCircleCurve(entity) && getCurveDefinition(entity) === CURVE_DEF.CIRCLE_CENTER_POINT; +} + +function isThreePointCircle(entity) { + if (!isCircleCurve(entity)) return false; + if (getCurveDefinition(entity) !== CURVE_DEF.CIRCLE_THREE_POINT) return false; + const ids = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds.filter(Boolean) : []; + return ids.length >= 3; +} + +export { + CURVE_TYPE, + CURVE_DEF, + isArcEntity, + getCurveType, + isCircleCurve, + getCurveDefinition, + applyCurveSchema, + markArcThreePoint, + markArcCenterPoint, + markArcTangent, + markCircleCenterPoint, + markCircleThreePoint, + isCenterPointCircle, + isThreePointCircle +}; diff --git a/src/void/sketch/geometry.js b/src/void/sketch/geometry.js new file mode 100644 index 00000000..0bd231cb --- /dev/null +++ b/src/void/sketch/geometry.js @@ -0,0 +1,1033 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; +import { space } from '../../moto/space.js'; +import { api } from '../api.js'; +import { isCircleCurve, isThreePointCircle, isCenterPointCircle } from './curve.js'; +import { + SKETCH_HIT_POINT_PX, + SKETCH_HIT_LINE_PX, + SKETCH_MIN_LINE_LENGTH, + SKETCH_POINT_MERGE_EPS, + SKETCH_VIRTUAL_ORIGIN_ID +} from './constants.js'; + +function pointerDistance(event, pointerDown) { + if (!event || !pointerDown) return 0; + return Math.hypot((event.clientX || 0) - pointerDown.clientX, (event.clientY || 0) - pointerDown.clientY); +} + +function collectInternalCircleEndpointIds(entities = []) { + const hidden = new Set(); + for (const entity of entities) { + if (entity?.type !== 'arc' || !isCircleCurve(entity) || isThreePointCircle(entity)) continue; + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if (aId) hidden.add(aId); + if (bId) hidden.add(bId); + } + return hidden; +} + +function hitTestSketchEntity(event, feature) { + if (!event || !feature) { + return null; + } + + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities); + + const basis = this.getSketchBasis(feature); + const screenPoint = this.getEventViewportXY(event); + if (!basis || !screenPoint) { + return null; + } + + let bestPoint = null; + let bestLine = null; + const pointById = new Map(); + const pointHitPx = SKETCH_HIT_POINT_PX; + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + pointById.set(entity.id, entity); + } + } + + for (const entity of entities) { + if (!entity?.id) continue; + + if (entity.type === 'point') { + if (internalCircleEndpointIds.has(entity.id)) continue; + const world = this.sketchLocalToWorld(entity, basis); + const proj = api.overlay.project3Dto2D(world); + if (!proj?.visible) continue; + const dist = Math.hypot(screenPoint.x - proj.x, screenPoint.y - proj.y); + if (dist <= pointHitPx && (!bestPoint || dist < bestPoint.dist)) { + bestPoint = { id: entity.id, type: 'point', dist }; + } + continue; + } + + if (entity.type === 'line') { + const [a, b] = this.getLineEndpoints(entity, pointById); + if (!a || !b) continue; + const wa = this.sketchLocalToWorld(a, basis); + const wb = this.sketchLocalToWorld(b, basis); + const pa = api.overlay.project3Dto2D(wa); + const pb = api.overlay.project3Dto2D(wb); + if (!pa?.visible || !pb?.visible) continue; + const dist = this.distanceToSegmentPx(screenPoint.x, screenPoint.y, pa.x, pa.y, pb.x, pb.y); + if (dist <= SKETCH_HIT_LINE_PX && (!bestLine || dist < bestLine.dist)) { + bestLine = { id: entity.id, type: 'line', dist }; + } + } + if (entity.type === 'arc') { + const center = this.getArcCenterLocalFromEntity(entity, pointById); + if (center && !isThreePointCircle(entity)) { + const wc = this.sketchLocalToWorld(center, basis); + const pc = api.overlay.project3Dto2D(wc); + if (pc?.visible) { + const cd = Math.hypot(screenPoint.x - pc.x, screenPoint.y - pc.y); + const bestIsArcCenter = bestPoint?.type === 'arc-center'; + const sameDist = bestPoint ? Math.abs(cd - bestPoint.dist) <= 1e-6 : false; + if (cd <= pointHitPx && ( + !bestPoint || + cd < bestPoint.dist - 1e-6 || + (bestIsArcCenter && sameDist) + )) { + bestPoint = { id: `arc-center:${entity.id}`, type: 'arc-center', dist: cd }; + } + } + } + const [a, b] = this.getArcEndpoints(entity, pointById); + if (!a || !b) continue; + const sample = this.sampleArcPolyline(entity, a, b, 32); + let minDist = Infinity; + for (let i = 0; i < sample.length - 1; i++) { + const wa = this.sketchLocalToWorld(sample[i], basis); + const wb = this.sketchLocalToWorld(sample[i + 1], basis); + const pa = api.overlay.project3Dto2D(wa); + const pb = api.overlay.project3Dto2D(wb); + if (!pa?.visible || !pb?.visible) continue; + const dist = this.distanceToSegmentPx(screenPoint.x, screenPoint.y, pa.x, pa.y, pb.x, pb.y); + minDist = Math.min(minDist, dist); + } + if (minDist <= SKETCH_HIT_LINE_PX && (!bestLine || minDist < bestLine.dist)) { + bestLine = { id: entity.id, type: 'arc', dist: minDist }; + } + } + } + + const originProj = api.overlay.project3Dto2D(basis.origin); + if (originProj?.visible) { + const originDist = Math.hypot(screenPoint.x - originProj.x, screenPoint.y - originProj.y); + if (originDist <= pointHitPx && (!bestPoint || originDist < bestPoint.dist)) { + bestPoint = { id: SKETCH_VIRTUAL_ORIGIN_ID, type: 'point', dist: originDist }; + } + } + + return bestPoint || bestLine; +} + +function getArcCenterLocalFromEntity(arc, pointById) { + const [a, b] = this.getArcEndpoints(arc, pointById); + if (!a || !b) return null; + if (isCircleCurve(arc)) { + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + } + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my)) { + const geom = this.computeArcGeometry( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (geom) { + return { x: geom.cx, y: geom.cy }; + } + } + if (Number.isFinite(arc?.cx) && Number.isFinite(arc?.cy)) { + return { x: arc.cx, y: arc.cy }; + } + return null; +} + +function getSketchEntityHitFromIntersections(intersections, feature) { + if (!intersections || !intersections.length) { + return null; + } + const rec = api.sketchRuntime?.getRecord?.(feature?.id); + const allowed = rec?.entityViews ? new Set(Array.from(rec.entityViews.keys())) : null; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities); + let bestPoint = null; + let bestLine = null; + for (const hit of intersections) { + const id = hit?.object?.userData?.sketchEntityId; + if (!id) continue; + if (allowed && !allowed.has(id)) continue; + const type = hit.object.userData?.sketchEntityType || null; + if (type === 'profile') continue; + // Arc-center hits must keep their synthetic id (`arc-center:`) + // so drag/snap code can resolve them unambiguously. + const refId = (type === 'arc-center') ? id : (hit.object.userData?.sketchEntityRefId || id); + if (type === 'point' && internalCircleEndpointIds.has(refId)) continue; + const cand = { id: refId, type, distance: hit.distance ?? Infinity }; + if (type === 'point' || type === 'arc-center') { + const bestIsArcCenter = bestPoint?.type === 'arc-center'; + const sameDist = bestPoint ? Math.abs((cand.distance ?? Infinity) - (bestPoint.distance ?? Infinity)) <= 1e-6 : false; + if (!bestPoint || cand.distance < bestPoint.distance - 1e-6 || (type === 'point' && bestIsArcCenter && sameDist)) { + bestPoint = cand; + } + } else if (!bestLine || cand.distance < bestLine.distance) { + bestLine = cand; + } + } + return bestPoint || bestLine || null; +} + +function resolveSketchHit(event, intersections, feature) { + const rayHit = this.getSketchEntityHitFromIntersections(intersections, feature); + const screenHit = this.hitTestSketchEntity(event, feature); + if (screenHit?.type === 'point' || screenHit?.type === 'arc-center') { + return screenHit; + } + if (rayHit?.type === 'point' || rayHit?.type === 'arc-center') { + return rayHit; + } + return rayHit || screenHit || null; +} + +function worldToSketchLocal(world, basis) { + if (!world || !basis) return null; + const rel = world.clone().sub(basis.origin); + return { + x: rel.dot(basis.xAxis), + y: rel.dot(basis.yAxis) + }; +} + +function resolveDerivedEdgeCandidate(event, intersections, feature) { + if (!event || !feature) return null; + const basis = this.getSketchBasis(feature); + if (!basis) return null; + const vp = this.getEventViewportXY(event); + if (!vp) return null; + + const primary = this.getPrimarySurfaceHitFromIntersections?.(intersections || []) || null; + let faceKey = null; + let facePoint = null; + let solidId = ''; + let faceId = NaN; + if (primary?.type === 'solid-face') { + const faceHit = primary.hit || null; + faceKey = String(faceHit?.key || '') || null; + facePoint = faceHit?.intersection?.point || null; + } else if (primary?.type === 'solid-edge') { + const edge = primary.hit || null; + solidId = String(edge?.solidId || ''); + faceId = Number(edge?.faceId); + if (!solidId || !Number.isFinite(faceId)) { + const raw = String(edge?.key || ''); + if (raw.startsWith('faceedge:') || raw.startsWith('faceedgeloop:')) { + const parts = raw.split(':'); + faceId = Number(parts[parts.length - 2]); + solidId = parts.slice(1, -2).join(':'); + } + } + if (solidId && Number.isFinite(faceId)) { + faceKey = `${solidId}:${faceId}`; + facePoint = edge?.intersection?.point || null; + } + } + if (!faceKey || !facePoint) { + const faceHit = api.solids?.getFaceHitFromIntersections?.(intersections || []) || null; + faceKey = String(faceHit?.key || '') || null; + facePoint = faceHit?.intersection?.point || null; + } + if (!faceKey || !facePoint) return null; + const splitAt = String(faceKey).lastIndexOf(':'); + if (splitAt > 0) { + solidId = String(faceKey).substring(0, splitAt); + faceId = Number(String(faceKey).substring(splitAt + 1)); + } + if (!solidId || !Number.isFinite(faceId)) return null; + + const loops = api.solids?.getFaceBoundaryLoops?.(faceKey) || []; + if (!loops.length) return null; + + const segments = []; + for (let li = 0; li < loops.length; li++) { + const loop = loops[li]; + const points = Array.isArray(loop?.points) ? loop.points : []; + if (points.length < 2) continue; + const segIndices = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + for (let si = 0; si + 1 < points.length; si++) { + const wa = points[si]; + const wb = points[si + 1]; + if (!wa || !wb) continue; + const pwa = api.overlay.project3Dto2D(wa); + const pwb = api.overlay.project3Dto2D(wb); + if (!pwa?.visible || !pwb?.visible) continue; + const dist = this.distanceToSegmentPx(vp.x, vp.y, pwa.x || 0, pwa.y || 0, pwb.x || 0, pwb.y || 0); + if (!Number.isFinite(dist)) continue; + const segIndex = Number(segIndices[si]); + segments.push({ + loopIndex: li, + segPos: si, + segIndex: Number.isFinite(segIndex) ? segIndex : si, + closed: !!loop?.closed, + aWorld: wa.clone ? wa.clone() : new THREE.Vector3(Number(wa.x || 0), Number(wa.y || 0), Number(wa.z || 0)), + bWorld: wb.clone ? wb.clone() : new THREE.Vector3(Number(wb.x || 0), Number(wb.y || 0), Number(wb.z || 0)), + distPx: dist + }); + } + } + if (!segments.length) return null; + segments.sort((l, r) => + l.distPx - r.distPx + || l.loopIndex - r.loopIndex + || l.segPos - r.segPos + ); + const bestSeg = segments[0]; + const bestSegDist = Number(bestSeg?.distPx || Infinity); + // Only treat as edge-hover when pointer is genuinely near the edge. + // Otherwise keep face-hover path active so full boundary preview renders. + // Edge mode should only activate when genuinely near a boundary. + // Otherwise allow face mode to show the full boundary set. + const edgeHoverPx = Math.max(2.5, SKETCH_HIT_LINE_PX * 0.35); + if (!Number.isFinite(bestSegDist) || bestSegDist > edgeHoverPx) { + return null; + } + + const a = bestSeg.aWorld; + const b = bestSeg.bWorld; + const mid = a.clone().add(b).multiplyScalar(0.5); + const aLocal = this.worldToSketchLocal(a, basis); + const bLocal = this.worldToSketchLocal(b, basis); + const midLocal = this.worldToSketchLocal(mid, basis); + if (!aLocal || !bLocal || !midLocal) return null; + const pwa = api.overlay.project3Dto2D(a); + const pwb = api.overlay.project3Dto2D(b); + const pwm = api.overlay.project3Dto2D(mid); + const pm = api.overlay.project3Dto2D(this.sketchLocalToWorld(midLocal, basis)); + + const pointHits = []; + if (pwa?.visible) pointHits.push({ kind: 'a', local: aLocal, dist: Math.hypot(vp.x - pwa.x, vp.y - pwa.y) }); + if (pwb?.visible) pointHits.push({ kind: 'b', local: bLocal, dist: Math.hypot(vp.x - pwb.x, vp.y - pwb.y) }); + if (pwm?.visible && pm?.visible) pointHits.push({ kind: 'mid', local: midLocal, dist: Math.hypot(vp.x - pwm.x, vp.y - pwm.y) }); + pointHits.sort((l, r) => l.dist - r.dist); + const pointThreshold = Math.min( + SKETCH_HIT_POINT_PX * 0.45, + Math.max(2.25, bestSegDist * 0.35 + 0.5) + ); + const hoverPoint = pointHits[0] + && pointHits[0].dist <= pointThreshold + && pointHits[0].dist <= (bestSegDist * 0.8) + ? pointHits[0] + : null; + const hoverWorld = hoverPoint + ? (hoverPoint.kind === 'a' + ? { x: a.x, y: a.y, z: a.z } + : hoverPoint.kind === 'b' + ? { x: b.x, y: b.y, z: b.z } + : { x: mid.x, y: mid.y, z: mid.z }) + : null; + + const loop = loops[bestSeg.loopIndex] || null; + const segLen = a.distanceTo(b); + const shortSegThreshold = 6; + const promoteLoop = !!(loop?.closed && Number.isFinite(segLen) && segLen <= shortSegThreshold); + const edgeKey = promoteLoop + ? `faceedgeloop:${solidId}:${faceId}:${bestSeg.loopIndex}` + : `faceedge:${solidId}:${faceId}:${bestSeg.segIndex}`; + + const solid = api.solids?.list?.().find?.(item => item?.id === solidId) || null; + const target = api.solids?.getSketchTargetForFaceKey?.(faceKey) || null; + const faceFrame = target?.frame || null; + const frameBasis = (() => { + if (!faceFrame?.origin || !faceFrame?.normal || !faceFrame?.x_axis) return null; + const origin = new THREE.Vector3( + Number(faceFrame.origin.x || 0), + Number(faceFrame.origin.y || 0), + Number(faceFrame.origin.z || 0) + ); + const normal = new THREE.Vector3( + Number(faceFrame.normal.x || 0), + Number(faceFrame.normal.y || 0), + Number(faceFrame.normal.z || 1) + ).normalize(); + let xAxis = new THREE.Vector3( + Number(faceFrame.x_axis.x || 1), + Number(faceFrame.x_axis.y || 0), + Number(faceFrame.x_axis.z || 0) + ); + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + if (xAxis.lengthSq() <= 1e-12) { + xAxis.set(1, 0, 0); + xAxis.addScaledVector(normal, -xAxis.dot(normal)); + } + xAxis.normalize(); + const yAxis = new THREE.Vector3().crossVectors(normal, xAxis).normalize(); + return { origin, xAxis, yAxis }; + })(); + const toFaceLocal = world => { + if (!world || !frameBasis) return null; + const rel = world.clone().sub(frameBasis.origin); + return { + x: rel.dot(frameBasis.xAxis), + y: rel.dot(frameBasis.yAxis) + }; + }; + const localA = toFaceLocal(a); + const localB = toFaceLocal(b); + const localP = hoverWorld ? toFaceLocal(new THREE.Vector3(hoverWorld.x, hoverWorld.y, hoverWorld.z)) : null; + const pathWorldSegments = []; + const pathLocalSegments = []; + const pathSegmentKeys = []; + const pathSegmentEntityIds = []; + if (promoteLoop) { + const pts = Array.isArray(loop?.points) ? loop.points : []; + const segIdx = Array.isArray(loop?.segmentIndices) ? loop.segmentIndices : []; + for (let i = 0; i + 1 < pts.length; i++) { + const wa = pts[i]; + const wb = pts[i + 1]; + if (!wa || !wb) continue; + const la = this.worldToSketchLocal(wa, basis); + const lb = this.worldToSketchLocal(wb, basis); + if (!la || !lb) continue; + pathLocalSegments.push({ a: la, b: lb }); + pathWorldSegments.push({ + a: { x: Number(wa.x || 0), y: Number(wa.y || 0), z: Number(wa.z || 0) }, + b: { x: Number(wb.x || 0), y: Number(wb.y || 0), z: Number(wb.z || 0) } + }); + const segIndex = Number(segIdx?.[i]); + if (Number.isFinite(segIndex) && Number.isFinite(faceId) && solidId) { + const segKey = `faceedge:${solidId}:${faceId}:${segIndex}`; + pathSegmentKeys.push(segKey); + const ent = api.solids?.resolveCanonicalEdgeEntity?.(segKey) || null; + pathSegmentEntityIds.push(String(ent?.id || '')); + } else { + pathSegmentKeys.push(''); + pathSegmentEntityIds.push(''); + } + } + } + const canonicalEntity = api.solids?.resolveCanonicalEdgeEntity?.(edgeKey) || null; + const canonicalEntityId = String(canonicalEntity?.id || ''); + const canonicalEntityKind = String(canonicalEntity?.kind || 'boundary-segment'); + const out = { + type: 'solid-edge', + solidId, + solidFeatureId: solid?.source?.feature_id || null, + index: Number(bestSeg.segIndex ?? 0), + segDist: bestSegDist, + aLocal, + bLocal, + midLocal, + aWorld: { x: a.x, y: a.y, z: a.z }, + bWorld: { x: b.x, y: b.y, z: b.z }, + midWorld: { x: mid.x, y: mid.y, z: mid.z }, + pathLocalSegments: pathLocalSegments.length ? pathLocalSegments : null, + pathWorldSegments: pathWorldSegments.length ? pathWorldSegments : null, + pathSegmentKeys: pathSegmentKeys.length ? pathSegmentKeys : null, + pathSegmentEntityIds: pathSegmentEntityIds.length ? pathSegmentEntityIds : null, + a: { x: a.x, y: a.y, z: a.z }, + b: { x: b.x, y: b.y, z: b.z }, + hoverPoint: hoverPoint ? { ...hoverPoint, world: hoverWorld } : null, + source: { + type: 'solid-edge', + entity: { + kind: canonicalEntityKind, + id: canonicalEntityId + }, + solid_id: solidId, + solid_feature_id: solid?.source?.feature_id || null, + face_id: Number.isFinite(faceId) ? faceId : null, + face_frame: faceFrame || null, + face_key: faceKey || null, + boundary_segment_id: canonicalEntityId, + local_a: localA || null, + local_b: localB || null, + local_point: localP || null, + edge_key: String(edgeKey || ''), + edge_index: Number(bestSeg.segIndex ?? 0), + a: { x: a.x, y: a.y, z: a.z }, + b: { x: b.x, y: b.y, z: b.z } + } + }; + return out; +} + +function projectFaceBoundaryToSketch(feature, faceKey) { + if (!feature || !faceKey) return null; + const basis = this.getSketchBasis(feature); + if (!basis) return null; + const loops = api.solids?.getFaceBoundaryLoops?.(faceKey) || []; + if (!loops.length) return null; + const out = []; + for (const loop of loops) { + const points = Array.isArray(loop?.points) ? loop.points : []; + if (points.length < 2) continue; + for (let i = 0; i + 1 < points.length; i++) { + const a = this.worldToSketchLocal(points[i], basis); + const b = this.worldToSketchLocal(points[i + 1], basis); + if (!a || !b) continue; + out.push({ a, b }); + } + } + return out.length ? out : null; +} + +function isSketchEventInViewport(event) { + if (!event) return true; + const { container } = space.internals(); + if (!container) return true; + if (!event.target) return true; + return container.contains(event.target); +} + +function getSketchHitLocalPoint(feature, hit) { + if (!hit?.id) { + return null; + } + const isArcCenter = hit?.type === 'arc-center' || String(hit.id).startsWith('arc-center:'); + if (isArcCenter) { + const arcId = String(hit.id).startsWith('arc-center:') + ? String(hit.id).substring('arc-center:'.length) + : String(hit.id); + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e])); + const arc = entities.find(entity => entity?.type === 'arc' && entity?.id === arcId) || null; + if (arc) { + const center = this.getArcCenterLocalFromEntity(arc, pointById); + if (center) return { x: center.x || 0, y: center.y || 0 }; + } + } + if (hit.id === SKETCH_VIRTUAL_ORIGIN_ID) { + return { x: 0, y: 0 }; + } + + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + for (const entity of entities) { + if (entity?.type === 'point' && entity.id === hit.id) { + return { x: entity.x || 0, y: entity.y || 0 }; + } + } + + const rec = api.sketchRuntime?.getRecord?.(feature?.id); + const view = rec?.entityViews?.get?.(hit.id); + if (view?.type === 'point' && view.entity) { + return { x: view.entity.x || 0, y: view.entity.y || 0 }; + } + return null; +} + +function getSketchDragSnapTarget(event, feature, movedPointIds) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const internalCircleEndpointIds = collectInternalCircleEndpointIds(entities); + const basis = this.getSketchBasis(feature); + const vp = this.getEventViewportXY(event); + if (!basis || !vp) { + return null; + } + + const points = entities.filter(e => e?.type === 'point' && e.id && !internalCircleEndpointIds.has(e.id)); + const moved = points.filter(p => movedPointIds?.has(p.id)); + const others = points.filter(p => !movedPointIds?.has(p.id) && p.id !== SKETCH_VIRTUAL_ORIGIN_ID); + if (!moved.length) { + return null; + } + + let target = null; + for (const p of others) { + const world = this.sketchLocalToWorld(p, basis); + const proj = api.overlay.project3Dto2D(world); + if (!proj?.visible) continue; + const d = Math.hypot(vp.x - proj.x, vp.y - proj.y); + if (d > SKETCH_HIT_POINT_PX * 1.8) continue; + if (!target || d < target.dist) { + target = { point: p, dist: d, type: 'point', hoveredId: p.id }; + } + } + const byId = new Map(points.map(p => [p.id, p])); + for (const arc of entities) { + if (arc?.type !== 'arc' || !arc.id) continue; + if (isThreePointCircle(arc)) continue; + const center = this.getArcCenterLocalFromEntity(arc, byId); + if (!center) continue; + const world = this.sketchLocalToWorld(center, basis); + const proj = api.overlay.project3Dto2D(world); + if (!proj?.visible) continue; + const d = Math.hypot(vp.x - proj.x, vp.y - proj.y); + if (d > SKETCH_HIT_POINT_PX * 1.8) continue; + const arcCenterId = `arc-center:${arc.id}`; + if (!target || d < target.dist) { + target = { arc, center, dist: d, type: 'arc-center', hoveredId: arcCenterId }; + } + } + if (!target) { + return null; + } + + let nearestMoved = null; + const tx = target.type === 'arc-center' ? (target.center.x || 0) : (target.point.x || 0); + const ty = target.type === 'arc-center' ? (target.center.y || 0) : (target.point.y || 0); + for (const p of moved) { + const dx = (p.x || 0) - tx; + const dy = (p.y || 0) - ty; + const d = Math.hypot(dx, dy); + if (!nearestMoved || d < nearestMoved.dist) { + nearestMoved = { point: p, dist: d }; + } + } + if (!nearestMoved) { + return null; + } + return { + targetType: target.type || 'point', + targetId: target.point?.id || null, + targetArcId: target.arc?.id || null, + hoveredId: target.hoveredId || target.point?.id || null, + movedId: nearestMoved.point.id + }; +} + +function findPointByCoord(feature, local, eps = SKETCH_POINT_MERGE_EPS) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + for (const entity of entities) { + if (entity?.type !== 'point' || !entity.id) continue; + if (Math.abs((entity.x || 0) - local.x) <= eps && Math.abs((entity.y || 0) - local.y) <= eps) { + return entity; + } + } + return null; +} + +function ensureSketchPoint(sketch, local) { + sketch.entities = Array.isArray(sketch.entities) ? sketch.entities : []; + const existing = this.findPointByCoord(sketch, local, SKETCH_POINT_MERGE_EPS); + if (existing) { + return existing; + } + const point = { + id: this.newSketchEntityId('point'), + type: 'point', + x: local.x, + y: local.y, + fixed: false + }; + sketch.entities.push(point); + return point; +} + +function getLineEndpoints(line, pointById) { + const aId = typeof line?.a === 'string' ? line.a : (typeof line?.p1_id === 'string' ? line.p1_id : null); + const bId = typeof line?.b === 'string' ? line.b : (typeof line?.p2_id === 'string' ? line.p2_id : null); + let a = null; + let b = null; + if (aId) { + a = pointById?.get(aId) || null; + } else if (line?.a && typeof line.a === 'object') { + a = line.a; + } + if (bId) { + b = pointById?.get(bId) || null; + } else if (line?.b && typeof line.b === 'object') { + b = line.b; + } + return [a, b]; +} + +function getArcEndpoints(arc, pointById) { + const aId = typeof arc?.a === 'string' ? arc.a : null; + const bId = typeof arc?.b === 'string' ? arc.b : null; + const a = aId ? (pointById?.get(aId) || null) : null; + const b = bId ? (pointById?.get(bId) || null) : null; + return [a, b]; +} + +function applyCircleDragKinematics(feature, dx = 0, dy = 0, local = null) { + const drag = this.sketchDrag; + if (!drag) return; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const ctrlByArcId = new Map((drag.arcControlBaseline || []).map(rec => [rec.entity?.id, rec])); + + for (const arc of entities) { + if (arc?.type !== 'arc' || !arc.id) continue; + const touchesCircle = drag.activeIds?.has?.(arc.id) + || drag.movedPointIds?.has?.(arc.a) + || drag.movedPointIds?.has?.(arc.b); + if (!touchesCircle) continue; + const a = byId.get(arc.a); + const b = byId.get(arc.b); + if (!a || !b) continue; + + // Arc-center drag should move the whole arc rigidly, regardless of + // arc/circle definition, so the dragged center tracks the pointer. + if (drag.centerDrag) { + const cbase = ctrlByArcId.get(arc.id); + if (cbase) { + if (Number.isFinite(cbase.cx)) arc.cx = (cbase.cx || 0) + dx; + if (Number.isFinite(cbase.cy)) arc.cy = (cbase.cy || 0) + dy; + if (cbase.a) { + a.x = (cbase.a.x || 0) + dx; + a.y = (cbase.a.y || 0) + dy; + } + if (cbase.b) { + b.x = (cbase.b.x || 0) + dx; + b.y = (cbase.b.y || 0) + dy; + } + arc.mx = (cbase.mx || 0) + dx; + arc.my = (cbase.my || 0) + dy; + if (isCircleCurve(arc)) { + arc.radius = Number.isFinite(cbase.radius) ? cbase.radius : (arc.radius || 0); + arc.startAngle = 0; + arc.endAngle = Math.PI * 2; + arc.ccw = true; + } + continue; + } + } + + if (!isDragResizableCircleArc(arc, byId)) continue; + let cx = Number(arc.cx || 0); + let cy = Number(arc.cy || 0); + + const movedA = drag.movedPointIds?.has?.(a.id); + const movedB = drag.movedPointIds?.has?.(b.id); + cx = arc.cx; + cy = arc.cy; + + const curveDrag = drag.circleCurveDragIds?.has?.(arc.id) && !drag.centerDrag; + const anchor = movedA ? a : (movedB ? b : null); + const vx = curveDrag && local ? ((local.x || 0) - cx) : ((anchor?.x || a.x || 0) - cx); + const vy = curveDrag && local ? ((local.y || 0) - cy) : ((anchor?.y || a.y || 0) - cy); + let radius = Math.hypot(vx, vy); + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + radius = Number(arc.radius || 0); + } + if (!Number.isFinite(radius) || radius < SKETCH_MIN_LINE_LENGTH) { + continue; + } + const angle = Math.atan2(vy, vx); + const px = cx + Math.cos(angle) * radius; + const py = cy + Math.sin(angle) * radius; + a.x = px; + a.y = py; + b.x = px; + b.y = py; + arc.radius = radius; + arc.mx = cx + Math.cos(angle + Math.PI / 2) * radius; + arc.my = cy + Math.sin(angle + Math.PI / 2) * radius; + arc.startAngle = 0; + arc.endAngle = Math.PI * 2; + arc.ccw = true; + } +} + +function isDragResizableCircleArc(entity, pointById) { + if (entity?.type !== 'arc') return false; + if (isCenterPointCircle(entity)) return true; + if (!Number.isFinite(entity?.cx) || !Number.isFinite(entity?.cy) || !Number.isFinite(entity?.radius)) return false; + const start = Number(entity?.startAngle); + const end = Number(entity?.endAngle); + const full = Number.isFinite(start) && Number.isFinite(end) && Math.abs(start) < 1e-6 && Math.abs(end - Math.PI * 2) < 1e-6; + if (!full) return false; + const a = typeof entity?.a === 'string' ? pointById?.get?.(entity.a) : null; + const b = typeof entity?.b === 'string' ? pointById?.get?.(entity.b) : null; + if (!a || !b) return !!isCircleCurve(entity); + return Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)) < 1e-6; +} + +function projectPointOnArcConstraintsForArcs(feature, arcIds) { + const idSet = arcIds instanceof Set ? arcIds : new Set(Array.isArray(arcIds) ? arcIds : []); + if (!idSet.size) return; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const constraints = Array.isArray(feature?.constraints) ? feature.constraints : []; + const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e])); + const arcById = new Map(entities.filter(e => e?.type === 'arc' && e?.id).map(e => [e.id, e])); + for (const c of constraints) { + if (c?.type !== 'point_on_arc') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + if (refs.length < 2) continue; + const arcId = arcById.has(refs[0]) ? refs[0] : (arcById.has(refs[1]) ? refs[1] : null); + const pointId = pointById.has(refs[0]) ? refs[0] : (pointById.has(refs[1]) ? refs[1] : null); + if (!arcId || !pointId || !idSet.has(arcId)) continue; + const arc = arcById.get(arcId); + const point = pointById.get(pointId); + if (!isCircleCurve(arc) || !point) continue; + const cx = Number(arc.cx || 0); + const cy = Number(arc.cy || 0); + const radius = Number(arc.radius || 0); + if (!Number.isFinite(radius) || radius <= SKETCH_MIN_LINE_LENGTH) continue; + const vx = (point.x || 0) - cx; + const vy = (point.y || 0) - cy; + const len = Math.hypot(vx, vy); + if (!Number.isFinite(len) || len <= 1e-9) continue; + point.x = cx + (vx / len) * radius; + point.y = cy + (vy / len) * radius; + } +} + +function rebaseSketchDragState(feature, local) { + const drag = this.sketchDrag; + if (!drag || !local) return; + drag.start = { x: local.x || 0, y: local.y || 0 }; + + if (drag.baseline instanceof Map) { + for (const ref of drag.baseline.keys()) { + drag.baseline.set(ref, { x: ref.x || 0, y: ref.y || 0 }); + } + } + + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const entityById = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const pointById = new Map(entities.filter(e => e?.type === 'point' && e?.id).map(e => [e.id, e])); + drag.arcControlBaseline = []; + for (const id of (drag.activeIds || [])) { + const entity = entityById.get(id); + if (entity?.type !== 'arc' || !entity.id) continue; + if (!Number.isFinite(entity.mx) || !Number.isFinite(entity.my)) continue; + const pa = pointById.get(entity.a) || null; + const pb = pointById.get(entity.b) || null; + drag.arcControlBaseline.push({ + entity, + mx: entity.mx, + my: entity.my, + cx: Number(entity.cx || 0), + cy: Number(entity.cy || 0), + radius: Number(entity.radius || 0), + a: pa ? { x: pa.x || 0, y: pa.y || 0 } : null, + b: pb ? { x: pb.x || 0, y: pb.y || 0 } : null + }); + } +} + +function sampleArcPolyline(arc, a, b, segments = 24) { + if (isCircleCurve(arc)) { + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + let radius = Number(arc?.radius); + if (!Number.isFinite(radius) || radius <= 0) { + radius = a ? Math.hypot((a.x || 0) - cx, (a.y || 0) - cy) : 0; + } + if (!Number.isFinite(cx) || !Number.isFinite(cy) || radius <= 0) { + return []; + } + const count = Math.max(24, segments * 2); + let start = 0; + if (a) { + start = Math.atan2((a.y || 0) - cy, (a.x || 0) - cx); + } + const pts = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const angle = start + t * Math.PI * 2; + pts.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius }); + } + return pts; + } + let cx = Number(arc?.cx); + let cy = Number(arc?.cy); + let radius = Number(arc?.radius); + let startAngle = Number(arc?.startAngle); + let endAngle = Number(arc?.endAngle); + let ccw = arc?.ccw !== false; + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) && a && b) { + const geomFromThree = this.computeArcGeometry( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (geomFromThree) { + cx = geomFromThree.cx; + cy = geomFromThree.cy; + radius = geomFromThree.radius; + startAngle = geomFromThree.startAngle; + endAngle = geomFromThree.endAngle; + ccw = geomFromThree.ccw; + } + } + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(startAngle) || !Number.isFinite(endAngle)) { + if (!a || !b) return []; + const geom = this.computeArcGeometry(a, b, { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 + 1e-3 }); + if (!geom) return [{ x: a.x || 0, y: a.y || 0 }, { x: b.x || 0, y: b.y || 0 }]; + startAngle = geom.startAngle; + endAngle = geom.endAngle; + cx = geom.cx; + cy = geom.cy; + radius = geom.radius; + ccw = geom.ccw; + } + if (!Number.isFinite(radius) || radius <= 0) { + radius = a ? Math.hypot((a.x || 0) - cx, (a.y || 0) - cy) : 0; + } + if (radius <= 0) return []; + const tau = Math.PI * 2; + let sweep; + if (ccw) { + sweep = (endAngle - startAngle) % tau; + if (sweep < 0) sweep += tau; + } else { + sweep = (startAngle - endAngle) % tau; + if (sweep < 0) sweep += tau; + sweep = -sweep; + } + const count = Math.max(6, segments); + const pts = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const angle = startAngle + sweep * t; + pts.push({ x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius }); + } + if (a) { + pts[0] = { x: a.x || 0, y: a.y || 0 }; + } + if (b) { + pts[pts.length - 1] = { x: b.x || 0, y: b.y || 0 }; + } + return pts; +} + +function distanceToSegmentPx(px, py, ax, ay, bx, by) { + const abx = bx - ax; + const aby = by - ay; + const apx = px - ax; + const apy = py - ay; + const abLenSq = abx * abx + aby * aby; + if (abLenSq <= 1e-9) { + return Math.hypot(px - ax, py - ay); + } + const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / abLenSq)); + const cx = ax + abx * t; + const cy = ay + aby * t; + return Math.hypot(px - cx, py - cy); +} + +function getEventViewportXY(event) { + const { renderer } = space.internals(); + const canvas = renderer?.domElement; + if (!canvas || !event) { + return null; + } + const rect = canvas.getBoundingClientRect(); + return { + x: event.clientX - rect.left, + y: event.clientY - rect.top, + width: rect.width, + height: rect.height + }; +} + +function getSketchBasis(feature) { + const rec = api.sketchRuntime?.getRecord?.(feature?.id); + const entitiesGroup = rec?.entitiesGroup; + if (entitiesGroup) { + entitiesGroup.updateMatrixWorld(true); + const xAxis = new THREE.Vector3(); + const yAxis = new THREE.Vector3(); + const normal = new THREE.Vector3(); + entitiesGroup.matrixWorld.extractBasis(xAxis, yAxis, normal); + xAxis.normalize(); + yAxis.normalize(); + normal.normalize(); + // Re-orthogonalize in case parent transforms introduce drift. + yAxis.copy(new THREE.Vector3().crossVectors(normal, xAxis).normalize()); + xAxis.copy(new THREE.Vector3().crossVectors(yAxis, normal).normalize()); + const origin = new THREE.Vector3(); + entitiesGroup.getWorldPosition(origin); + return { origin, normal, xAxis, yAxis }; + } + + const frame = feature?.plane; + if (!frame) return null; + const origin = new THREE.Vector3( + frame.origin?.x || 0, + frame.origin?.y || 0, + frame.origin?.z || 0 + ); + const normal = new THREE.Vector3( + frame.normal?.x ?? 0, + frame.normal?.y ?? 0, + frame.normal?.z ?? 1 + ).normalize(); + const xAxis = new THREE.Vector3( + frame.x_axis?.x ?? 1, + frame.x_axis?.y ?? 0, + frame.x_axis?.z ?? 0 + ).normalize(); + const yAxis = new THREE.Vector3().crossVectors(normal, xAxis).normalize(); + return { origin, normal, xAxis, yAxis }; +} + +function sketchLocalToWorld(local, basis) { + return basis.origin.clone() + .addScaledVector(basis.xAxis, local.x || 0) + .addScaledVector(basis.yAxis, local.y || 0); +} + +function projectEventToSketchLocal(event, feature) { + const basis = this.getSketchBasis(feature); + const vp = this.getEventViewportXY(event); + if (!basis || !vp) { + return null; + } + + const { camera } = space.internals(); + if (!camera) { + return null; + } + + const ndc = new THREE.Vector2( + (vp.x / vp.width) * 2 - 1, + -(vp.y / vp.height) * 2 + 1 + ); + + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(ndc, camera); + + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(basis.normal, basis.origin); + const world = new THREE.Vector3(); + const hit = raycaster.ray.intersectPlane(plane, world); + if (!hit) { + return null; + } + + const rel = world.clone().sub(basis.origin); + return { + x: rel.dot(basis.xAxis), + y: rel.dot(basis.yAxis) + }; +} + +export { + pointerDistance, + hitTestSketchEntity, + getArcCenterLocalFromEntity, + getSketchEntityHitFromIntersections, + resolveSketchHit, + resolveDerivedEdgeCandidate, + projectFaceBoundaryToSketch, + worldToSketchLocal, + isSketchEventInViewport, + getSketchHitLocalPoint, + getSketchDragSnapTarget, + findPointByCoord, + ensureSketchPoint, + getLineEndpoints, + getArcEndpoints, + applyCircleDragKinematics, + projectPointOnArcConstraintsForArcs, + rebaseSketchDragState, + sampleArcPolyline, + distanceToSegmentPx, + getEventViewportXY, + getSketchBasis, + sketchLocalToWorld, + projectEventToSketchLocal +}; diff --git a/src/void/sketch/index.js b/src/void/sketch/index.js new file mode 100644 index 00000000..ebaed775 --- /dev/null +++ b/src/void/sketch/index.js @@ -0,0 +1,484 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { space } from '../../moto/space.js'; +import { api } from '../api.js'; +import { + SKETCH_POINT_MERGE_EPS +} from './constants.js'; +import * as sketchGeom from './geometry.js'; +import * as sketchCreate from './create.js'; +import { + getEditingSketchFeature, + isSketchEditing, + setSketchTool, + getSketchTool, + cancelSketchLine, + cancelSketchArc, + cancelSketchCircle, + cancelSketchRect, + clearSketchSelection, + getSelectedSketchMirrorAxis, + startSketchMirrorMode, + stopSketchMirrorMode, + getSelectedSketchPatternCenter, + startSketchCircularPatternMode, + stopSketchCircularPatternMode, + getSelectedSketchGridAnchor, + startSketchGridPatternMode, + stopSketchGridPatternMode, + editSketchCircularPatternConstraint, + editSketchGridPatternConstraint, + handleSketchKeyDown, + selectSketchConstraint, + setHoveredSketchConstraint, + useHoveredDerivedEdge, + useHoveredDerivedPoint +} from './tools.js'; +import { + deleteSelectedSketchConstraints, + deleteSelectedSketchEntities, + toggleSelectedConstruction, + applySketchConstraint, + editSketchDimensionConstraint, + toggleSketchDimensionMode, + findArcWithEndpoints, + convertArcToCircle, + findSketchConstraintInList, + toggleSketchConstraintInList, + normalizeConstraintRefs, + makeSketchConstraintKey +} from './constraints_actions.js'; +import { + handleSketchPointerDown, + handleSketchHover, + handleSketchPointerMove, + handleSketchMouseUp, + handleSketchDrag, + collectDragLockedArcCenters, + applyDragLockedArcCenters, + draggedArcsHaveTangent, + isPointOnSelectedSketchLine +} from './pointer.js'; +import { + startSketchMarquee, + updateSketchMarquee, + finishSketchMarquee, + clearSketchMarquee, + updateSketchMarqueeVisual, + viewportPointFromClient, + selectSketchEntitiesInMarquee, + projectSketchLocalToScreen, + isPointInRect, + segmentTouchesRect, + segmentsIntersect, + collectSelectedCoordinateRefs, + collectCoordinateRefsFromIds +} from './marquee.js'; + +function createSketchPoint(feature, local) { + return sketchCreate.createSketchPoint.call(this, feature, local); +} + +function createSketchLine(feature, a, b, options = {}) { + return sketchCreate.createSketchLine.call(this, feature, a, b, options); +} + +function createSketchArc(feature, start, end, onArc, options = {}) { + return sketchCreate.createSketchArc.call(this, feature, start, end, onArc, options); +} + +function createSketchArcFromCenter(feature, center, start, endRaw, options = {}) { + return sketchCreate.createSketchArcFromCenter.call(this, feature, center, start, endRaw, options); +} + +function createSketchCircle(feature, center, edge, options = {}) { + return sketchCreate.createSketchCircle.call(this, feature, center, edge, options); +} + +function createSketchCircle3Point(feature, a, b, c, options = {}) { + return sketchCreate.createSketchCircle3Point.call(this, feature, a, b, c, options); +} + +function makeSketchRectPreview(start, end, centerMode = false) { + return sketchCreate.makeSketchRectPreview.call(this, start, end, centerMode); +} + +function getRectangleCorners(start, end, centerMode = false) { + return sketchCreate.getRectangleCorners.call(this, start, end, centerMode); +} + +function createSketchRectangle(feature, start, end, options = {}) { + return sketchCreate.createSketchRectangle.call(this, feature, start, end, options); +} + +function createDerivedSketchPoint(feature, local, source = {}) { + return sketchCreate.createDerivedSketchPoint.call(this, feature, local, source); +} + +function createDerivedSketchLine(feature, candidate) { + return sketchCreate.createDerivedSketchLine.call(this, feature, candidate); +} + +function deriveSelectionsAtomic(feature, selection) { + return sketchCreate.deriveSelectionsAtomic.call(this, feature, selection); +} + +function refreshDerivedSketchGeometry(feature) { + return sketchCreate.refreshDerivedSketchGeometry.call(this, feature); +} + +function createSketchPolygonFromSelectedCircle(mode = 'inscribed') { + return sketchCreate.createSketchPolygonFromSelectedCircle.call(this, mode); +} + +function mirrorSelectedSketchGeometry(options = {}) { + return sketchCreate.mirrorSelectedSketchGeometry.call(this, options); +} + +function circularPatternSelectedSketchGeometry(options = {}) { + return sketchCreate.circularPatternSelectedSketchGeometry.call(this, options); +} + +function updateCircularPatternConstraintCopies(constraintId, count) { + return sketchCreate.updateCircularPatternConstraintCopies.call(this, constraintId, count); +} + +function gridPatternSelectedSketchGeometry(options = {}) { + return sketchCreate.gridPatternSelectedSketchGeometry.call(this, options); +} + +function updateGridPatternConstraintCopies(constraintId, axis = 'h', count = 3) { + return sketchCreate.updateGridPatternConstraintCopies.call(this, constraintId, axis, count); +} + +function getSelectedSketchCircle(feature) { + return sketchCreate.getSelectedSketchCircle.call(this, feature); +} + +function getCircleData(feature, circle) { + return sketchCreate.getCircleData.call(this, feature, circle); +} + +function computeArcGeometry(start, end, onArc) { + return sketchCreate.computeArcGeometry.call(this, start, end, onArc); +} + +function computeArcGeometryFromCenter(center, start, endRaw) { + return sketchCreate.computeArcGeometryFromCenter.call(this, center, start, endRaw); +} + +function computeCircleFromThreePoints(a, b, c) { + return sketchCreate.computeCircleFromThreePoints.call(this, a, b, c); +} + +function addCoincidentConstraintIfMissing(sketch, aId, bId) { + return sketchCreate.addCoincidentConstraintIfMissing.call(this, sketch, aId, bId); +} + +function convertArcToCircleInSketch(sketch, p1Id, p2Id) { + return sketchCreate.convertArcToCircleInSketch.call(this, sketch, p1Id, p2Id); +} + +function updateSketchInteractionVisuals() { + const feature = this.getEditingSketchFeature(); + if (!feature) { + return; + } + const dragHoverId = this.sketchDrag?.snapPointId || null; + const external = this.hoveredDerivedCandidate || null; + const canShowExternalPreview = !this.sketchDrag + && !this.sketchLineStart + && !this.sketchArcStart + && !this.sketchCircleCenter + && !this.sketchRectStart; + const externalPointLocal = canShowExternalPreview ? (external?.hoverPoint?.local || null) : null; + const showExternalPoint = !!externalPointLocal; + const showExternalSegments = canShowExternalPreview && Array.isArray(external?.pathLocalSegments) && external.pathLocalSegments.length > 1; + const showExternalLine = canShowExternalPreview && !showExternalSegments && !!external?.aLocal && !!external?.bLocal; + const externalLine = showExternalLine + ? { a: external.aLocal, b: external.bLocal, forceHover: true, projected: true } + : null; + const externalStart = showExternalPoint + ? { ...externalPointLocal, projected: true } + : null; + const externalEnd = null; + const externalPointWorld = showExternalPoint ? (external?.hoverPoint?.world || null) : null; + const projectedFaceSegments = showExternalSegments + ? external.pathLocalSegments + : (!showExternalPoint && !showExternalLine && this.hoveredSolidFaceKey + ? this.projectFaceBoundaryToSketch(feature, this.hoveredSolidFaceKey) + : null); + const sourceFaceWorldSegments = showExternalSegments + ? (external.pathWorldSegments || null) + : (!showExternalPoint && !showExternalLine && this.hoveredSolidFaceKey + ? (() => { + const loops = api.solids?.getFaceBoundaryLoops?.(this.hoveredSolidFaceKey) || []; + if (!loops.length) return null; + const out = []; + for (const loop of loops) { + const points = Array.isArray(loop?.points) ? loop.points : []; + if (points.length < 2) continue; + for (let i = 0; i + 1 < points.length; i++) { + const a = points[i]; + const b = points[i + 1]; + if (!a || !b) continue; + out.push({ + a: { x: Number(a.x || 0), y: Number(a.y || 0), z: Number(a.z || 0) }, + b: { x: Number(b.x || 0), y: Number(b.y || 0), z: Number(b.z || 0) } + }); + } + } + return out.length ? out : null; + })() + : null); + api.sketchRuntime?.setEntityInteraction(feature.id, { + hoveredId: this.sketchDrag ? dragHoverId : this.hoveredSketchEntityId, + selectedIds: Array.from(this.selectedSketchEntities), + mirrorMode: !!this.sketchMirrorMode, + mirrorAxisId: this.sketchMirrorAxisId || null, + circularPatternMode: !!this.sketchCircularPatternMode, + circularPatternCenterRef: this.sketchCircularPatternCenterRef || null, + gridPatternMode: !!this.sketchGridPatternMode, + gridPatternCenterRef: this.sketchGridPatternCenterRef || null, + hoveredConstraintId: this.hoveredSketchConstraintId || null, + selectedConstraintIds: Array.from(this.selectedSketchConstraints || []), + previewLine: this.sketchLinePreview || externalLine, + previewExternalWorldLine: showExternalLine + ? { + a: external?.a || null, + b: external?.b || null, + forceHover: true + } + : null, + previewExternalWorldPoint: showExternalPoint + ? { + x: externalPointWorld?.x || 0, + y: externalPointWorld?.y || 0, + z: externalPointWorld?.z || 0, + forceHover: true + } + : null, + previewExternalWorldSegments: sourceFaceWorldSegments || null, + previewFaceSegments: projectedFaceSegments || null, + previewStart: this.sketchLineStart || this.sketchArcStart || this.sketchCircleCenter || this.sketchRectStart || externalStart, + previewEnd: this.sketchArcEnd || this.sketchCircleSecond || externalEnd || null, + previewMid: null, + previewArc: this.sketchArcPreview, + previewRect: this.sketchRectPreview + }); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function newSketchEntityId(prefix = 'e') { + const tail = Math.random().toString(36).slice(2, 7); + return `${prefix}-${Date.now().toString(36)}-${tail}`; +} + +function pointerDistance(event, pointerDown) { + return sketchGeom.pointerDistance.call(this, event, pointerDown); +} + +function hitTestSketchEntity(event, feature) { + return sketchGeom.hitTestSketchEntity.call(this, event, feature); +} + +function getArcCenterLocalFromEntity(arc, pointById) { + return sketchGeom.getArcCenterLocalFromEntity.call(this, arc, pointById); +} + +function getSketchEntityHitFromIntersections(intersections, feature) { + return sketchGeom.getSketchEntityHitFromIntersections.call(this, intersections, feature); +} + +function resolveSketchHit(event, intersections, feature) { + return sketchGeom.resolveSketchHit.call(this, event, intersections, feature); +} + +function resolveDerivedEdgeCandidate(event, intersections, feature) { + return sketchGeom.resolveDerivedEdgeCandidate.call(this, event, intersections, feature); +} + +function projectFaceBoundaryToSketch(feature, faceKey) { + return sketchGeom.projectFaceBoundaryToSketch.call(this, feature, faceKey); +} + +function isSketchEventInViewport(event) { + return sketchGeom.isSketchEventInViewport.call(this, event); +} + +function getSketchHitLocalPoint(feature, hit) { + return sketchGeom.getSketchHitLocalPoint.call(this, feature, hit); +} + +function getSketchDragSnapTarget(event, feature, movedPointIds) { + return sketchGeom.getSketchDragSnapTarget.call(this, event, feature, movedPointIds); +} + +function findPointByCoord(feature, local, eps = SKETCH_POINT_MERGE_EPS) { + return sketchGeom.findPointByCoord.call(this, feature, local, eps); +} + +function ensureSketchPoint(sketch, local) { + return sketchGeom.ensureSketchPoint.call(this, sketch, local); +} + +function getLineEndpoints(line, pointById) { + return sketchGeom.getLineEndpoints.call(this, line, pointById); +} + +function getArcEndpoints(arc, pointById) { + return sketchGeom.getArcEndpoints.call(this, arc, pointById); +} + +function applyCircleDragKinematics(feature, dx = 0, dy = 0, local = null) { + return sketchGeom.applyCircleDragKinematics.call(this, feature, dx, dy, local); +} + +function projectPointOnArcConstraintsForArcs(feature, arcIds) { + return sketchGeom.projectPointOnArcConstraintsForArcs.call(this, feature, arcIds); +} + +function rebaseSketchDragState(feature, local) { + return sketchGeom.rebaseSketchDragState.call(this, feature, local); +} + +function sampleArcPolyline(arc, a, b, segments = 24) { + return sketchGeom.sampleArcPolyline.call(this, arc, a, b, segments); +} + +function distanceToSegmentPx(px, py, ax, ay, bx, by) { + return sketchGeom.distanceToSegmentPx.call(this, px, py, ax, ay, bx, by); +} + +function getEventViewportXY(event) { + return sketchGeom.getEventViewportXY.call(this, event); +} + +function getSketchBasis(feature) { + return sketchGeom.getSketchBasis.call(this, feature); +} + +function sketchLocalToWorld(local, basis) { + return sketchGeom.sketchLocalToWorld.call(this, local, basis); +} + +function worldToSketchLocal(world, basis) { + return sketchGeom.worldToSketchLocal.call(this, world, basis); +} + +function projectEventToSketchLocal(event, feature) { + return sketchGeom.projectEventToSketchLocal.call(this, event, feature); +} + +export { + getEditingSketchFeature, + isSketchEditing, + setSketchTool, + getSketchTool, + cancelSketchLine, + cancelSketchArc, + cancelSketchCircle, + cancelSketchRect, + clearSketchSelection, + getSelectedSketchMirrorAxis, + startSketchMirrorMode, + stopSketchMirrorMode, + getSelectedSketchPatternCenter, + startSketchCircularPatternMode, + stopSketchCircularPatternMode, + getSelectedSketchGridAnchor, + startSketchGridPatternMode, + stopSketchGridPatternMode, + editSketchCircularPatternConstraint, + editSketchGridPatternConstraint, + selectSketchConstraint, + setHoveredSketchConstraint, + useHoveredDerivedEdge, + useHoveredDerivedPoint, + handleSketchKeyDown, + applySketchConstraint, + editSketchDimensionConstraint, + toggleSketchDimensionMode, + findSketchConstraintInList, + toggleSketchConstraintInList, + normalizeConstraintRefs, + makeSketchConstraintKey, + handleSketchPointerDown, + handleSketchHover, + handleSketchPointerMove, + handleSketchMouseUp, + handleSketchDrag, + toggleSelectedConstruction, + updateSketchInteractionVisuals, + newSketchEntityId, + pointerDistance, + hitTestSketchEntity, + getSketchEntityHitFromIntersections, + resolveSketchHit, + resolveDerivedEdgeCandidate, + projectFaceBoundaryToSketch, + isSketchEventInViewport, + getSketchHitLocalPoint, + getSketchDragSnapTarget, + distanceToSegmentPx, + getEventViewportXY, + getSketchBasis, + sketchLocalToWorld, + worldToSketchLocal, + projectEventToSketchLocal, + viewportPointFromClient, + startSketchMarquee, + updateSketchMarquee, + finishSketchMarquee, + clearSketchMarquee, + updateSketchMarqueeVisual, + selectSketchEntitiesInMarquee, + projectSketchLocalToScreen, + isPointInRect, + segmentTouchesRect, + segmentsIntersect, + collectSelectedCoordinateRefs, + collectCoordinateRefsFromIds, + isPointOnSelectedSketchLine, + createSketchArc, + createSketchArcFromCenter, + createSketchCircle, + createSketchCircle3Point, + createSketchRectangle, + makeSketchRectPreview, + getRectangleCorners, + findArcWithEndpoints, + convertArcToCircle, + convertArcToCircleInSketch, + computeArcGeometry, + computeArcGeometryFromCenter, + computeCircleFromThreePoints, + getArcEndpoints, + applyCircleDragKinematics, + projectPointOnArcConstraintsForArcs, + collectDragLockedArcCenters, + applyDragLockedArcCenters, + draggedArcsHaveTangent, + rebaseSketchDragState, + getArcCenterLocalFromEntity, + sampleArcPolyline, + createSketchPoint, + createSketchLine, + createDerivedSketchPoint, + createDerivedSketchLine, + deriveSelectionsAtomic, + refreshDerivedSketchGeometry, + createSketchPolygonFromSelectedCircle, + mirrorSelectedSketchGeometry, + circularPatternSelectedSketchGeometry, + updateCircularPatternConstraintCopies, + gridPatternSelectedSketchGeometry, + updateGridPatternConstraintCopies, + deleteSelectedSketchEntities, + deleteSelectedSketchConstraints, + findPointByCoord, + ensureSketchPoint, + getLineEndpoints, + getSelectedSketchCircle, + getCircleData +}; diff --git a/src/void/sketch/marquee.js b/src/void/sketch/marquee.js new file mode 100644 index 00000000..15948157 --- /dev/null +++ b/src/void/sketch/marquee.js @@ -0,0 +1,275 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { space } from '../../moto/space.js'; +import { api } from '../api.js'; + +function startSketchMarquee(feature, pointerDown, event) { + const start = this.viewportPointFromClient(pointerDown?.clientX, pointerDown?.clientY); + const end = this.getEventViewportXY(event); + if (!start || !end) { + return; + } + this.sketchMarquee = { + featureId: feature?.id || null, + startX: start.x, + startY: start.y, + endX: end.x, + endY: end.y, + mode: end.x >= start.x ? 'window' : 'cross' + }; + this.updateSketchMarqueeVisual(); +} + +function updateSketchMarquee(event) { + if (!this.sketchMarquee) { + return; + } + const end = this.getEventViewportXY(event); + if (!end) { + return; + } + this.sketchMarquee.endX = end.x; + this.sketchMarquee.endY = end.y; + this.sketchMarquee.mode = end.x >= this.sketchMarquee.startX ? 'window' : 'cross'; + this.updateSketchMarqueeVisual(); +} + +function finishSketchMarquee(feature) { + if (!this.sketchMarquee) { + return; + } + const marquee = this.sketchMarquee; + this.clearSketchMarquee(); + const selectIds = this.selectSketchEntitiesInMarquee(feature, marquee); + this.selectedSketchEntities = new Set(selectIds); + this.selectedSketchArcCenters?.clear?.(); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); +} + +function clearSketchMarquee() { + this.sketchMarquee = null; + if (this.sketchMarqueeEl?.parentElement) { + this.sketchMarqueeEl.parentElement.removeChild(this.sketchMarqueeEl); + } + this.sketchMarqueeEl = null; +} + +function updateSketchMarqueeVisual() { + const marquee = this.sketchMarquee; + if (!marquee) { + this.clearSketchMarquee(); + return; + } + const { container } = space.internals(); + if (!container) { + return; + } + if (!this.sketchMarqueeEl) { + const el = document.createElement('div'); + el.className = 'sketch-marquee sketch-marquee-window'; + container.appendChild(el); + this.sketchMarqueeEl = el; + } + const left = Math.min(marquee.startX, marquee.endX); + const top = Math.min(marquee.startY, marquee.endY); + const width = Math.abs(marquee.endX - marquee.startX); + const height = Math.abs(marquee.endY - marquee.startY); + this.sketchMarqueeEl.className = `sketch-marquee ${marquee.mode === 'cross' ? 'sketch-marquee-cross' : 'sketch-marquee-window'}`; + this.sketchMarqueeEl.style.left = `${left}px`; + this.sketchMarqueeEl.style.top = `${top}px`; + this.sketchMarqueeEl.style.width = `${width}px`; + this.sketchMarqueeEl.style.height = `${height}px`; +} + +function viewportPointFromClient(clientX, clientY) { + const { container } = space.internals(); + if (!container) { + return null; + } + const rect = container.getBoundingClientRect(); + return { + x: (clientX || 0) - rect.left, + y: (clientY || 0) - rect.top + }; +} + +function selectSketchEntitiesInMarquee(feature, marquee) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const basis = this.getSketchBasis(feature); + if (!basis) { + return []; + } + const minX = Math.min(marquee.startX, marquee.endX); + const maxX = Math.max(marquee.startX, marquee.endX); + const minY = Math.min(marquee.startY, marquee.endY); + const maxY = Math.max(marquee.startY, marquee.endY); + const rect = { minX, maxX, minY, maxY }; + const isWindow = marquee.mode !== 'cross'; + + const out = []; + const pointById = new Map(); + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + pointById.set(entity.id, entity); + } + } + for (const entity of entities) { + if (!entity?.id) continue; + if (entity.type === 'point') { + const p = this.projectSketchLocalToScreen({ x: entity.x || 0, y: entity.y || 0 }, basis); + if (!p) continue; + if (this.isPointInRect(p.x, p.y, rect)) { + out.push(entity.id); + } + continue; + } + if (entity.type === 'line') { + const [a, b] = this.getLineEndpoints(entity, pointById); + if (!a || !b) continue; + const pa = this.projectSketchLocalToScreen({ x: a.x || 0, y: a.y || 0 }, basis); + const pb = this.projectSketchLocalToScreen({ x: b.x || 0, y: b.y || 0 }, basis); + if (!pa || !pb) continue; + const hit = isWindow + ? (this.isPointInRect(pa.x, pa.y, rect) && this.isPointInRect(pb.x, pb.y, rect)) + : this.segmentTouchesRect(pa, pb, rect); + if (hit) { + out.push(entity.id); + } + continue; + } + if (entity.type === 'arc') { + const [a, b] = this.getArcEndpoints(entity, pointById); + if (!a || !b) continue; + const sample = this.sampleArcPolyline(entity, a, b, 28); + if (!sample.length) continue; + const screen = sample + .map(local => this.projectSketchLocalToScreen(local, basis)) + .filter(Boolean); + if (screen.length < 2) continue; + let hit = false; + if (isWindow) { + hit = screen.every(p => this.isPointInRect(p.x, p.y, rect)); + } else { + for (let i = 0; i < screen.length - 1 && !hit; i++) { + if (this.segmentTouchesRect(screen[i], screen[i + 1], rect)) { + hit = true; + } + } + } + if (hit) { + out.push(entity.id); + } + } + } + return out; +} + +function projectSketchLocalToScreen(local, basis) { + const world = this.sketchLocalToWorld(local, basis); + const proj = api.overlay.project3Dto2D(world); + if (!proj?.visible) { + return null; + } + return { x: proj.x, y: proj.y }; +} + +function isPointInRect(x, y, rect) { + return x >= rect.minX && x <= rect.maxX && y >= rect.minY && y <= rect.maxY; +} + +function segmentTouchesRect(a, b, rect) { + if (this.isPointInRect(a.x, a.y, rect) || this.isPointInRect(b.x, b.y, rect)) { + return true; + } + const edges = [ + [{ x: rect.minX, y: rect.minY }, { x: rect.maxX, y: rect.minY }], + [{ x: rect.maxX, y: rect.minY }, { x: rect.maxX, y: rect.maxY }], + [{ x: rect.maxX, y: rect.maxY }, { x: rect.minX, y: rect.maxY }], + [{ x: rect.minX, y: rect.maxY }, { x: rect.minX, y: rect.minY }] + ]; + for (const [c, d] of edges) { + if (this.segmentsIntersect(a, b, c, d)) { + return true; + } + } + return false; +} + +function segmentsIntersect(a, b, c, d) { + const orient = (p, q, r) => (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + const onSeg = (p, q, r) => + Math.min(p.x, r.x) <= q.x && q.x <= Math.max(p.x, r.x) && + Math.min(p.y, r.y) <= q.y && q.y <= Math.max(p.y, r.y); + + const o1 = orient(a, b, c); + const o2 = orient(a, b, d); + const o3 = orient(c, d, a); + const o4 = orient(c, d, b); + + if ((o1 > 0) !== (o2 > 0) && (o3 > 0) !== (o4 > 0)) { + return true; + } + if (Math.abs(o1) < 1e-9 && onSeg(a, c, b)) return true; + if (Math.abs(o2) < 1e-9 && onSeg(a, d, b)) return true; + if (Math.abs(o3) < 1e-9 && onSeg(c, a, d)) return true; + if (Math.abs(o4) < 1e-9 && onSeg(c, b, d)) return true; + return false; +} + +function collectSelectedCoordinateRefs(feature) { + return this.collectCoordinateRefsFromIds(feature, this.selectedSketchEntities); +} + +function collectCoordinateRefsFromIds(feature, selectedIds) { + const refs = new Set(); + const entities = Array.isArray(feature.entities) ? feature.entities : []; + const pointById = new Map(); + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + pointById.set(entity.id, entity); + } + } + for (const entity of entities) { + if (!selectedIds?.has(entity.id)) { + continue; + } + if (entity.type === 'point') { + refs.add(entity); + continue; + } + if (entity.type === 'line') { + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if (aId && pointById.has(aId)) refs.add(pointById.get(aId)); + if (bId && pointById.has(bId)) refs.add(pointById.get(bId)); + } + if (entity.type === 'arc') { + const aId = typeof entity?.a === 'string' ? entity.a : null; + const bId = typeof entity?.b === 'string' ? entity.b : null; + if (aId && pointById.has(aId)) refs.add(pointById.get(aId)); + if (bId && pointById.has(bId)) refs.add(pointById.get(bId)); + const threePointIds = Array.isArray(entity?.data?.threePointIds) ? entity.data.threePointIds : []; + for (const pid of threePointIds) { + if (pid && pointById.has(pid)) refs.add(pointById.get(pid)); + } + } + } + return Array.from(refs); +} + +export { + startSketchMarquee, + updateSketchMarquee, + finishSketchMarquee, + clearSketchMarquee, + updateSketchMarqueeVisual, + viewportPointFromClient, + selectSketchEntitiesInMarquee, + projectSketchLocalToScreen, + isPointInRect, + segmentTouchesRect, + segmentsIntersect, + collectSelectedCoordinateRefs, + collectCoordinateRefsFromIds +}; diff --git a/src/void/sketch/pointer.js b/src/void/sketch/pointer.js new file mode 100644 index 00000000..f2dd7350 --- /dev/null +++ b/src/void/sketch/pointer.js @@ -0,0 +1,1226 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from '../api.js'; +import { enforceSketchConstraintsInPlace } from './constraints.js'; +import * as sketchCreate from './create.js'; +import { isCircleCurve, isCenterPointCircle, isThreePointCircle } from './curve.js'; +import { + SKETCH_DRAG_START_PX, + SKETCH_MIN_LINE_LENGTH, + SKETCH_VIRTUAL_ORIGIN_ID +} from './constants.js'; + +function normalizeArcCenterRefId(type, id) { + if (type !== 'arc-center' || typeof id !== 'string' || !id) return id || null; + return id.startsWith('arc-center:') ? id : `arc-center:${id}`; +} + +function collectArcCenterCoincidentPointIds(feature, arcId) { + if (!feature || !arcId) return []; + const constraints = Array.isArray(feature.constraints) ? feature.constraints : []; + const out = []; + for (const c of constraints) { + if (c?.type !== 'arc_center_coincident') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + if (refs.length < 2) continue; + if (refs[0] !== arcId) continue; + if (typeof refs[1] === 'string' && refs[1]) out.push(refs[1]); + } + return out; +} + +function getSketchHitTypeById(feature, id) { + if (!id) return null; + if (id === SKETCH_VIRTUAL_ORIGIN_ID) return 'point'; + if (typeof id === 'string' && id.startsWith('arc-center:')) return 'arc-center'; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const entity = entities.find(item => item?.id === id) || null; + if (!entity) return null; + if (entity.type === 'point') return 'point'; + if (entity.type === 'line') return 'line'; + if (entity.type === 'arc') return 'arc'; + return null; +} + +function handleSketchPointerDown(event, intersections) { + const feature = this.getEditingSketchFeature(); + if (!feature) { + return false; + } + + this.sketchPointerSeq = (this.sketchPointerSeq || 0) + 1; + const seq = this.sketchPointerSeq; + const local = this.projectEventToSketchLocal(event, feature); + const hit = this.resolveSketchHit(event, intersections, feature); + const hoveredId = this.hoveredSketchEntityId || null; + const hoveredType = getSketchHitTypeById(feature, hoveredId); + const preferHovered = this.getSketchTool() === 'select' + && hoveredId + && hoveredId !== SKETCH_VIRTUAL_ORIGIN_ID + && hoveredType; + const resolvedHit = preferHovered ? { id: hoveredId, type: hoveredType } : hit; + const hitLocal = this.getSketchHitLocalPoint(feature, resolvedHit); + + this.sketchPointerDown = { + seq, + local, + hitId: resolvedHit?.id || hoveredId || null, + hitType: resolvedHit?.type || hoveredType || null, + hoveredHitId: hoveredId || null, + hoveredHitType: hoveredType || null, + clientX: event?.clientX ?? 0, + clientY: event?.clientY ?? 0 + }; + + const tool = this.getSketchTool(); + const isArcThreePoint = tool === 'arc' || tool === 'arc-3pt' || tool === 'arc-tangent'; + const isCircleCenter = tool === 'circle' || tool === 'circle-center'; + const isCircleThreePoint = tool === 'circle-3pt'; + + if (tool === 'line' && !this.sketchLineStart) { + const start = hitLocal || local; + if (!start) { + return true; + } + this.sketchLineStart = start; + this.sketchLineStartRefId = ((resolvedHit?.type === 'point' || resolvedHit?.type === 'arc-center') && resolvedHit?.id && resolvedHit.id !== SKETCH_VIRTUAL_ORIGIN_ID) + ? normalizeArcCenterRefId(resolvedHit.type, resolvedHit.id) + : null; + this.sketchLineStartSeq = seq; + this.sketchLinePreview = { a: start, b: start }; + this.updateSketchInteractionVisuals(); + } + if ((this.getSketchTool() === 'rect' || this.getSketchTool() === 'rect-center') && !this.sketchRectStart) { + const start = hitLocal || local; + if (!start) { + return true; + } + this.sketchRectStart = start; + this.sketchRectStartRefId = (resolvedHit?.type === 'point' && resolvedHit?.id && resolvedHit.id !== SKETCH_VIRTUAL_ORIGIN_ID) ? resolvedHit.id : null; + this.sketchRectStartSeq = seq; + this.sketchRectPreview = this.makeSketchRectPreview(start, start, this.getSketchTool() === 'rect-center'); + this.updateSketchInteractionVisuals(); + } + if ((isArcThreePoint) && !this.sketchArcStart) { + // no-op: first click handled in mouse-up for click/click workflow + } + + if (isCircleCenter && !this.sketchCircleCenter) { + const start = hitLocal || local; + if (!start) { + return true; + } + this.sketchCircleCenter = start; + this.sketchCircleCenterRefId = null; + this.sketchCircleSecond = null; + this.sketchCircleStartSeq = seq; + this.updateSketchInteractionVisuals(); + } + + if (isCircleThreePoint && !this.sketchCircleCenter) { + // no-op: click sequence handled in mouse-up + } + return true; +} + +function handleSketchHover(event, intersections) { + const feature = this.getEditingSketchFeature(); + if (!feature) { + return false; + } + + if (this.sketchDrag) { + return true; + } + + const tool = this.getSketchTool(); + const isArcThreePoint = tool === 'arc' || tool === 'arc-3pt' || tool === 'arc-tangent'; + const isArcCenterPoint = tool === 'arc-center'; + const isCircleCenter = tool === 'circle' || tool === 'circle-center'; + const isCircleThreePoint = tool === 'circle-3pt'; + let previewChanged = false; + if (tool === 'line' && this.sketchLineStart) { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + const next = local ? { a: this.sketchLineStart, b: local } : null; + const prev = this.sketchLinePreview; + const same = !!(prev && next + && prev.a && next.a + && prev.b && next.b + && prev.a.x === next.a.x + && prev.a.y === next.a.y + && prev.b.x === next.b.x + && prev.b.y === next.b.y); + if (!same) { + this.sketchLinePreview = next; + previewChanged = true; + } + } else if (this.sketchLinePreview !== null) { + this.sketchLinePreview = null; + previewChanged = true; + } + + if (isArcThreePoint) { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + let nextArc = null; + if (this.sketchArcStart && !this.sketchArcEnd && local) { + nextArc = { mode: 'chord', a: this.sketchArcStart, b: local }; + } else if (this.sketchArcStart && this.sketchArcEnd && local) { + const geom = this.computeArcGeometry(this.sketchArcStart, this.sketchArcEnd, local); + if (geom) nextArc = { mode: 'arc', a: this.sketchArcStart, b: this.sketchArcEnd, ...geom }; + } + const sameArc = JSON.stringify(this.sketchArcPreview || null) === JSON.stringify(nextArc || null); + if (!sameArc) { + this.sketchArcPreview = nextArc; + previewChanged = true; + } + } else if (isArcCenterPoint) { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + let nextArc = null; + if (this.sketchArcStart && !this.sketchArcEnd && local) { + nextArc = { mode: 'chord', a: this.sketchArcStart, b: local }; + } else if (this.sketchArcStart && this.sketchArcEnd && local) { + const geom = this.computeArcGeometryFromCenter(this.sketchArcStart, this.sketchArcEnd, local); + if (geom) { + const arc = this.computeArcGeometry(geom.start, geom.end, geom.onArc); + if (arc) nextArc = { mode: 'arc', a: geom.start, b: geom.end, ...arc }; + } + } + const sameArc = JSON.stringify(this.sketchArcPreview || null) === JSON.stringify(nextArc || null); + if (!sameArc) { + this.sketchArcPreview = nextArc; + previewChanged = true; + } + } else if (this.sketchArcPreview !== null) { + this.sketchArcPreview = null; + previewChanged = true; + } + + if (isCircleCenter) { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + let nextArc = null; + if (this.sketchCircleCenter && local) { + const radius = Math.hypot((local.x || 0) - (this.sketchCircleCenter.x || 0), (local.y || 0) - (this.sketchCircleCenter.y || 0)); + if (radius > SKETCH_MIN_LINE_LENGTH) { + nextArc = { + mode: 'circle', + circle: true, + cx: this.sketchCircleCenter.x || 0, + cy: this.sketchCircleCenter.y || 0, + radius + }; + } + } + const sameArc = JSON.stringify(this.sketchArcPreview || null) === JSON.stringify(nextArc || null); + if (!sameArc) { + this.sketchArcPreview = nextArc; + previewChanged = true; + } + } else if (isCircleThreePoint) { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + let nextArc = null; + if (this.sketchCircleCenter && this.sketchCircleSecond && local) { + const circle = this.computeCircleFromThreePoints(this.sketchCircleCenter, this.sketchCircleSecond, local); + if (circle && circle.radius > SKETCH_MIN_LINE_LENGTH) { + nextArc = { + mode: 'circle', + circle: true, + cx: circle.cx, + cy: circle.cy, + radius: circle.radius + }; + } + } + const sameArc = JSON.stringify(this.sketchArcPreview || null) === JSON.stringify(nextArc || null); + if (!sameArc) { + this.sketchArcPreview = nextArc; + previewChanged = true; + } + } + if (tool === 'rect' || tool === 'rect-center') { + const local = event ? this.projectEventToSketchLocal(event, feature) : null; + let nextRect = null; + if (this.sketchRectStart && local) { + nextRect = this.makeSketchRectPreview(this.sketchRectStart, local, tool === 'rect-center'); + } + const sameRect = JSON.stringify(this.sketchRectPreview || null) === JSON.stringify(nextRect || null); + if (!sameRect) { + this.sketchRectPreview = nextRect; + previewChanged = true; + } + } else if (this.sketchRectPreview !== null) { + this.sketchRectPreview = null; + previewChanged = true; + } + + const hit = this.resolveSketchHit(event, intersections, feature); + const hasIntersections = Array.isArray(intersections) && intersections.length > 0; + const primary = hasIntersections + ? (this.getPrimarySurfaceHitFromIntersections?.(intersections) || null) + : null; + let derived = null; + if (hasIntersections) { + // Resolve from actual edge-distance each frame instead of trusting + // primary hit type ordering, which can be unstable across reloads. + derived = this.resolveDerivedEdgeCandidate(event, intersections, feature); + } + if (hit?.id) { + // Current sketch entities take priority over any behind-surface derive targets. + derived = null; + if (this.hoveredSolidFaceKey) { + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + // Force immediate preview refresh so face boundaries disappear as + // soon as a sketch entity takes hover priority. + previewChanged = true; + } + } + if (this.hoveredSolidFaceKey) { + // In sketch mode boundary/projection previews replace solid-face fill hover. + api.solids?.setHoveredFace?.(null); + } + const prevDerived = this.hoveredDerivedCandidate || null; + this.hoveredDerivedCandidate = derived || null; + const derivedChanged = (!!prevDerived) !== (!!derived) + || (prevDerived?.solidId !== derived?.solidId) + || (prevDerived?.index !== derived?.index) + || (prevDerived?.hoverPoint?.kind !== derived?.hoverPoint?.kind); + const hoveredId = hit ? hit.id : null; + if (this.hoveredSketchEntityId !== hoveredId || previewChanged || derivedChanged) { + this.hoveredSketchEntityId = hoveredId; + this.updateSketchInteractionVisuals(); + } + return true; +} + +function handleSketchPointerMove(event) { + const feature = this.getEditingSketchFeature(); + if (!feature || this.getSketchTool() !== 'select') return false; + if (!this.sketchPointerDown) return false; + if (!(event?.buttons & 1)) return false; + if (this.sketchDrag) return false; + if (this.pointerDistance(event, this.sketchPointerDown) < SKETCH_DRAG_START_PX) return false; + const downId = this.sketchPointerDown.hitId + || this.sketchPointerDown.hoveredHitId + || this.hoveredSketchEntityId + || null; + if (downId && downId !== SKETCH_VIRTUAL_ORIGIN_ID) return false; + if (!this.sketchMarquee) this.startSketchMarquee(feature, this.sketchPointerDown, event); + else this.updateSketchMarquee(event); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function handleSketchMouseUp(event, intersections) { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const tool = this.getSketchTool(); + const isArcThreePoint = tool === 'arc' || tool === 'arc-3pt' || tool === 'arc-tangent'; + const isArcCenterPoint = tool === 'arc-center'; + const isCircleCenter = tool === 'circle' || tool === 'circle-center'; + const isCircleThreePoint = tool === 'circle-3pt'; + const allowOutsideViewport = (isCircleCenter || isCircleThreePoint) && !!this.sketchCircleCenter; + if (!allowOutsideViewport && !this.isSketchEventInViewport(event)) return false; + if (this.sketchMarquee) { + this.finishSketchMarquee(feature); + return true; + } + + const pointerDown = this.sketchPointerDown; + const dist = pointerDown ? this.pointerDistance(event, pointerDown) : 0; + const wasDrag = !!this.sketchDrag; + if (wasDrag) return true; + + if (tool === 'select') { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const entityById = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const mirrorMode = !!this.sketchMirrorMode; + const mirrorAxisId = this.sketchMirrorAxisId || null; + const circularMode = !!this.sketchCircularPatternMode; + const circularCenterRef = this.sketchCircularPatternCenterRef || null; + const gridMode = !!this.sketchGridPatternMode; + const gridCenterRef = this.sketchGridPatternCenterRef || null; + const upHit = this.resolveSketchHit(event, intersections, feature); + const hit = upHit + || (pointerDown?.hitId ? { id: pointerDown.hitId, type: pointerDown?.hitType || null } : null) + || (pointerDown?.hoveredHitId ? { id: pointerDown.hoveredHitId, type: pointerDown?.hoveredHitType || null } : null) + || (this.hoveredSketchEntityId ? { id: this.hoveredSketchEntityId } : null); + if (hit?.id) { + if (dist > SKETCH_DRAG_START_PX && pointerDown?.hitId) { + // Intended drag gesture that failed to initialize; do not toggle select on mouse-up. + return true; + } + const isArcCenter = hit.type === 'arc-center'; + const arcCenterEntityId = isArcCenter + ? (String(hit.id).startsWith('arc-center:') ? String(hit.id).substring('arc-center:'.length) : String(hit.id)) + : null; + const entitySelectId = isArcCenter ? hit.id : hit.id; + if (this.selectedSketchEntities.has(entitySelectId)) { + this.selectedSketchEntities.delete(entitySelectId); + if (arcCenterEntityId) this.selectedSketchArcCenters?.delete?.(arcCenterEntityId); + } else { + this.selectedSketchEntities.add(entitySelectId); + if (arcCenterEntityId) this.selectedSketchArcCenters?.add?.(arcCenterEntityId); + } + const sourceRefId = isArcCenter ? `arc-center:${arcCenterEntityId}` : entitySelectId; + if (mirrorMode && mirrorAxisId) { + const sourceId = (typeof sourceRefId === 'string' && sourceRefId.startsWith('arc-center:')) + ? sourceRefId.substring('arc-center:'.length) + : sourceRefId; + const sourceEntity = entityById.get(sourceId) || null; + const patternable = sourceEntity && (sourceEntity.type === 'line' || sourceEntity.type === 'arc'); + if (sourceId === mirrorAxisId) { + this.selectedSketchEntities.add(mirrorAxisId); + } else if (patternable && typeof sourceId === 'string' && sourceId && this.selectedSketchEntities.has(entitySelectId)) { + const mirrored = this.mirrorSelectedSketchGeometry?.({ + axisId: mirrorAxisId, + sourceIds: [sourceId], + keepResultSelected: false + }); + if (mirrored) { + this.selectedSketchEntities.delete(entitySelectId); + this.selectedSketchArcCenters?.delete?.(sourceId); + } + } + } else if (circularMode && circularCenterRef) { + const centerMatch = sourceRefId === circularCenterRef + || (typeof circularCenterRef === 'string' && circularCenterRef.startsWith('arc-center:') + && sourceRefId === circularCenterRef.substring('arc-center:'.length)) + || (typeof sourceRefId === 'string' && sourceRefId.startsWith('arc-center:') + && sourceRefId.substring('arc-center:'.length) === circularCenterRef); + const sourceId = (typeof sourceRefId === 'string' && sourceRefId.startsWith('arc-center:')) + ? null + : sourceRefId; + const sourceEntity = sourceId ? (entityById.get(sourceId) || null) : null; + const patternable = sourceEntity && (sourceEntity.type === 'line' || sourceEntity.type === 'arc'); + if (!centerMatch && patternable && typeof sourceId === 'string' && sourceId && this.selectedSketchEntities.has(entitySelectId)) { + const patterned = this.circularPatternSelectedSketchGeometry?.({ + centerRef: circularCenterRef, + sourceIds: [sourceId], + keepResultSelected: false + }); + if (patterned) { + this.selectedSketchEntities.delete(entitySelectId); + this.selectedSketchArcCenters?.delete?.(sourceId); + } + } + } else if (gridMode && gridCenterRef) { + const centerMatch = sourceRefId === gridCenterRef; + const sourceId = (typeof sourceRefId === 'string' && sourceRefId.startsWith('arc-center:')) + ? null + : sourceRefId; + const sourceEntity = sourceId ? (entityById.get(sourceId) || null) : null; + const patternable = sourceEntity && (sourceEntity.type === 'line' || sourceEntity.type === 'arc'); + if (!centerMatch && patternable && typeof sourceId === 'string' && sourceId && this.selectedSketchEntities.has(entitySelectId)) { + const patterned = this.gridPatternSelectedSketchGeometry?.({ + centerRef: gridCenterRef, + sourceIds: [sourceId], + keepResultSelected: false + }); + if (patterned) { + this.selectedSketchEntities.delete(entitySelectId); + this.selectedSketchArcCenters?.delete?.(sourceId); + } + } + } + } else { + const derived = this.hoveredDerivedCandidate || this.resolveDerivedEdgeCandidate(event, intersections, feature); + if (derived?.aLocal && derived?.bLocal) { + const multi = !!(event?.ctrlKey || event?.metaKey || event?.shiftKey); + if (!multi) { + this.selectedDerivedSelections?.clear?.(); + this.selectedSolidFaceKeys?.clear?.(); + api.solids?.clearFaceSelection?.(); + } + const pointKind = derived?.hoverPoint?.kind || null; + if (pointKind && derived?.hoverPoint?.local) { + const sourceEntityId = String(derived?.source?.entity?.id || ''); + const key = sourceEntityId + ? `point:${sourceEntityId}:${pointKind}` + : `point:${derived?.solidId || ''}:${derived?.index ?? -1}:${pointKind}`; + if (this.selectedDerivedSelections?.has?.(key)) { + this.selectedDerivedSelections.delete(key); + } else { + this.selectedDerivedSelections?.set?.(key, { + type: 'point', + local: derived.hoverPoint.local, + source: { + ...(derived.source || {}), + local_point: null, + point_kind: pointKind || 'mid' + } + }); + } + } else { + const sourceEntityId = String(derived?.source?.entity?.id || ''); + const key = sourceEntityId + ? `edge:${sourceEntityId}` + : `edge:${derived?.solidId || ''}:${derived?.index ?? -1}`; + if (this.selectedDerivedSelections?.has?.(key)) { + this.selectedDerivedSelections.delete(key); + } else { + this.selectedDerivedSelections?.set?.(key, { + type: 'edge', + aLocal: derived.aLocal, + bLocal: derived.bLocal, + source: derived.source || null + }); + } + } + this.updateSketchInteractionVisuals(); + return true; + } + if (this.hoveredSolidFaceKey) { + const multi = !!(event?.ctrlKey || event?.metaKey || event?.shiftKey); + if (!multi) { + this.selectedDerivedSelections?.clear?.(); + } + const selected = api.solids?.toggleSelectedFace?.(this.hoveredSolidFaceKey, multi) || []; + this.selectedSolidFaceKeys = new Set(selected); + this.updateSketchInteractionVisuals(); + return true; + } + const profileHit = this.getSketchProfileHitFromIntersections?.(intersections || []); + if (profileHit) { + this.selectSketchProfile?.(profileHit, event); + return true; + } + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedDerivedSelections?.clear?.(); + this.selectedSolidFaceKeys?.clear?.(); + api.solids?.clearFaceSelection?.(); + } + this.updateSketchInteractionVisuals(); + return true; + } + + if (tool === 'point') { + if (dist > SKETCH_DRAG_START_PX) return true; + const local = this.projectEventToSketchLocal(event, feature); + if (!local) return true; + this.createSketchPoint(feature, local); + return true; + } + + if (tool === 'line') { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const local = this.getSketchHitLocalPoint(feature, resolved) || this.projectEventToSketchLocal(event, feature); + const endRefId = ((resolved?.type === 'point' || resolved?.type === 'arc-center') && resolved?.id && resolved.id !== SKETCH_VIRTUAL_ORIGIN_ID) + ? normalizeArcCenterRefId(resolved.type, resolved.id) + : null; + if (!local || !this.sketchLineStart) return true; + if (this.sketchLineStartSeq === pointerDown?.seq) { + if (dist > SKETCH_DRAG_START_PX) { + this.createSketchLine(feature, this.sketchLineStart, local, { startRefId: this.sketchLineStartRefId || null, endRefId }); + this.cancelSketchLine(); + } + return true; + } + const created = this.createSketchLine(feature, this.sketchLineStart, local, { startRefId: this.sketchLineStartRefId || null, endRefId }); + this.sketchLineStart = { x: local.x, y: local.y }; + this.sketchLineStartRefId = endRefId || created?.endPointId || null; + this.sketchLineStartSeq = null; + return true; + } + + if (isArcThreePoint) { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const local = this.getSketchHitLocalPoint(feature, resolved) || this.projectEventToSketchLocal(event, feature); + const refId = (resolved?.type === 'point' && resolved?.id && resolved.id !== SKETCH_VIRTUAL_ORIGIN_ID) ? resolved.id : null; + if (!local) return true; + if (!this.sketchArcStart) { + const downLocal = pointerDown?.local; + const downRefId = (pointerDown?.hitId && pointerDown.hitId !== SKETCH_VIRTUAL_ORIGIN_ID) ? pointerDown.hitId : null; + if (downLocal && dist > SKETCH_DRAG_START_PX) { + this.sketchArcStart = { x: downLocal.x, y: downLocal.y }; + this.sketchArcStartRefId = downRefId; + this.sketchArcEnd = { x: local.x, y: local.y }; + this.sketchArcEndRefId = refId; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return true; + } + this.sketchArcStart = { x: local.x, y: local.y }; + this.sketchArcStartRefId = refId; + this.sketchArcEnd = null; + this.sketchArcEndRefId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return true; + } + if (!this.sketchArcEnd) { + this.sketchArcEnd = { x: local.x, y: local.y }; + this.sketchArcEndRefId = refId; + this.updateSketchInteractionVisuals(); + return true; + } + const created = this.createSketchArc(feature, this.sketchArcStart, this.sketchArcEnd, local, { + startRefId: this.sketchArcStartRefId || null, + endRefId: this.sketchArcEndRefId || null, + variant: tool === 'arc-tangent' ? 'arc-tangent' : 'arc-3pt' + }); + if (created) { + this.cancelSketchArc(); + } + return true; + } + + if (isArcCenterPoint) { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const local = this.getSketchHitLocalPoint(feature, resolved) || this.projectEventToSketchLocal(event, feature); + const refId = (resolved?.type === 'point' && resolved?.id && resolved.id !== SKETCH_VIRTUAL_ORIGIN_ID) ? resolved.id : null; + if (!local) return true; + if (!this.sketchArcStart) { + this.sketchArcStart = { x: local.x, y: local.y }; // center + this.sketchArcStartRefId = refId; + this.sketchArcEnd = null; + this.sketchArcEndRefId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return true; + } + if (!this.sketchArcEnd) { + this.sketchArcEnd = { x: local.x, y: local.y }; + this.sketchArcEndRefId = refId; + this.updateSketchInteractionVisuals(); + return true; + } + const created = this.createSketchArcFromCenter(feature, this.sketchArcStart, this.sketchArcEnd, local, { + startRefId: this.sketchArcEndRefId || null, + endRefId: refId || null + }); + if (created) { + this.cancelSketchArc(); + } + return true; + } + + if (isCircleCenter) { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const unsnappedLocal = this.projectEventToSketchLocal(event, feature); + const snappedLocal = this.getSketchHitLocalPoint(feature, resolved) || unsnappedLocal; + if (!this.sketchCircleCenter) return true; + let end = unsnappedLocal || snappedLocal; + if (!end && this.sketchArcPreview?.mode === 'circle') { + end = { + x: (this.sketchArcPreview.cx || 0) + (this.sketchArcPreview.radius || 0), + y: this.sketchArcPreview.cy || 0 + }; + } + if (!end) return true; + const radial = Math.hypot((end.x || 0) - (this.sketchCircleCenter.x || 0), (end.y || 0) - (this.sketchCircleCenter.y || 0)); + if (!Number.isFinite(radial) || radial <= SKETCH_MIN_LINE_LENGTH) return true; + const created = this.createSketchCircle(feature, this.sketchCircleCenter, end, { + centerRefId: this.sketchCircleCenterRefId || null + }); + if (created) { + this.cancelSketchCircle(); + } + return true; + } + + if (isCircleThreePoint) { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const local = this.getSketchHitLocalPoint(feature, resolved) || this.projectEventToSketchLocal(event, feature); + const refId = (resolved?.type === 'point' && resolved?.id && resolved.id !== SKETCH_VIRTUAL_ORIGIN_ID) ? resolved.id : null; + if (!local) return true; + if (!this.sketchCircleCenter) { + this.sketchCircleCenter = { x: local.x, y: local.y }; + this.sketchCircleCenterRefId = refId; + this.sketchCircleSecond = null; + this.sketchCircleSecondRefId = null; + this.sketchArcPreview = null; + this.updateSketchInteractionVisuals(); + return true; + } + if (!this.sketchCircleSecond) { + this.sketchCircleSecond = { x: local.x, y: local.y }; + this.sketchCircleSecondRefId = refId; + this.updateSketchInteractionVisuals(); + return true; + } + const created = this.createSketchCircle3Point(feature, this.sketchCircleCenter, this.sketchCircleSecond, local, { + pointRefIds: [ + this.sketchCircleCenterRefId || null, + this.sketchCircleSecondRefId || null, + refId || null + ] + }); + if (created) { + this.cancelSketchCircle(); + } + return true; + } + + if (tool === 'rect' || tool === 'rect-center') { + const upHit = this.resolveSketchHit(event, intersections, feature); + const fallbackHovered = this.hoveredSketchEntityId && this.hoveredSketchEntityId !== SKETCH_VIRTUAL_ORIGIN_ID ? { id: this.hoveredSketchEntityId, type: 'point' } : null; + const resolved = upHit || fallbackHovered; + const local = this.getSketchHitLocalPoint(feature, resolved) || this.projectEventToSketchLocal(event, feature); + const endRefId = (resolved?.type === 'point' && resolved?.id && resolved.id !== SKETCH_VIRTUAL_ORIGIN_ID) ? resolved.id : null; + if (!local || !this.sketchRectStart) return true; + const centerMode = tool === 'rect-center'; + if (this.sketchRectStartSeq === pointerDown?.seq) { + if (dist > SKETCH_DRAG_START_PX) { + this.createSketchRectangle(feature, this.sketchRectStart, local, { centerMode, startRefId: this.sketchRectStartRefId || null, endRefId }); + this.cancelSketchRect(); + } + return true; + } + const created = this.createSketchRectangle(feature, this.sketchRectStart, local, { + centerMode, + startRefId: this.sketchRectStartRefId || null, + endRefId + }); + if (created) { + this.cancelSketchRect(); + } + return true; + } + + return true; +} + +function handleSketchDrag(delta, offset, isDone) { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const tool = this.getSketchTool(); + const isCircleCenter = tool === 'circle' || tool === 'circle-center'; + + if (tool === 'line') { + if (!isDone) { + const local = this.projectEventToSketchLocal(delta?.event, feature); + if (this.sketchLineStart && local) { + this.sketchLinePreview = { a: this.sketchLineStart, b: local }; + this.updateSketchInteractionVisuals(); + } + return true; + } + if (!this.sketchLineStart || this.sketchLineStartSeq !== this.sketchPointerDown?.seq) { + return true; + } + const local = this.sketchLinePreview?.b || null; + if (!local) return true; + this.createSketchLine(feature, this.sketchLineStart, local, { + startRefId: this.sketchLineStartRefId || null, + endRefId: null + }); + this.cancelSketchLine(); + return true; + } + + if (tool === 'rect' || tool === 'rect-center') { + const centerMode = tool === 'rect-center'; + if (!isDone) { + const local = this.projectEventToSketchLocal(delta?.event, feature); + if (this.sketchRectStart && local) { + this.sketchRectPreview = this.makeSketchRectPreview(this.sketchRectStart, local, centerMode); + this.updateSketchInteractionVisuals(); + } + return true; + } + if (!this.sketchRectStart || this.sketchRectStartSeq !== this.sketchPointerDown?.seq) { + return true; + } + const end = this.sketchRectPreview?.corners?.[2] + ? { x: this.sketchRectPreview.corners[2].x, y: this.sketchRectPreview.corners[2].y } + : null; + if (!end) return true; + const created = this.createSketchRectangle(feature, this.sketchRectStart, end, { + centerMode, + startRefId: this.sketchRectStartRefId || null, + endRefId: null + }); + if (created) { + this.cancelSketchRect(); + } + return true; + } + + if (isCircleCenter) { + if (!isDone) { + if (!this.sketchCircleCenter) return true; + const local = this.projectEventToSketchLocal(delta?.event, feature); + if (!local) return true; + const radius = Math.hypot( + (local.x || 0) - (this.sketchCircleCenter.x || 0), + (local.y || 0) - (this.sketchCircleCenter.y || 0) + ); + if (Number.isFinite(radius) && radius > SKETCH_MIN_LINE_LENGTH) { + this.sketchArcPreview = { + mode: 'circle', + circle: true, + cx: this.sketchCircleCenter.x || 0, + cy: this.sketchCircleCenter.y || 0, + radius + }; + } else { + this.sketchArcPreview = null; + } + this.updateSketchInteractionVisuals(); + return true; + } + if (!this.sketchCircleCenter) return false; + const preview = this.sketchArcPreview; + let end = null; + if (preview && preview.mode === 'circle' && Number.isFinite(preview.radius) && preview.radius > SKETCH_MIN_LINE_LENGTH) { + end = { x: Number(preview.cx || 0) + Number(preview.radius || 0), y: Number(preview.cy || 0) }; + } else { + const local = this.projectEventToSketchLocal(delta?.event, feature); + if (local) end = local; + } + if (!end) return true; + const created = this.createSketchCircle(feature, this.sketchCircleCenter, end, { centerRefId: this.sketchCircleCenterRefId || null }); + if (created) { + this.cancelSketchCircle(); + } + return true; + } + if (tool !== 'select') return false; + if (!this.sketchPointerDown) return false; + + if (isDone) { + if (this.sketchMarquee) { + this.finishSketchMarquee(feature); + return true; + } + if (!this.sketchDrag) return false; + const drag = this.sketchDrag; + const moved = !!drag.moved; + const snapPointId = drag.snapPointId || null; + const snapPointType = drag.snapPointType || null; + const snapArcId = drag.snapArcId || null; + const snapMovedPointId = drag.snapMovedPointId || null; + const movedPointIds = drag.movedPointIds || new Set(); + const draggedArcIds = drag.draggedArcIds || new Set(); + const pointDrag = !!drag.pointDrag; + const tangentDriven = dragTouchesTangentConstraint(feature, movedPointIds, draggedArcIds); + this.sketchDrag = null; + api.sketchRuntime?.setMutating?.(feature.id, false); + if (moved) { + if (snapPointType === 'point' && snapPointId && snapMovedPointId && snapMovedPointId !== snapPointId) { + sketchCreate.addCoincidentConstraintIfMissing.call(this, feature, snapMovedPointId, snapPointId); + enforceSketchConstraintsInPlace(feature); + } + if (snapPointType === 'arc-center' && snapArcId && snapMovedPointId) { + feature.constraints = Array.isArray(feature.constraints) ? feature.constraints : []; + this.toggleSketchConstraintInList(feature, feature.constraints, 'arc_center_coincident', [snapArcId, snapMovedPointId]); + enforceSketchConstraintsInPlace(feature); + } + enforceSketchConstraintsInPlace(feature, { + useFallback: tangentDriven || !pointDrag, + iterations: 64, + draggedPointIds: Array.from(movedPointIds || []), + draggedArcIds: Array.from(draggedArcIds || []), + tangentAggressive: true + }); + // Always finish drag with a clean global settle while preserving + // drag context so directional constraints (like mirror) do not snap back. + enforceSketchConstraintsInPlace(feature, { + iterations: 96, + draggedPointIds: Array.from(movedPointIds || []), + draggedArcIds: Array.from(draggedArcIds || []) + }); + enforceSketchConstraintsInPlace(feature, { + useFallback: true, + iterations: 96, + draggedPointIds: Array.from(movedPointIds || []), + draggedArcIds: Array.from(draggedArcIds || []), + tangentAggressive: false + }); + api.features.commit(feature.id, { + opType: 'feature.update', + payload: { + field: snapPointType === 'point' && snapPointId && snapMovedPointId + ? 'entities.move+constraints.coincident' + : snapPointType === 'arc-center' && snapArcId && snapMovedPointId + ? 'entities.move+constraints.arc_center_coincident' + : 'entities.move' + } + }); + } + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return true; + } + + const event = delta?.event; + if (!event) return false; + + if (!this.sketchDrag) { + if (Math.hypot(offset?.x || 0, offset?.y || 0) < SKETCH_DRAG_START_PX) return false; + const downId = this.sketchPointerDown.hitId + || this.sketchPointerDown.hoveredHitId + || this.hoveredSketchEntityId + || null; + const downType = this.sketchPointerDown.hitType || this.sketchPointerDown.hoveredHitType || null; + const downArcId = downType === 'arc-center' + ? (typeof downId === 'string' + ? (downId.startsWith('arc-center:') ? downId.substring('arc-center:'.length) : downId) + : null) + : null; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const entityById = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const pointById = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e])); + if (!downId || downId === SKETCH_VIRTUAL_ORIGIN_ID) { + this.startSketchMarquee(feature, this.sketchPointerDown, event); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + return true; + } + const centerDrag = downType === 'arc-center'; + const downEntity = entityById.get(downId) || null; + const circleCurveDown = downType === 'arc' && isDragResizableCircleArc(downEntity, pointById); + const dragSelectedLines = this.selectedSketchEntities.has(downId) || this.isPointOnSelectedSketchLine(feature, downId); + const activeIds = centerDrag + ? new Set(downArcId ? [downArcId] : []) + : circleCurveDown + ? new Set([downId]) + : dragSelectedLines + ? new Set(this.selectedSketchEntities) + : new Set([downId]); + const circleCurveDragIds = new Set(); + if (!centerDrag) { + for (const id of activeIds) { + const ent = entityById.get(id); + if (isDragResizableCircleArc(ent, pointById)) circleCurveDragIds.add(id); + } + const downEnt = entityById.get(downId); + if (downType === 'arc' && isDragResizableCircleArc(downEnt, pointById)) circleCurveDragIds.add(downId); + } + const refs = this.collectCoordinateRefsFromIds(feature, activeIds); + if (centerDrag && downArcId) { + const entitiesById = new Map((Array.isArray(feature?.entities) ? feature.entities : []) + .filter(e => e?.type === 'point' && e?.id) + .map(e => [e.id, e])); + const extraPointIds = collectArcCenterCoincidentPointIds(feature, downArcId); + for (const pid of extraPointIds) { + const p = entitiesById.get(pid); + if (p) refs.push(p); + } + } + const startLocal = this.sketchPointerDown.local + || this.getSketchHitLocalPoint(feature, { id: downId, type: downType }) + || this.projectEventToSketchLocal(event, feature); + if (!startLocal) return false; + if (!this.sketchPointerDown.local) { + this.sketchPointerDown.local = { x: startLocal.x || 0, y: startLocal.y || 0 }; + } + const baseline = new Map(); + for (const ref of refs) baseline.set(ref, { x: ref.x || 0, y: ref.y || 0 }); + const arcControlBaseline = []; + for (const entity of entities) { + if (entity?.type !== 'arc' || !entity.id) continue; + if (!activeIds.has(entity.id)) continue; + if (!Number.isFinite(entity.mx) || !Number.isFinite(entity.my)) continue; + const pa = pointById.get(entity.a) || null; + const pb = pointById.get(entity.b) || null; + arcControlBaseline.push({ + entity, + mx: entity.mx, + my: entity.my, + cx: Number(entity.cx || 0), + cy: Number(entity.cy || 0), + radius: Number(entity.radius || 0), + a: pa ? { x: pa.x || 0, y: pa.y || 0 } : null, + b: pb ? { x: pb.x || 0, y: pb.y || 0 } : null + }); + } + this.sketchDrag = { + start: { x: startLocal.x || 0, y: startLocal.y || 0 }, + baseline, + arcControlBaseline, + activeIds, + circleCurveDragIds, + movedPointIds: new Set(refs.map(ref => ref?.id).filter(Boolean)), + draggedArcIds: new Set(Array.from(activeIds).filter(id => entityById.get(id)?.type === 'arc')), + centerLocks: (!centerDrag && !circleCurveDown) + ? this.collectDragLockedArcCenters(feature, activeIds, refs, { includePointOnArc: true }) + : new Map(), + pointDrag: downType === 'point', + centerDrag, + snapPointId: null, + snapMovedPointId: null, + moved: false + }; + api.sketchRuntime?.setMutating?.(feature.id, true); + this.hoveredSketchEntityId = null; + this.updateSketchInteractionVisuals(); + } + + if (this.sketchMarquee) { + this.updateSketchMarquee(event); + return true; + } + + const local = this.projectEventToSketchLocal(event, feature); + if (!local) return true; + + const dx = local.x - this.sketchDrag.start.x; + const dy = local.y - this.sketchDrag.start.y; + for (const [ref, base] of this.sketchDrag.baseline.entries()) { + ref.x = base.x + dx; + ref.y = base.y + dy; + } + for (const ctrl of this.sketchDrag.arcControlBaseline || []) { + ctrl.entity.mx = ctrl.mx + dx; + ctrl.entity.my = ctrl.my + dy; + } + refreshThreePointCirclesFromDefinitions.call(this, feature); + this.applyCircleDragKinematics(feature, dx, dy, local); + + const activeCircleDrag = !!(this.sketchDrag.circleCurveDragIds?.size); + const snap = (this.sketchDrag.centerDrag || activeCircleDrag) ? null : this.getSketchDragSnapTarget(event, feature, this.sketchDrag.movedPointIds); + const snapId = snap?.targetId || null; + const snapType = snap?.targetType || null; + const snapArcId = snap?.targetArcId || null; + const snapMovedPointId = snap?.movedId || null; + this.sketchDrag.snapPointId = snapId; + this.sketchDrag.snapPointType = snapType; + this.sketchDrag.snapArcId = snapArcId; + this.sketchDrag.snapMovedPointId = snapMovedPointId; + this.hoveredSketchEntityId = snap?.hoveredId || snapId; + + const hasCircularPattern = Array.isArray(feature?.constraints) + && feature.constraints.some(c => c?.type === 'circular_pattern'); + if (activeCircleDrag && !this.sketchDrag.centerDrag) { + this.projectPointOnArcConstraintsForArcs(feature, this.sketchDrag.circleCurveDragIds); + const tangentDriven = dragTouchesTangentConstraint( + feature, + this.sketchDrag.movedPointIds || new Set(), + this.sketchDrag.draggedArcIds || new Set() + ); + this.applyDragLockedArcCenters(feature, this.sketchDrag.centerLocks); + enforceSketchConstraintsInPlace(feature, { + useFallback: tangentDriven || !this.sketchDrag.pointDrag, + iterations: hasCircularPattern ? 14 : 24, + draggedPointIds: Array.from(this.sketchDrag.movedPointIds || []), + draggedArcIds: Array.from(this.sketchDrag.draggedArcIds || []), + tangentAggressive: false + }); + this.applyDragLockedArcCenters(feature, this.sketchDrag.centerLocks); + } else { + const tangentDriven = dragTouchesTangentConstraint( + feature, + this.sketchDrag.movedPointIds || new Set(), + this.sketchDrag.draggedArcIds || new Set() + ); + this.applyDragLockedArcCenters(feature, this.sketchDrag.centerLocks); + enforceSketchConstraintsInPlace(feature, { + useFallback: tangentDriven || !this.sketchDrag.pointDrag, + iterations: hasCircularPattern ? 18 : 48, + draggedPointIds: Array.from(this.sketchDrag.movedPointIds || []), + draggedArcIds: Array.from(this.sketchDrag.draggedArcIds || []), + tangentAggressive: false + }); + this.applyDragLockedArcCenters(feature, this.sketchDrag.centerLocks); + } + refreshThreePointCirclesFromDefinitions.call(this, feature); + this.sketchDrag.moved = this.sketchDrag.moved || Math.hypot(dx, dy) > 0; + api.sketchRuntime?.syncFeature?.(feature.id); + this.updateSketchInteractionVisuals(); + return true; +} + +function isDragResizableCircleArc(entity, pointById) { + if (entity?.type !== 'arc') return false; + if (isCenterPointCircle(entity)) return true; + // Compatibility path: treat full-circle arc records with coincident endpoints + // as center-point circles for drag-resize interactions. + if (!Number.isFinite(entity?.cx) || !Number.isFinite(entity?.cy) || !Number.isFinite(entity?.radius)) return false; + const start = Number(entity?.startAngle); + const end = Number(entity?.endAngle); + const full = Number.isFinite(start) && Number.isFinite(end) && Math.abs(start) < 1e-6 && Math.abs(end - Math.PI * 2) < 1e-6; + if (!full) return false; + const a = typeof entity?.a === 'string' ? pointById?.get?.(entity.a) : null; + const b = typeof entity?.b === 'string' ? pointById?.get?.(entity.b) : null; + if (!a || !b) return !!isCircleCurve(entity); + return Math.hypot((a.x || 0) - (b.x || 0), (a.y || 0) - (b.y || 0)) < 1e-6; +} + +function refreshThreePointCirclesFromDefinitions(feature) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + if (!entities.length) return false; + const points = new Map(entities.filter(e => e?.type === 'point' && e.id).map(e => [e.id, e])); + let changed = false; + for (const arc of entities) { + if (arc?.type !== 'arc' || !arc?.id) continue; + const ids = Array.isArray(arc?.data?.threePointIds) ? arc.data.threePointIds.filter(Boolean) : []; + if (!isThreePointCircle(arc) && ids.length < 3) continue; + if (ids.length < 3) continue; + const p1 = points.get(ids[0]); + const p2 = points.get(ids[1]); + const p3 = points.get(ids[2]); + if (!p1 || !p2 || !p3) continue; + const circle = this.computeCircleFromThreePoints(p1, p2, p3); + if (!circle) continue; + const radius = Number(circle.radius || 0); + if (!Number.isFinite(radius) || radius <= SKETCH_MIN_LINE_LENGTH) continue; + const cx = Number(circle.cx || 0); + const cy = Number(circle.cy || 0); + if (Math.abs((arc.cx || 0) - cx) > 1e-9) { + arc.cx = cx; + changed = true; + } + if (Math.abs((arc.cy || 0) - cy) > 1e-9) { + arc.cy = cy; + changed = true; + } + if (Math.abs((arc.radius || 0) - radius) > 1e-9) { + arc.radius = radius; + changed = true; + } + if (Math.abs((arc.startAngle || 0) - 0) > 1e-9) { + arc.startAngle = 0; + changed = true; + } + if (Math.abs((arc.endAngle || 0) - (Math.PI * 2)) > 1e-9) { + arc.endAngle = Math.PI * 2; + changed = true; + } + if (arc.ccw !== true) { + arc.ccw = true; + changed = true; + } + const mx = cx; + const my = cy + radius; + if (Math.abs((arc.mx || 0) - mx) > 1e-9) { + arc.mx = mx; + changed = true; + } + if (Math.abs((arc.my || 0) - my) > 1e-9) { + arc.my = my; + changed = true; + } + const a = typeof arc.a === 'string' ? points.get(arc.a) : null; + const b = typeof arc.b === 'string' ? points.get(arc.b) : null; + if (a) { + const nx = p1.x || 0; + const ny = p1.y || 0; + if (Math.abs((a.x || 0) - nx) > 1e-9 || Math.abs((a.y || 0) - ny) > 1e-9) { + a.x = nx; + a.y = ny; + changed = true; + } + } + if (b) { + const nx = p1.x || 0; + const ny = p1.y || 0; + if (Math.abs((b.x || 0) - nx) > 1e-9 || Math.abs((b.y || 0) - ny) > 1e-9) { + b.x = nx; + b.y = ny; + changed = true; + } + } + } + return changed; +} + +function collectDragLockedArcCenters(feature, activeIds, refs = [], options = {}) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const constraints = Array.isArray(feature?.constraints) ? feature.constraints : []; + const arcById = new Map(entities.filter(e => e?.type === 'arc' && e?.id).map(e => [e.id, e])); + const selected = new Set([...(activeIds || [])]); + for (const ref of refs || []) { + if (ref?.id) selected.add(ref.id); + } + const lockTypes = new Set(['tangent']); + if (options?.includePointOnArc) lockTypes.add('point_on_arc'); + const out = new Map(); + for (const c of constraints) { + if (!lockTypes.has(c?.type)) continue; + const crefs = Array.isArray(c.refs) ? c.refs : []; + if (!crefs.some(id => selected.has(id))) continue; + const arcId = crefs.find(id => arcById.has(id)); + if (!arcId) continue; + const arc = arcById.get(arcId); + if (!isCircleCurve(arc)) continue; + const cx = Number(arc.cx); + const cy = Number(arc.cy); + if (!Number.isFinite(cx) || !Number.isFinite(cy)) continue; + out.set(arcId, { cx, cy }); + } + return out; +} + +function applyDragLockedArcCenters(feature, centerLocks) { + if (!(centerLocks instanceof Map) || !centerLocks.size) return; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const arcById = new Map(entities.filter(e => e?.type === 'arc' && e?.id).map(e => [e.id, e])); + for (const [arcId, lock] of centerLocks.entries()) { + const arc = arcById.get(arcId); + if (!arc) continue; + arc.cx = lock.cx; + arc.cy = lock.cy; + } +} + +function draggedArcsHaveTangent(feature, draggedArcIds) { + if (!(draggedArcIds instanceof Set) || !draggedArcIds.size) return false; + const constraints = Array.isArray(feature?.constraints) ? feature.constraints : []; + for (const c of constraints) { + if (c?.type !== 'tangent') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + if (refs.some(id => draggedArcIds.has(id))) return true; + } + return false; +} + +function dragTouchesTangentConstraint(feature, movedPointIds = new Set(), draggedArcIds = new Set()) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const constraints = Array.isArray(feature?.constraints) ? feature.constraints : []; + if (!constraints.length) return false; + const touched = new Set(Array.from(draggedArcIds || [])); + for (const entity of entities) { + if (entity?.type !== 'line' || !entity.id) continue; + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if ((aId && movedPointIds?.has?.(aId)) || (bId && movedPointIds?.has?.(bId))) { + touched.add(entity.id); + } + } + if (!touched.size) return false; + for (const c of constraints) { + if (c?.type !== 'tangent') continue; + const refs = Array.isArray(c.refs) ? c.refs : []; + if (refs.some(id => touched.has(id))) return true; + } + return false; +} + +function isPointOnSelectedSketchLine(feature, pointId) { + if (!pointId || !this.selectedSketchEntities?.size) return false; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + for (const entity of entities) { + if ((entity?.type !== 'line' && entity?.type !== 'arc') || !entity.id) continue; + if (!this.selectedSketchEntities.has(entity.id)) continue; + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if (aId === pointId || bId === pointId) return true; + } + return false; +} + +export { + handleSketchPointerDown, + handleSketchHover, + handleSketchPointerMove, + handleSketchMouseUp, + handleSketchDrag, + collectDragLockedArcCenters, + applyDragLockedArcCenters, + draggedArcsHaveTangent, + dragTouchesTangentConstraint, + isPointOnSelectedSketchLine +}; diff --git a/src/void/sketch/runtime.js b/src/void/sketch/runtime.js new file mode 100644 index 00000000..f047baee --- /dev/null +++ b/src/void/sketch/runtime.js @@ -0,0 +1,918 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE, Line2, LineGeometry, LineMaterial, LineSegments2, LineSegmentsGeometry } from '../../ext/three.js'; +import { space } from '../../moto/space.js'; +import { Plane } from '../plane.js'; +import { VOID_PALETTE } from '../palette.js'; +import * as markerOps from './runtime_markers.js'; +import * as profileOps from './runtime_profiles.js'; +import * as arcOps from './runtime_arc.js'; +import * as uiOps from './runtime_ui.js'; +import { isCircleCurve, isThreePointCircle } from './curve.js'; + +const SKETCH_COLORS = VOID_PALETTE.sketch; +const SKETCH_PLANE_SCALE = 0.86; +const SKETCH_PLANE_MIN_SIZE = 24; +const SKETCH_POINT_SCREEN_RADIUS_PX = 6; +const SKETCH_POINT_BASE_RADIUS = 1.8; +const SKETCH_VIRTUAL_ORIGIN_ID = '__sketch-origin__'; +const CONSTRAINT_GLYPH_SIZE_PX = 18; +const CONSTRAINT_GLYPH_GAP_PX = 4; + +function createSketchRuntimeApi(getApi) { + return { + root: null, + sketches: new Map(), // id -> record + hoveredId: null, + editingId: null, + selectedIds: new Set(), + forcedVisibleIds: new Set(), + mutatingIds: new Set(), + hoveredProfileKey: null, + selectedProfileKeys: new Set(), + _glyphLayer: null, + _glyphDrag: null, + _glyphClick: null, + _cameraSyncQueued: false, + _viewCtrlBound: null, + _viewCtrlChangeHandler: null, + _renderPrefs: { + arcSegmentLength: 2.5 + }, + + init(world) { + if (this.root) return; + this.root = new THREE.Group(); + this.root.name = 'sketch-runtime'; + world.add(this.root); + this._tmpPointWorld = new THREE.Vector3(); + this.ensureConstraintGlyphLayer(); + const queueGlyphSync = () => { + if (this._cameraSyncQueued) return; + this._cameraSyncQueued = true; + self.requestAnimationFrame(() => { + this._cameraSyncQueued = false; + if (!this.sketches?.size) return; + this.updatePointScreenScales(); + this.updateConstraintGlyphs(); + }); + }; + this._viewCtrlChangeHandler = queueGlyphSync; + this.bindViewControl(); + window.addEventListener('resize', () => { + this.updatePointScreenScales(); + this.updateConstraintGlyphs(); + }); + window.addEventListener('mousemove', event => { + if (this._glyphDrag) { + this.updateConstraintDrag(event, false); + } + }); + window.addEventListener('mouseup', event => { + if (this._glyphDrag) { + this.updateConstraintDrag(event, true); + } + }); + }, + + sync() { + this.bindViewControl(); + const api = getApi(); + const features = api.features.listBuilt().filter(f => f?.type === 'sketch'); + const present = new Set(features.map(f => f.id)); + + for (const [id, rec] of this.sketches.entries()) { + if (!present.has(id)) { + this.removeLabel(rec); + this.root?.remove(rec.group); + rec.plane?.dispose?.(); + this.sketches.delete(id); + } + } + + for (const feature of features) { + let rec = this.sketches.get(feature.id); + if (!rec) { + rec = this.createSketchRecord(feature); + this.sketches.set(feature.id, rec); + this.root?.add(rec.group); + } else { + rec.feature = feature; + } + this.updateSketchRecord(rec); + } + this.updatePointScreenScales(); + this.updateConstraintGlyphs(); + }, + + syncFeature(featureId) { + if (!featureId) return this.sync(); + this.bindViewControl(); + const api = getApi(); + const feature = api.features.findById(featureId); + if (!feature || feature.type !== 'sketch') { + return this.sync(); + } + let rec = this.sketches.get(feature.id); + if (!rec) { + rec = this.createSketchRecord(feature); + this.sketches.set(feature.id, rec); + this.root?.add(rec.group); + } else { + rec.feature = feature; + } + this.updateSketchRecord(rec); + this.updatePointScreenScales(); + this.updateConstraintGlyphs(); + }, + + bindViewControl() { + const next = space.view?.ctrl || null; + if (next === this._viewCtrlBound) { + return; + } + if (this._viewCtrlBound?.removeEventListener && this._viewCtrlChangeHandler) { + this._viewCtrlBound.removeEventListener('change', this._viewCtrlChangeHandler); + } + this._viewCtrlBound = next; + if (this._viewCtrlBound?.addEventListener && this._viewCtrlChangeHandler) { + this._viewCtrlBound.addEventListener('change', this._viewCtrlChangeHandler); + } + }, + + getRecord(featureId) { + return this.sketches.get(featureId) || null; + }, + + getEditingRecord() { + return this.getRecord(this.editingId); + }, + + createSketchRecord(feature) { + const group = new THREE.Group(); + group.name = `sketch-${feature.id}`; + const plane = new Plane({ + id: `sketch-plane-${feature.id}`, + name: feature.name || 'Sketch Plane', + size: 160, + showHandles: false, + color: SKETCH_COLORS.planeDefault.fill, + outlineColor: SKETCH_COLORS.planeDefault.outline, + opacity: SKETCH_COLORS.planeDefault.fillOpacity, + outlineOpacity: SKETCH_COLORS.planeDefault.outlineOpacity + }); + const planeGroup = plane.getGroup(); + planeGroup.visible = false; + + const entitiesGroup = new THREE.Group(); + entitiesGroup.name = `sketch-entities-${feature.id}`; + const dimensionGroup = new THREE.Group(); + dimensionGroup.name = `sketch-dimensions-${feature.id}`; + const previewLine = this.createFatLine([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0) + ], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover); + previewLine.visible = false; + previewLine.renderOrder = 9; + entitiesGroup.add(previewLine); + const previewArc = this.createFatLine([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0) + ], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover); + previewArc.visible = false; + previewArc.renderOrder = 9; + entitiesGroup.add(previewArc); + const previewStart = this.createSketchPointMarker(0, 0, { virtualOrigin: true }); + previewStart.visible = false; + previewStart.renderOrder = 11; + entitiesGroup.add(previewStart); + const previewEnd = this.createSketchPointMarker(0, 0, { virtualOrigin: true }); + previewEnd.visible = false; + previewEnd.renderOrder = 11; + entitiesGroup.add(previewEnd); + const previewArcCenter = this.createArcCenterMarker(0, 0); + previewArcCenter.visible = false; + previewArcCenter.renderOrder = 11; + entitiesGroup.add(previewArcCenter); + const previewRect = this.createFatLine([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0) + ], SKETCH_COLORS.linesHover, SKETCH_COLORS.lineWidths.hover); + previewRect.visible = false; + previewRect.renderOrder = 9; + entitiesGroup.add(previewRect); + const previewFaceSegments = this.createFatSegments([], SKETCH_COLORS.linesProjectedFace, SKETCH_COLORS.lineWidths.hover); + previewFaceSegments.visible = false; + previewFaceSegments.renderOrder = 55; + entitiesGroup.add(previewFaceSegments); + const previewExternalWorldLine = this.createFatLine([ + new THREE.Vector3(0, 0, 0), + new THREE.Vector3(0, 0, 0) + ], SKETCH_COLORS.linesDerivedActual, SKETCH_COLORS.lineWidths.hover); + previewExternalWorldLine.visible = false; + previewExternalWorldLine.renderOrder = 60; + group.add(previewExternalWorldLine); + const previewExternalWorldSegments = this.createFatSegments([], SKETCH_COLORS.linesDerivedActual, SKETCH_COLORS.lineWidths.hover); + previewExternalWorldSegments.visible = false; + previewExternalWorldSegments.renderOrder = 60; + group.add(previewExternalWorldSegments); + const previewExternalWorldPoint = this.createArcCenterMarker(0, 0); + previewExternalWorldPoint.visible = false; + previewExternalWorldPoint.renderOrder = 60; + group.add(previewExternalWorldPoint); + + group.add(planeGroup); + group.add(entitiesGroup); + group.add(dimensionGroup); + + return { + feature, + group, + plane, + entitiesGroup, + dimensionGroup, + previewLine, + previewArc, + previewStart, + previewEnd, + previewArcCenter, + previewRect, + previewFaceSegments, + previewExternalWorldLine, + previewExternalWorldSegments, + previewExternalWorldPoint, + entityViews: new Map(), + interaction: { + hoveredId: null, + selectedIds: new Set(), + mirrorMode: false, + mirrorAxisId: null, + circularPatternMode: false, + circularPatternCenterRef: null, + gridPatternMode: false, + gridPatternCenterRef: null, + hoveredProfileId: null, + selectedProfileIds: new Set(), + hoveredConstraintId: null, + selectedConstraintIds: new Set(), + previewLine: null, + previewArc: null, + previewRect: null, + previewFaceSegments: null, + previewExternalWorldLine: null, + previewExternalWorldSegments: null, + previewExternalWorldPoint: null, + previewStart: null, + previewEnd: null, + previewMid: null + }, + labelId: `sketch-label-${feature.id}` + }; + }, + + createFatLine(points = [], color = SKETCH_COLORS.linesGray, width = SKETCH_COLORS.lineWidths.default) { + const geo = new LineGeometry(); + const pos = []; + for (const p of points) { + pos.push(p.x || 0, p.y || 0, p.z || 0); + } + geo.setPositions(pos); + const mat = new LineMaterial({ + color, + linewidth: width, + transparent: true, + opacity: 1, + depthWrite: false, + alphaToCoverage: false + }); + const { renderer } = space.internals(); + const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1; + const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1; + mat.resolution.set(w, h); + const line = new Line2(geo, mat); + line.computeLineDistances?.(); + line.userData = line.userData || {}; + line.userData.isFatLine = true; + return line; + }, + + createFatSegments(positions = [], color = SKETCH_COLORS.linesGray, width = SKETCH_COLORS.lineWidths.default) { + const geo = new LineSegmentsGeometry(); + geo.setPositions(positions); + const mat = new LineMaterial({ + color, + linewidth: width, + transparent: true, + opacity: 1, + depthWrite: false, + alphaToCoverage: false + }); + const { renderer } = space.internals(); + const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1; + const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1; + mat.resolution.set(w, h); + const line = new LineSegments2(geo, mat); + line.computeLineDistances?.(); + line.userData = line.userData || {}; + line.userData.isFatLine = true; + return line; + }, + + updateSketchRecord(rec) { + const feature = rec.feature; + if (feature?.plane) { + rec.plane.setFrame(this.toDisplayPlaneFrame(feature.plane)); + const pg = rec.plane.getGroup(); + rec.entitiesGroup.position.copy(pg.position); + rec.entitiesGroup.quaternion.copy(pg.quaternion); + rec.entitiesGroup.scale.copy(pg.scale); + rec.dimensionGroup.position.copy(pg.position); + rec.dimensionGroup.quaternion.copy(pg.quaternion); + rec.dimensionGroup.scale.copy(pg.scale); + } + this.rebuildEntities(rec); + this.applySketchState(rec); + }, + + toDisplayPlaneFrame(frame) { + if (!frame || typeof frame !== 'object') { + return frame; + } + const out = JSON.parse(JSON.stringify(frame)); + const width = Number(out?.size?.width); + const height = Number(out?.size?.height); + if (Number.isFinite(width) && Number.isFinite(height)) { + out.size.width = Math.max(SKETCH_PLANE_MIN_SIZE, width * SKETCH_PLANE_SCALE); + out.size.height = Math.max(SKETCH_PLANE_MIN_SIZE, height * SKETCH_PLANE_SCALE); + } + return out; + }, + + ensureConstraintGlyphLayer() { + if (this._glyphLayer?.isConnected) { + return this._glyphLayer; + } + const { container } = space.internals(); + if (!container) { + return null; + } + const layer = document.createElement('div'); + layer.className = 'sketch-constraint-layer'; + container.appendChild(layer); + this._glyphLayer = layer; + return layer; + }, + + clearConstraintGlyphs() { + if (this._glyphLayer) { + this._glyphLayer.innerHTML = ''; + } + }, + + constraintGlyphLabel(type) { + return uiOps.constraintGlyphLabel(type); + }, + + createSketchPointMarker(x = 0, y = 0, opts = {}) { + return markerOps.createSketchPointMarker(x, y, opts, SKETCH_COLORS); + }, + + createArcCenterMarker(x = 0, y = 0) { + return markerOps.createArcCenterMarker(x, y, SKETCH_COLORS); + }, + + rebuildEntities(rec) { + while (rec.entitiesGroup.children.length) { + const child = rec.entitiesGroup.children[0]; + if (child === rec.previewLine || child === rec.previewArc || child === rec.previewRect || child === rec.previewFaceSegments || child === rec.previewStart || child === rec.previewEnd || child === rec.previewArcCenter) { + rec.entitiesGroup.remove(child); + continue; + } + child.traverse?.(obj => { + obj.geometry?.dispose?.(); + if (Array.isArray(obj.material)) { + for (const mat of obj.material) mat?.dispose?.(); + } else { + obj.material?.dispose?.(); + } + }); + rec.entitiesGroup.remove(child); + } + rec.entityViews.clear(); + + // Sketch-local origin point, always available for snapping/line anchoring. + const originPoint = this.createSketchPointMarker(0, 0, { virtualOrigin: true }); + originPoint.userData.sketchEntityId = SKETCH_VIRTUAL_ORIGIN_ID; + originPoint.userData.sketchEntityType = 'point'; + this.tagPointMarker(originPoint, SKETCH_VIRTUAL_ORIGIN_ID); + rec.entitiesGroup.add(originPoint); + rec.entityViews.set(SKETCH_VIRTUAL_ORIGIN_ID, { + entity: { id: SKETCH_VIRTUAL_ORIGIN_ID, type: 'point', x: 0, y: 0, virtual: true }, + object: originPoint, + type: 'point', + virtual: true + }); + + const entities = Array.isArray(rec.feature?.entities) ? rec.feature.entities : []; + const pointById = new Map(); + for (const entity of entities) { + if (entity?.type === 'point' && entity.id) { + pointById.set(entity.id, entity); + } + } + // Circle endpoint points are implementation details; hide their markers. + const hiddenPointIds = new Set(); + for (const entity of entities) { + if (entity?.type !== 'arc' || !isCircleCurve(entity)) continue; + if (typeof entity.a === 'string') hiddenPointIds.add(entity.a); + if (typeof entity.b === 'string') hiddenPointIds.add(entity.b); + } + if (!this.mutatingIds.has(rec.feature?.id)) { + this.addClosedProfileFills(rec, entities, pointById); + } + for (const entity of entities) { + if (!entity?.id) { + continue; + } + if (entity.type === 'line' && entity.a && entity.b) { + const [a, b] = this.getLineEndpoints(entity, pointById); + if (!a || !b) continue; + const material = entity.construction + ? new THREE.LineDashedMaterial({ + color: SKETCH_COLORS.linesGray, + transparent: true, + opacity: 1, + dashSize: 3, + gapSize: 2, + depthWrite: false + }) + : new THREE.LineBasicMaterial({ + color: SKETCH_COLORS.linesGray, + transparent: true, + opacity: 1, + depthWrite: false + }); + const line = entity.construction + ? new THREE.Line( + new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(a.x || 0, a.y || 0, 0), + new THREE.Vector3(b.x || 0, b.y || 0, 0) + ]), + material + ) + : this.createFatLine([ + new THREE.Vector3(a.x || 0, a.y || 0, 0), + new THREE.Vector3(b.x || 0, b.y || 0, 0) + ], SKETCH_COLORS.linesGray, SKETCH_COLORS.lineWidths.default); + if (entity.construction && line.computeLineDistances) { + line.computeLineDistances(); + } + line.renderOrder = 7; + line.userData.sketchEntityId = entity.id; + line.userData.sketchEntityType = 'line'; + rec.entitiesGroup.add(line); + rec.entityViews.set(entity.id, { entity, object: line, type: 'line' }); + continue; + } + if (entity.type === 'arc' && entity.a && entity.b) { + const [a, b] = this.getArcEndpoints(entity, pointById); + if (!a || !b) continue; + const points = this.getArcRenderPoints(entity, a, b, this.getArcSegmentsFor(entity, a, b, 'entity')); + if (points.length < 2) continue; + const material = entity.construction + ? new THREE.LineDashedMaterial({ + color: SKETCH_COLORS.linesGray, + transparent: true, + opacity: 1, + dashSize: 3, + gapSize: 2, + depthWrite: false + }) + : new THREE.LineBasicMaterial({ + color: SKETCH_COLORS.linesGray, + transparent: true, + opacity: 1, + depthWrite: false + }); + const arcPoints = points.map(p => new THREE.Vector3(p.x, p.y, 0)); + const arc = entity.construction + ? new THREE.Line(new THREE.BufferGeometry().setFromPoints(arcPoints), material) + : this.createFatLine(arcPoints, SKETCH_COLORS.linesGray, SKETCH_COLORS.lineWidths.default); + if (entity.construction && arc.computeLineDistances) { + arc.computeLineDistances(); + } + arc.renderOrder = 7; + arc.userData.sketchEntityId = entity.id; + arc.userData.sketchEntityType = 'arc'; + rec.entitiesGroup.add(arc); + rec.entityViews.set(entity.id, { entity, object: arc, type: 'arc' }); + + const center = this.getArcCenterLocal(entity, a, b); + if (center && !isThreePointCircle(entity)) { + const centerKey = `arc-center:${entity.id}`; + const centerMarker = this.createArcCenterMarker(center.x, center.y); + centerMarker.userData.sketchEntityId = centerKey; + centerMarker.userData.sketchEntityType = 'arc-center'; + centerMarker.userData.sketchEntityRefId = entity.id; + centerMarker.traverse(obj => { + obj.userData = obj.userData || {}; + obj.userData.sketchEntityId = centerKey; + obj.userData.sketchEntityType = 'arc-center'; + obj.userData.sketchEntityRefId = entity.id; + }); + rec.entitiesGroup.add(centerMarker); + rec.entityViews.set(centerKey, { entity, object: centerMarker, type: 'arc-center' }); + } + continue; + } + + if (entity.type === 'point') { + if (hiddenPointIds.has(entity.id)) { + continue; + } + const point = this.createSketchPointMarker(entity.x || 0, entity.y || 0); + point.userData.sketchEntityId = entity.id; + point.userData.sketchEntityType = 'point'; + this.tagPointMarker(point, entity.id); + rec.entitiesGroup.add(point); + rec.entityViews.set(entity.id, { entity, object: point, type: 'point' }); + } + } + if (rec.previewLine && rec.previewLine.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewLine); + } else if (rec.previewLine) { + rec.entitiesGroup.remove(rec.previewLine); + rec.entitiesGroup.add(rec.previewLine); + } + if (rec.previewStart && rec.previewStart.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewStart); + } else if (rec.previewStart) { + rec.entitiesGroup.remove(rec.previewStart); + rec.entitiesGroup.add(rec.previewStart); + } + if (rec.previewArc && rec.previewArc.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewArc); + } else if (rec.previewArc) { + rec.entitiesGroup.remove(rec.previewArc); + rec.entitiesGroup.add(rec.previewArc); + } + if (rec.previewEnd && rec.previewEnd.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewEnd); + } else if (rec.previewEnd) { + rec.entitiesGroup.remove(rec.previewEnd); + rec.entitiesGroup.add(rec.previewEnd); + } + if (rec.previewArcCenter && rec.previewArcCenter.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewArcCenter); + } else if (rec.previewArcCenter) { + rec.entitiesGroup.remove(rec.previewArcCenter); + rec.entitiesGroup.add(rec.previewArcCenter); + } + if (rec.previewFaceSegments && rec.previewFaceSegments.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewFaceSegments); + } else if (rec.previewFaceSegments) { + rec.entitiesGroup.remove(rec.previewFaceSegments); + rec.entitiesGroup.add(rec.previewFaceSegments); + } + if (rec.previewRect && rec.previewRect.parent !== rec.entitiesGroup) { + rec.entitiesGroup.add(rec.previewRect); + } else if (rec.previewRect) { + rec.entitiesGroup.remove(rec.previewRect); + rec.entitiesGroup.add(rec.previewRect); + } + }, + + addClosedProfileFills(rec, entities, pointById) { + return profileOps.addClosedProfileFills.call(this, rec, entities, pointById); + }, + + simplifyLoopsWithClipper(loops) { + return profileOps.simplifyLoopsWithClipper.call(this, loops); + }, + + findClosedCurveLoops(feature, entities, pointById) { + return profileOps.findClosedCurveLoops.call(this, feature, entities, pointById); + }, + + segmentIntersectionParams(a, b, c, d, eps = 1e-9) { + return profileOps.segmentIntersectionParams.call(this, a, b, c, d, eps); + }, + + findClosedLineLoops(feature, entities, pointById) { + return profileOps.findClosedLineLoops.call(this, feature, entities, pointById); + }, + + applySketchState(rec) { + return uiOps.applySketchState.call(this, rec, getApi, SKETCH_COLORS); + }, + + applyPlaneStyle(plane, mode) { + return uiOps.applyPlaneStyle.call(this, plane, mode, SKETCH_COLORS); + }, + + applyEntityStyle(rec, mode) { + return uiOps.applyEntityStyle.call(this, rec, mode, SKETCH_COLORS); + }, + + getConstraintHoverHighlight(rec) { + return uiOps.getConstraintHoverHighlight.call(this, rec); + }, + + applyPreviewLine(rec, mode, editing) { + return uiOps.applyPreviewLine.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewStart(rec, mode, editing) { + return uiOps.applyPreviewStart.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewEnd(rec, mode, editing) { + return uiOps.applyPreviewEnd.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewArc(rec, mode, editing) { + return uiOps.applyPreviewArc.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewRect(rec, mode, editing) { + return uiOps.applyPreviewRect.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewFaceSegments(rec, mode, editing) { + return uiOps.applyPreviewFaceSegments.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyPreviewExternalWorld(rec, mode, editing) { + return uiOps.applyPreviewExternalWorld.call(this, rec, mode, editing, SKETCH_COLORS); + }, + + applyLabelState(rec, mode, showPlane) { + return uiOps.applyLabelState.call(this, rec, mode, showPlane, getApi, SKETCH_COLORS); + }, + + removeLabel(rec) { + return uiOps.removeLabel.call(this, rec, getApi); + }, + + getPlaneLabelPosition(plane) { + return uiOps.getPlaneLabelPosition.call(this, plane); + }, + + setHovered(featureId) { + this.hoveredId = featureId || null; + this.refreshStates(); + }, + + setEditing(featureId) { + this.editingId = featureId || null; + this.refreshStates(); + }, + + setSelected(featureIds) { + this.selectedIds = new Set(featureIds || []); + this.refreshStates(); + }, + + setForcedVisible(featureIds) { + this.forcedVisibleIds = new Set(featureIds || []); + this.refreshStates(); + }, + + setMutating(featureId, active = false) { + if (!featureId) return; + if (active) { + this.mutatingIds.add(featureId); + } else { + this.mutatingIds.delete(featureId); + } + this.sync(); + }, + + setEntityInteraction(featureId, interaction = {}) { + const rec = this.getRecord(featureId); + if (!rec) return; + rec.interaction.hoveredId = interaction.hoveredId || null; + rec.interaction.selectedIds = new Set(interaction.selectedIds || []); + rec.interaction.mirrorMode = !!interaction.mirrorMode; + rec.interaction.mirrorAxisId = interaction.mirrorAxisId || null; + rec.interaction.circularPatternMode = !!interaction.circularPatternMode; + rec.interaction.circularPatternCenterRef = interaction.circularPatternCenterRef || null; + rec.interaction.gridPatternMode = !!interaction.gridPatternMode; + rec.interaction.gridPatternCenterRef = interaction.gridPatternCenterRef || null; + if (Object.prototype.hasOwnProperty.call(interaction, 'hoveredProfileId')) { + rec.interaction.hoveredProfileId = interaction.hoveredProfileId || null; + } + if (Object.prototype.hasOwnProperty.call(interaction, 'selectedProfileIds')) { + rec.interaction.selectedProfileIds = new Set(interaction.selectedProfileIds || []); + } + rec.interaction.hoveredConstraintId = interaction.hoveredConstraintId || null; + rec.interaction.selectedConstraintIds = new Set(interaction.selectedConstraintIds || []); + rec.interaction.previewLine = interaction.previewLine || null; + rec.interaction.previewArc = interaction.previewArc || null; + rec.interaction.previewRect = interaction.previewRect || null; + rec.interaction.previewFaceSegments = interaction.previewFaceSegments || null; + rec.interaction.previewExternalWorldLine = interaction.previewExternalWorldLine || null; + rec.interaction.previewExternalWorldSegments = interaction.previewExternalWorldSegments || null; + rec.interaction.previewExternalWorldPoint = interaction.previewExternalWorldPoint || null; + rec.interaction.previewStart = interaction.previewStart || null; + rec.interaction.previewEnd = interaction.previewEnd || null; + rec.interaction.previewMid = interaction.previewMid || null; + this.applySketchState(rec); + if (!this.mutatingIds?.has?.(featureId)) { + this.updateConstraintGlyphs(); + } + }, + + clearEntityInteraction(featureId) { + const rec = this.getRecord(featureId); + if (!rec) return; + rec.interaction.hoveredId = null; + rec.interaction.selectedIds = new Set(); + rec.interaction.mirrorMode = false; + rec.interaction.mirrorAxisId = null; + rec.interaction.circularPatternMode = false; + rec.interaction.circularPatternCenterRef = null; + rec.interaction.gridPatternMode = false; + rec.interaction.gridPatternCenterRef = null; + rec.interaction.hoveredProfileId = null; + rec.interaction.selectedProfileIds = new Set(); + rec.interaction.hoveredConstraintId = null; + rec.interaction.selectedConstraintIds = new Set(); + rec.interaction.previewLine = null; + rec.interaction.previewArc = null; + rec.interaction.previewRect = null; + rec.interaction.previewFaceSegments = null; + rec.interaction.previewExternalWorldLine = null; + rec.interaction.previewExternalWorldSegments = null; + rec.interaction.previewExternalWorldPoint = null; + rec.interaction.previewStart = null; + rec.interaction.previewEnd = null; + rec.interaction.previewMid = null; + this.applySketchState(rec); + this.updateConstraintGlyphs(); + }, + + getLineEndpoints(line, pointById) { + return arcOps.getLineEndpoints(line, pointById); + }, + + getArcEndpoints(arc, pointById) { + return arcOps.getArcEndpoints(arc, pointById); + }, + + getArcRenderPoints(arc, a, b, segments = 32) { + return arcOps.getArcRenderPoints(arc, a, b, segments); + }, + + getArcLength(arc, a, b) { + if (!arc) return 0; + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + let radius = Number(arc?.radius); + if (!Number.isFinite(radius) || radius <= 0) { + if (a && Number.isFinite(cx) && Number.isFinite(cy)) { + radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy); + } else { + radius = 0; + } + } + if (isCircleCurve(arc)) { + return radius > 0 ? (Math.PI * 2 * radius) : 0; + } + if (!Number.isFinite(radius) || radius <= 0) { + if (a && b) { + return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + } + return 0; + } + let startAngle = Number(arc?.startAngle); + let endAngle = Number(arc?.endAngle); + let ccw = arc?.ccw !== false; + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) && a && b) { + const geom = this.computeArcFromThreePoints( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (geom) { + startAngle = geom.startAngle; + endAngle = geom.endAngle; + ccw = geom.ccw; + } + } + if (!Number.isFinite(startAngle) || !Number.isFinite(endAngle)) { + if (a && b) { + return Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + } + return 0; + } + const tau = Math.PI * 2; + let sweep; + if (ccw) { + sweep = (endAngle - startAngle) % tau; + if (sweep < 0) sweep += tau; + } else { + sweep = (startAngle - endAngle) % tau; + if (sweep < 0) sweep += tau; + } + return Math.abs(sweep) * radius; + }, + + getArcSegmentsFor(arc, a, b, mode = 'entity') { + const unit = Math.max(0.05, Number(this._renderPrefs?.arcSegmentLength || 2.5) || 2.5); + const arcLength = Math.max(0, Number(this.getArcLength(arc, a, b) || 0)); + const base = Math.ceil(arcLength / unit); + if (mode === 'profile') return Math.max(24, Math.min(768, base)); + if (mode === 'preview') return Math.max(16, Math.min(768, base)); + return Math.max(12, Math.min(768, base)); + }, + + setRenderPreferences(next = {}) { + if (!next || typeof next !== 'object') return; + if (next.arcSegmentLength !== undefined) { + const value = Math.max(0.05, Number(next.arcSegmentLength) || 2.5); + this._renderPrefs.arcSegmentLength = value; + } + this.sync(); + }, + + getArcCenterLocal(arc, a, b) { + return arcOps.getArcCenterLocal(arc, a, b); + }, + + computeArcFromThreePoints(start, end, onArc) { + return arcOps.computeArcFromThreePoints(start, end, onArc); + }, + + updatePointScreenScales() { + return uiOps.updatePointScreenScales.call(this, { + pointScreenRadiusPx: SKETCH_POINT_SCREEN_RADIUS_PX, + pointBaseRadius: SKETCH_POINT_BASE_RADIUS + }); + }, + + getConstraintAnchorLocal(feature, constraint) { + return uiOps.getConstraintAnchorLocal.call(this, feature, constraint); + }, + + projectConstraintAnchor(rec, local) { + return uiOps.projectConstraintAnchor.call(this, rec, local, getApi); + }, + + tagPointMarker(marker, id) { + return uiOps.tagPointMarker(marker, id); + }, + + applyConstraintOffset(constraint, screenPos, slotIndex = 0, slotCount = 1) { + return uiOps.applyConstraintOffset(constraint, screenPos, slotIndex, slotCount, { + glyphSizePx: CONSTRAINT_GLYPH_SIZE_PX, + glyphGapPx: CONSTRAINT_GLYPH_GAP_PX + }); + }, + + updateConstraintGlyphs() { + return uiOps.updateConstraintGlyphs.call(this, getApi, { + glyphSizePx: CONSTRAINT_GLYPH_SIZE_PX, + glyphGapPx: CONSTRAINT_GLYPH_GAP_PX, + colors: SKETCH_COLORS + }); + }, + + updateConstraintDrag(event, done = false) { + return uiOps.updateConstraintDrag.call(this, event, done, getApi); + }, + + refreshStates() { + const profileByFeature = new Map(); + for (const key of this.selectedProfileKeys) { + const [featureId, profileId] = String(key || '').split(':'); + if (!featureId || !profileId) continue; + if (!profileByFeature.has(featureId)) profileByFeature.set(featureId, new Set()); + profileByFeature.get(featureId).add(profileId); + } + const hovered = this.hoveredProfileKey ? String(this.hoveredProfileKey).split(':') : null; + for (const rec of this.sketches.values()) { + const featureId = rec.feature?.id; + rec.interaction.hoveredProfileId = hovered && hovered[0] === featureId ? hovered[1] : null; + rec.interaction.selectedProfileIds = profileByFeature.get(featureId) || new Set(); + this.applySketchState(rec); + } + this.updateConstraintGlyphs(); + }, + + setHoveredProfile(profileKey) { + this.hoveredProfileKey = profileKey || null; + this.refreshStates(); + }, + + setSelectedProfiles(profileKeys) { + this.selectedProfileKeys = new Set(profileKeys || []); + this.refreshStates(); + } + }; +} + +export { createSketchRuntimeApi }; diff --git a/src/void/sketch/runtime_arc.js b/src/void/sketch/runtime_arc.js new file mode 100644 index 00000000..9f01d9e8 --- /dev/null +++ b/src/void/sketch/runtime_arc.js @@ -0,0 +1,186 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { isCircleCurve } from './curve.js'; + +function getLineEndpoints(line, pointById) { + const aId = typeof line?.a === 'string' ? line.a : (typeof line?.p1_id === 'string' ? line.p1_id : null); + const bId = typeof line?.b === 'string' ? line.b : (typeof line?.p2_id === 'string' ? line.p2_id : null); + let a = null; + let b = null; + if (aId) { + a = pointById?.get(aId) || null; + } else if (line?.a && typeof line.a === 'object') { + a = line.a; + } + if (bId) { + b = pointById?.get(bId) || null; + } else if (line?.b && typeof line.b === 'object') { + b = line.b; + } + return [a, b]; +} + +function getArcEndpoints(arc, pointById) { + const aId = typeof arc?.a === 'string' ? arc.a : null; + const bId = typeof arc?.b === 'string' ? arc.b : null; + const a = aId ? (pointById?.get(aId) || null) : null; + const b = bId ? (pointById?.get(bId) || null) : null; + return [a, b]; +} + +function getArcRenderPoints(arc, a, b, segments = 32) { + if (isCircleCurve(arc)) { + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + let radius = Number(arc?.radius); + if (!Number.isFinite(radius) || radius <= 0) { + if (a) { + radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy); + } + } + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(radius) || radius <= 0) { + return []; + } + const count = Math.max(32, segments * 2); + let start = 0; + if (a) { + start = Math.atan2((a.y || 0) - cy, (a.x || 0) - cx); + } + const pts = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const ang = start + t * Math.PI * 2; + pts.push({ + x: cx + Math.cos(ang) * radius, + y: cy + Math.sin(ang) * radius + }); + } + return pts; + } + let cx = Number(arc?.cx); + let cy = Number(arc?.cy); + let radius = Number(arc?.radius); + let startAngle = Number(arc?.startAngle); + let endAngle = Number(arc?.endAngle); + let ccw = arc?.ccw !== false; + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) && a && b) { + const geom = computeArcFromThreePoints( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (geom) { + cx = geom.cx; + cy = geom.cy; + radius = geom.radius; + startAngle = geom.startAngle; + endAngle = geom.endAngle; + ccw = geom.ccw; + } + } + if (!Number.isFinite(cx) || !Number.isFinite(cy) || !Number.isFinite(startAngle) || !Number.isFinite(endAngle)) { + return []; + } + if (!Number.isFinite(radius) || radius <= 0) { + if (a) { + radius = Math.hypot((a.x || 0) - cx, (a.y || 0) - cy); + } + } + if (!Number.isFinite(radius) || radius <= 0) { + return []; + } + const tau = Math.PI * 2; + let sweep; + if (ccw) { + sweep = (endAngle - startAngle) % tau; + if (sweep < 0) sweep += tau; + } else { + sweep = (startAngle - endAngle) % tau; + if (sweep < 0) sweep += tau; + sweep = -sweep; + } + const count = Math.max(8, segments); + const pts = []; + for (let i = 0; i <= count; i++) { + const t = i / count; + const ang = startAngle + sweep * t; + pts.push({ + x: cx + Math.cos(ang) * radius, + y: cy + Math.sin(ang) * radius + }); + } + if (a) pts[0] = { x: a.x || 0, y: a.y || 0 }; + if (b) pts[pts.length - 1] = { x: b.x || 0, y: b.y || 0 }; + return pts; +} + +function getArcCenterLocal(arc, a, b) { + if (isCircleCurve(arc)) { + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + } + if (Number.isFinite(arc?.mx) && Number.isFinite(arc?.my) && a && b) { + const geom = computeArcFromThreePoints( + { x: a.x || 0, y: a.y || 0 }, + { x: b.x || 0, y: b.y || 0 }, + { x: arc.mx, y: arc.my } + ); + if (geom) { + return { x: geom.cx, y: geom.cy }; + } + } + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + return null; +} + +function computeArcFromThreePoints(start, end, onArc) { + const x1 = start.x || 0; + const y1 = start.y || 0; + const x2 = end.x || 0; + const y2 = end.y || 0; + const x3 = onArc.x || 0; + const y3 = onArc.y || 0; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) { + return null; + } + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const cx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const cy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + const radius = Math.hypot(x1 - cx, y1 - cy); + if (!Number.isFinite(radius) || radius < 1e-6) { + return null; + } + const startAngle = Math.atan2(y1 - cy, x1 - cx); + const endAngle = Math.atan2(y2 - cy, x2 - cx); + const midAngle = Math.atan2(y3 - cy, x3 - cx); + const normalize = a => { + let out = a % (Math.PI * 2); + if (out < 0) out += Math.PI * 2; + return out; + }; + const sa = normalize(startAngle); + const ea = normalize(endAngle); + const ma = normalize(midAngle); + const ccwSpan = (ea - sa + Math.PI * 2) % (Math.PI * 2); + const ccwMid = (ma - sa + Math.PI * 2) % (Math.PI * 2); + const ccw = ccwMid <= ccwSpan; + return { cx, cy, radius, startAngle, endAngle, ccw }; +} + +export { + getLineEndpoints, + getArcEndpoints, + getArcRenderPoints, + getArcCenterLocal, + computeArcFromThreePoints +}; diff --git a/src/void/sketch/runtime_markers.js b/src/void/sketch/runtime_markers.js new file mode 100644 index 00000000..a8287477 --- /dev/null +++ b/src/void/sketch/runtime_markers.js @@ -0,0 +1,247 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; + +function attachColorShim(material, key, fallbackHex = 0xffffff) { + if (!material?.uniforms?.[key]) return; + const uni = material.uniforms[key]; + const fallback = new THREE.Color(fallbackHex); + material.color = { + setHex(hex) { + if (uni.value?.setHex) uni.value.setHex(hex); + else uni.value = new THREE.Color(hex); + }, + getHex() { + if (uni.value?.getHex) return uni.value.getHex(); + if (uni.value?.isColor) return uni.value.getHex(); + return fallback.getHex(); + } + }; +} + +function createShaderPointSymbol(opts = {}) { + const size = Number(opts.sizePx || 14); + const coreR = Number(opts.coreR || 0.17); + const ringBlackR = Number(opts.ringBlackR || 0.23); + const ringWhiteR = Number(opts.ringWhiteR || 0.29); + const ringHighlightR = Number(opts.ringHighlightR || 0.36); + const thickness = Number(opts.thickness || 0.028); + const uniforms = { + uSize: { value: size }, + uCoreColor: { value: new THREE.Color(opts.coreColor || 0x8f8f8f) }, + uRingBlackColor: { value: new THREE.Color(opts.ringBlackColor || 0x101010) }, + uRingWhiteColor: { value: new THREE.Color(opts.ringWhiteColor || 0xffffff) }, + uHighlightColor: { value: new THREE.Color(opts.highlightColor || 0xff9933) }, + uShowBaseRings: { value: Number(opts.showBaseRings === false ? 0 : 1) }, + uCoreR: { value: coreR }, + uRingBlackR: { value: ringBlackR }, + uRingWhiteR: { value: ringWhiteR }, + uRingHighlightR: { value: ringHighlightR }, + uThickness: { value: thickness }, + uHighlight: { value: Number(opts.highlight || 0) } + }; + const mat = new THREE.ShaderMaterial({ + uniforms, + transparent: true, + depthWrite: false, + depthTest: false, + vertexShader: ` + uniform float uSize; + void main() { + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_Position = projectionMatrix * mv; + gl_PointSize = uSize; + } + `, + fragmentShader: ` + #include + uniform vec3 uCoreColor; + uniform vec3 uRingBlackColor; + uniform vec3 uRingWhiteColor; + uniform vec3 uHighlightColor; + uniform float uShowBaseRings; + uniform float uCoreR; + uniform float uRingBlackR; + uniform float uRingWhiteR; + uniform float uRingHighlightR; + uniform float uThickness; + uniform float uHighlight; + uniform float uSize; + + float band(float r, float c, float w) { + float d = abs(r - c); + float aa = max(fwidth(r) * 1.5, 1.0 / max(8.0, uSize)); + return 1.0 - smoothstep(w, w + aa, d); + } + + void main() { + vec2 p = gl_PointCoord - vec2(0.5); + float r = length(p); + if (r > 0.5) discard; + + vec3 col = uCoreColor; + float a = 0.0; + + float aa = max(fwidth(r) * 1.5, 1.0 / max(8.0, uSize)); + float core = 1.0 - smoothstep(uCoreR, uCoreR + aa, r); + if (core > 0.001) { + col = uCoreColor; + a = max(a, core); + } + + if (uShowBaseRings > 0.5) { + float blk = band(r, uRingBlackR, uThickness); + if (blk > 0.001) { + col = mix(col, uRingBlackColor, blk); + a = max(a, blk); + } + + float wht = band(r, uRingWhiteR, uThickness); + if (wht > 0.001) { + col = mix(col, uRingWhiteColor, wht); + a = max(a, wht); + } + } + + if (uHighlight > 0.5) { + float hi = band(r, uRingHighlightR, uThickness); + if (hi > 0.001) { + col = mix(col, uHighlightColor, hi); + a = max(a, hi); + } + } + + if (a < 0.01) discard; + gl_FragColor = vec4(col, a); + #include + #include + } + ` + }); + attachColorShim(mat, 'uCoreColor', 0x8f8f8f); + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0], 3)); + const pts = new THREE.Points(geom, mat); + pts.frustumCulled = false; + pts.renderOrder = 10; + return { points: pts, material: mat, uniforms }; +} + +function createSketchPointMarker(x = 0, y = 0, opts = {}, colors = {}) { + const marker = new THREE.Group(); + marker.position.set(x, y, 0); + marker.renderOrder = 8; + marker.userData._shaderPoint = true; + + const pickCore = new THREE.Mesh( + new THREE.CircleGeometry(0.72, 16), + new THREE.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0, + depthWrite: false + }) + ); + pickCore.material.colorWrite = false; + pickCore.userData.sketchPointPick = true; + marker.add(pickCore); + + const sym = createShaderPointSymbol({ + sizePx: opts.virtualOrigin ? 15 : 13, + coreColor: 0x8f8f8f, + ringBlackColor: 0x101010, + ringWhiteColor: 0xffffff, + highlightColor: colors.linesHover || 0xff9933, + coreR: 0.17, + ringBlackR: 0.23, + ringWhiteR: 0.29, + ringHighlightR: 0.36, + thickness: 0.028 + }); + marker.add(sym.points); + const ringHighlight = { + material: sym.material, + color: { + setHex(hex) { + if (sym.uniforms.uHighlightColor.value?.setHex) sym.uniforms.uHighlightColor.value.setHex(hex); + }, + getHex() { + return sym.uniforms.uHighlightColor.value?.getHex?.() || 0xff9933; + } + }, + get visible() { + return !!(sym.uniforms.uHighlight.value > 0.5); + }, + set visible(v) { + sym.uniforms.uHighlight.value = v ? 1 : 0; + } + }; + const ringWhite = { + material: { + color: { + setHex(hex) { + if (sym.uniforms.uRingWhiteColor.value?.setHex) sym.uniforms.uRingWhiteColor.value.setHex(hex); + }, + getHex() { + return sym.uniforms.uRingWhiteColor.value?.getHex?.() || 0xffffff; + } + }, + get depthTest() { + return sym.material.depthTest; + }, + set depthTest(v) { + sym.material.depthTest = !!v; + } + } + }; + const ringBlack = { + material: { + color: { + setHex(hex) { + if (sym.uniforms.uRingBlackColor.value?.setHex) sym.uniforms.uRingBlackColor.value.setHex(hex); + }, + getHex() { + return sym.uniforms.uRingBlackColor.value?.getHex?.() || 0x101010; + } + }, + get depthTest() { + return sym.material.depthTest; + }, + set depthTest(v) { + sym.material.depthTest = !!v; + } + } + }; + + marker.userData._markerParts = { + core: sym.points, + ringBlack, + ringWhite, + ringBase: { + material: sym.material, + get visible() { + return !!(sym.uniforms.uShowBaseRings.value > 0.5); + }, + set visible(v) { + sym.uniforms.uShowBaseRings.value = v ? 1 : 0; + } + }, + ringHighlight, + ringOuter: { material: sym.material }, + ringInner: { material: sym.material } + }; + marker.userData._isVirtualOrigin = !!opts.virtualOrigin; + + return marker; +} + +function createArcCenterMarker(x = 0, y = 0, colors = {}) { + const marker = createSketchPointMarker(x, y, {}, colors); + marker.userData._isArcCenter = true; + return marker; +} + +export { + createSketchPointMarker, + createArcCenterMarker +}; diff --git a/src/void/sketch/runtime_profiles.js b/src/void/sketch/runtime_profiles.js new file mode 100644 index 00000000..3de1cfe0 --- /dev/null +++ b/src/void/sketch/runtime_profiles.js @@ -0,0 +1,411 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; +import { ClipperLib } from '../../ext/clip2.esm.js'; + +const PROFILE_MERGE_EPS = 1e-3; +const CLIPPER_SCALE = 100000; + +function addClosedProfileFills(rec, entities, pointById) { + const loops = this.findClosedCurveLoops(rec.feature, entities, pointById); + const regions = buildProfileRegions(loops); + for (let index = 0; index < regions.length; index++) { + const region = regions[index]; + const outer = region?.outer; + const holes = Array.isArray(region?.holes) ? region.holes : []; + if (!Array.isArray(outer) || outer.length < 3) continue; + const shape = loopToShapePath(ensureLoopWinding(outer, true), THREE.Shape); + if (!shape) continue; + for (const hole of holes) { + const path = loopToShapePath(ensureLoopWinding(hole, false), THREE.Path); + if (path) shape.holes.push(path); + } + const geom = new THREE.ShapeGeometry(shape); + const mat = new THREE.MeshBasicMaterial({ + color: 0x8f8f8f, + transparent: true, + opacity: 0.18, + depthWrite: false, + polygonOffset: true, + polygonOffsetFactor: -1, + polygonOffsetUnits: -1, + side: THREE.DoubleSide + }); + const fill = new THREE.Mesh(geom, mat); + fill.position.z = 0; + fill.renderOrder = 6; + const profileId = `profile-${index}`; + fill.userData.sketchEntityId = profileId; + fill.userData.sketchEntityType = 'profile'; + fill.userData.sketchProfileId = profileId; + fill.userData.sketchFeatureId = rec.feature?.id || null; + const profileLoops = [ + ensureLoopWinding(outer, true), + ...holes.map(loop => ensureLoopWinding(loop, false)) + ] + .map(loop => (Array.isArray(loop) ? loop.map(p => ({ x: p.x || 0, y: p.y || 0 })) : null)) + .filter(loop => Array.isArray(loop) && loop.length >= 3); + fill.userData.sketchProfileLoops = profileLoops; + fill.userData.sketchProfileLoop = profileLoops[0] || null; + rec.entitiesGroup.add(fill); + rec.entityViews.set(profileId, { + entity: { + id: profileId, + type: 'profile', + loop: fill.userData.sketchProfileLoop, + loops: profileLoops + }, + object: fill, + type: 'profile' + }); + } +} + +function loopToShapePath(loop, Ctor = THREE.Path) { + if (!Array.isArray(loop) || loop.length < 3) return null; + const path = new Ctor(); + path.moveTo(loop[0].x || 0, loop[0].y || 0); + for (let i = 1; i < loop.length; i++) { + path.lineTo(loop[i].x || 0, loop[i].y || 0); + } + path.closePath(); + return path; +} + +function simplifyLoopsWithClipper(loops) { + if (!Array.isArray(loops) || !loops.length || !ClipperLib?.Clipper) { + return loops || []; + } + const out = []; + const fill = ClipperLib.PolyFillType.pftEvenOdd; + for (const loop of loops) { + if (!Array.isArray(loop) || loop.length < 3) continue; + const path = []; + for (const p of loop) { + path.push({ + X: Math.round((p.x || 0) * CLIPPER_SCALE), + Y: Math.round((p.y || 0) * CLIPPER_SCALE) + }); + } + if (path.length < 3) continue; + const simp = ClipperLib.Clipper.SimplifyPolygon(path, fill) || []; + if (!simp.length) { + out.push(loop); + continue; + } + for (const poly of simp) { + if (!Array.isArray(poly) || poly.length < 3) continue; + out.push(poly.map(pt => ({ + x: (pt.X || 0) / CLIPPER_SCALE, + y: (pt.Y || 0) / CLIPPER_SCALE + }))); + } + } + return out.length ? out : loops; +} + +function findClosedCurveLoops(feature, entities, pointById) { + const curves = entities.filter(e => (e?.type === 'line' || e?.type === 'arc') && !e.construction); + if (!curves.length) return []; + + const q = v => Math.round(v / PROFILE_MERGE_EPS) * PROFILE_MERGE_EPS; + const nodes = new Map(); + const nodeCoord = new Map(); + const baseSegments = []; + let nodeSeq = 0; + + const getNodeId = (x, y) => { + const key = `${q(x)},${q(y)}`; + let node = nodes.get(key); + if (!node) { + node = { id: `n${++nodeSeq}`, x, y }; + nodes.set(key, node); + nodeCoord.set(node.id, { x, y }); + } + return node.id; + }; + + for (const curve of curves) { + let poly = null; + if (curve.type === 'line') { + const [a, b] = this.getLineEndpoints(curve, pointById); + if (a && b) { + poly = [{ x: a.x || 0, y: a.y || 0 }, { x: b.x || 0, y: b.y || 0 }]; + } + } else if (curve.type === 'arc') { + const [a, b] = this.getArcEndpoints(curve, pointById); + if (a && b) { + poly = this.getArcRenderPoints(curve, a, b, this.getArcSegmentsFor?.(curve, a, b, 'profile') || 64); + } + } + if (!poly || poly.length < 2) continue; + + for (let i = 0; i < poly.length - 1; i++) { + const p1 = poly[i]; + const p2 = poly[i + 1]; + const x1 = p1.x || 0; + const y1 = p1.y || 0; + const x2 = p2.x || 0; + const y2 = p2.y || 0; + if (Math.hypot(x2 - x1, y2 - y1) < PROFILE_MERGE_EPS) continue; + baseSegments.push({ id: baseSegments.length, a: { x: x1, y: y1 }, b: { x: x2, y: y2 }, ts: [0, 1] }); + } + } + if (!baseSegments.length) return []; + + const segEps = 1e-9; + for (let i = 0; i < baseSegments.length; i++) { + const s1 = baseSegments[i]; + for (let j = i + 1; j < baseSegments.length; j++) { + const s2 = baseSegments[j]; + const hit = this.segmentIntersectionParams(s1.a, s1.b, s2.a, s2.b, segEps); + if (!hit || hit.collinear) continue; + if (Number.isFinite(hit.t) && hit.t >= -segEps && hit.t <= 1 + segEps) { + s1.ts.push(Math.max(0, Math.min(1, hit.t))); + } + if (Number.isFinite(hit.u) && hit.u >= -segEps && hit.u <= 1 + segEps) { + s2.ts.push(Math.max(0, Math.min(1, hit.u))); + } + } + } + + const edges = []; + const edgeKeys = new Set(); + const uniqueSorted = list => { + const out = Array.from(new Set(list.map(v => Number(v.toFixed(12))))); + out.sort((a, b) => a - b); + return out; + }; + for (const seg of baseSegments) { + const ts = uniqueSorted(seg.ts).filter(t => t >= 0 && t <= 1); + if (ts.length < 2) continue; + const sx = seg.a.x; + const sy = seg.a.y; + const dx = seg.b.x - seg.a.x; + const dy = seg.b.y - seg.a.y; + for (let i = 0; i < ts.length - 1; i++) { + const t0 = ts[i]; + const t1 = ts[i + 1]; + if ((t1 - t0) < 1e-9) continue; + const p0 = { x: sx + dx * t0, y: sy + dy * t0 }; + const p1 = { x: sx + dx * t1, y: sy + dy * t1 }; + if (Math.hypot(p1.x - p0.x, p1.y - p0.y) < PROFILE_MERGE_EPS) continue; + const aId = getNodeId(p0.x, p0.y); + const bId = getNodeId(p1.x, p1.y); + if (!aId || !bId || aId === bId) continue; + const key = aId < bId ? `${aId}|${bId}` : `${bId}|${aId}`; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ id: edges.length, a: aId, b: bId }); + } + } + if (!edges.length) return []; + + const halfEdges = []; + const outgoing = new Map(); + const addOutgoing = (nid, heId) => { + if (!outgoing.has(nid)) outgoing.set(nid, []); + outgoing.get(nid).push(heId); + }; + for (const edge of edges) { + const a = nodeCoord.get(edge.a); + const b = nodeCoord.get(edge.b); + if (!a || !b) continue; + const heAB = { id: halfEdges.length, edgeId: edge.id, from: edge.a, to: edge.b, angle: Math.atan2(b.y - a.y, b.x - a.x), twin: -1 }; + halfEdges.push(heAB); + const heBA = { id: halfEdges.length, edgeId: edge.id, from: edge.b, to: edge.a, angle: Math.atan2(a.y - b.y, a.x - b.x), twin: heAB.id }; + halfEdges.push(heBA); + heAB.twin = heBA.id; + addOutgoing(heAB.from, heAB.id); + addOutgoing(heBA.from, heBA.id); + } + for (const [nid, list] of outgoing.entries()) { + list.sort((ha, hb) => halfEdges[ha].angle - halfEdges[hb].angle); + outgoing.set(nid, list); + } + + const visited = new Set(); + const loops = []; + const minArea = 1e-5; + for (const start of halfEdges) { + if (visited.has(start.id)) continue; + const cycleHes = []; + let curr = start; + let guard = 0; + while (curr && !visited.has(curr.id) && guard++ < halfEdges.length * 4) { + visited.add(curr.id); + cycleHes.push(curr.id); + const outAtTo = outgoing.get(curr.to) || []; + if (!outAtTo.length) break; + const twinIndex = outAtTo.indexOf(curr.twin); + if (twinIndex < 0) break; + const nextIndex = (twinIndex - 1 + outAtTo.length) % outAtTo.length; + curr = halfEdges[outAtTo[nextIndex]]; + if (curr.id === start.id) { + cycleHes.push(curr.id); + break; + } + } + if (!cycleHes.length || cycleHes[cycleHes.length - 1] !== start.id) continue; + const nodeIds = []; + for (let i = 0; i < cycleHes.length - 1; i++) nodeIds.push(halfEdges[cycleHes[i]].from); + if (nodeIds.length < 3) continue; + const pts = nodeIds.map(nid => nodeCoord.get(nid)).filter(Boolean); + if (pts.length < 3) continue; + let area2 = 0; + for (let i = 0; i < pts.length; i++) { + const p = pts[i]; + const q2 = pts[(i + 1) % pts.length]; + area2 += p.x * q2.y - q2.x * p.y; + } + if ((area2 * 0.5) > minArea) { + loops.push(pts.map(p => ({ x: p.x, y: p.y }))); + } + } + return loops; +} + +function segmentIntersectionParams(a, b, c, d, eps = 1e-9) { + const r = { x: (b.x || 0) - (a.x || 0), y: (b.y || 0) - (a.y || 0) }; + const s = { x: (d.x || 0) - (c.x || 0), y: (d.y || 0) - (c.y || 0) }; + const cross = (u, v) => u.x * v.y - u.y * v.x; + const qmp = { x: (c.x || 0) - (a.x || 0), y: (c.y || 0) - (a.y || 0) }; + const denom = cross(r, s); + const qmpxr = cross(qmp, r); + + if (Math.abs(denom) < eps) { + if (Math.abs(qmpxr) < eps) return { collinear: true }; + return null; + } + const t = cross(qmp, s) / denom; + const u = cross(qmp, r) / denom; + if (t < -eps || t > 1 + eps || u < -eps || u > 1 + eps) return null; + return { t, u, collinear: false }; +} + +function findClosedLineLoops(feature, entities, pointById) { + return this.findClosedCurveLoops(feature, entities, pointById); +} + +function polygonAbsArea(loop) { + if (!Array.isArray(loop) || loop.length < 3) return 0; + let area2 = 0; + for (let i = 0; i < loop.length; i++) { + const a = loop[i]; + const b = loop[(i + 1) % loop.length]; + area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0); + } + return Math.abs(area2 * 0.5); +} + +function polygonSignedArea(loop) { + if (!Array.isArray(loop) || loop.length < 3) return 0; + let area2 = 0; + for (let i = 0; i < loop.length; i++) { + const a = loop[i]; + const b = loop[(i + 1) % loop.length]; + area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0); + } + return area2 * 0.5; +} + +function ensureLoopWinding(loop, ccw = true) { + if (!Array.isArray(loop)) return loop; + const signed = polygonSignedArea(loop); + const isCCW = signed > 0; + if ((ccw && isCCW) || (!ccw && !isCCW)) return loop; + return loop.slice().reverse(); +} + +function pointOnSegment(p, a, b, eps = 1e-8) { + const px = p.x || 0; + const py = p.y || 0; + const ax = a.x || 0; + const ay = a.y || 0; + const bx = b.x || 0; + const by = b.y || 0; + const abx = bx - ax; + const aby = by - ay; + const apx = px - ax; + const apy = py - ay; + const cross = abx * apy - aby * apx; + if (Math.abs(cross) > eps) return false; + const dot = apx * abx + apy * aby; + if (dot < -eps) return false; + const len2 = abx * abx + aby * aby; + if (dot - len2 > eps) return false; + return true; +} + +// returns 1 = inside, 0 = boundary, -1 = outside +function pointInPolygonState(point, loop) { + if (!Array.isArray(loop) || loop.length < 3) return -1; + const p = { x: point.x || 0, y: point.y || 0 }; + let inside = false; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) { + const a = loop[i]; + const b = loop[j]; + if (pointOnSegment(p, a, b)) return 0; + const yi = a.y || 0; + const yj = b.y || 0; + const xi = a.x || 0; + const xj = b.x || 0; + const intersect = ((yi > p.y) !== (yj > p.y)) + && (p.x < ((xj - xi) * (p.y - yi)) / ((yj - yi) || 1e-12) + xi); + if (intersect) inside = !inside; + } + return inside ? 1 : -1; +} + +function loopContainsLoop(outer, inner) { + if (!Array.isArray(outer) || !Array.isArray(inner) || outer.length < 3 || inner.length < 3) return false; + let sawInside = false; + for (const p of inner) { + const state = pointInPolygonState(p, outer); + if (state < 0) return false; + if (state > 0) sawInside = true; + } + return sawInside; +} + +function buildProfileRegions(loops) { + const valid = (loops || []) + .filter(loop => Array.isArray(loop) && loop.length >= 3 && polygonAbsArea(loop) > 1e-10) + .map((loop, index) => ({ + id: index, + loop, + area: polygonAbsArea(loop), + parent: null, + children: [] + })); + if (!valid.length) return []; + valid.sort((a, b) => a.area - b.area); + for (let i = 0; i < valid.length; i++) { + const child = valid[i]; + for (let j = i + 1; j < valid.length; j++) { + const parent = valid[j]; + if (loopContainsLoop(parent.loop, child.loop)) { + child.parent = parent; + parent.children.push(child); + break; + } + } + } + // Every loop yields one selectable region: itself minus immediate children. + const regions = []; + for (const node of valid) { + regions.push({ + outer: node.loop, + holes: node.children.map(c => c.loop) + }); + } + return regions; +} + +export { + addClosedProfileFills, + simplifyLoopsWithClipper, + findClosedCurveLoops, + segmentIntersectionParams, + findClosedLineLoops +}; diff --git a/src/void/sketch/runtime_ui.js b/src/void/sketch/runtime_ui.js new file mode 100644 index 00000000..e933c28e --- /dev/null +++ b/src/void/sketch/runtime_ui.js @@ -0,0 +1,1705 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../../ext/three.js'; +import { space } from '../../moto/space.js'; + +function constraintGlyphLabel(type) { + const labels = { + horizontal: 'H', + vertical: 'V', + horizontal_points: 'H', + vertical_points: 'V', + perpendicular: 'P', + collinear: 'L', + coincident: 'C', + point_on_line: 'PL', + point_on_arc: 'PA', + arc_center_coincident: 'C', + arc_center_on_line: 'PL', + arc_center_on_arc: 'PA', + arc_center_fixed_origin: 'C', + fixed: 'F', + tangent: 'T', + equal: '=', + midpoint: 'M', + dimension: 'D', + min_distance: 'd<', + max_distance: 'd>', + polygon_pattern: 'PG', + circular_pattern: 'CP', + grid_pattern: 'GP', + mirror_point: 'MR', + mirror_line: 'MR', + mirror_arc: 'MR' + }; + return labels[type] || '?'; +} + +function formatDimensionLabel(constraint) { + const value = Number(constraint?.data?.value); + if (!Number.isFinite(value) || value <= 0) { + return 'D'; + } + if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) { + return value.toExponential(2); + } + return Number(value.toFixed(3)).toString(); +} + +function getDimensionMode(constraint) { + return constraint?.data?.mode === 'driven' ? 'driven' : 'driving'; +} + +function formatMeasuredValue(value) { + if (!Number.isFinite(value) || value <= 0) return 'D'; + if (Math.abs(value) >= 1000 || Math.abs(value) < 0.01) { + return value.toExponential(2); + } + return Number(value.toFixed(3)).toString(); +} + +function computeArcCenterForUi(arc, a, b) { + const cx = Number(arc?.cx); + const cy = Number(arc?.cy); + if (Number.isFinite(cx) && Number.isFinite(cy)) { + return { x: cx, y: cy }; + } + const mx = Number(arc?.mx); + const my = Number(arc?.my); + if (!Number.isFinite(mx) || !Number.isFinite(my) || !a || !b) { + return null; + } + const x1 = a.x || 0; + const y1 = a.y || 0; + const x2 = b.x || 0; + const y2 = b.y || 0; + const x3 = mx; + const y3 = my; + const d = 2 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)); + if (Math.abs(d) < 1e-8) return null; + const x1sq = x1 * x1 + y1 * y1; + const x2sq = x2 * x2 + y2 * y2; + const x3sq = x3 * x3 + y3 * y3; + const ccx = (x1sq * (y2 - y3) + x2sq * (y3 - y1) + x3sq * (y1 - y2)) / d; + const ccy = (x1sq * (x3 - x2) + x2sq * (x1 - x3) + x3sq * (x2 - x1)) / d; + if (!Number.isFinite(ccx) || !Number.isFinite(ccy)) return null; + return { x: ccx, y: ccy }; +} + +function isArcDimensionConstraint(feature, constraint) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + if (refs.length !== 1) return false; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.map(e => [e?.id, e])); + return byId.get(refs[0])?.type === 'arc'; +} + +function projectLocalToScreen(rec, local, getApi) { + if (!rec?.entitiesGroup || !local) return null; + const world = new THREE.Vector3(local.x || 0, local.y || 0, 0); + rec.entitiesGroup.localToWorld(world); + const proj = getApi().overlay.project3Dto2D(world); + if (!proj?.visible) return null; + return { x: proj.x, y: proj.y }; +} + +function getDimensionEndpoints(feature, constraint) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.map(e => [e?.id, e])); + const refs = Array.isArray(constraint?.ui?.display_refs) && constraint.ui.display_refs.length + ? constraint.ui.display_refs + : (Array.isArray(constraint?.refs) ? constraint.refs : []); + if (refs.length === 1) { + const ent = byId.get(refs[0]); + if (ent?.type === 'line') { + const aId = typeof ent?.a === 'string' ? ent.a : (typeof ent?.p1_id === 'string' ? ent.p1_id : null); + const bId = typeof ent?.b === 'string' ? ent.b : (typeof ent?.p2_id === 'string' ? ent.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type !== 'point' || b?.type !== 'point') return null; + return [a, b]; + } + if (ent?.type === 'arc') { + const aId = typeof ent?.a === 'string' ? ent.a : (typeof ent?.p1_id === 'string' ? ent.p1_id : null); + const bId = typeof ent?.b === 'string' ? ent.b : (typeof ent?.p2_id === 'string' ? ent.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type !== 'point' || b?.type !== 'point') return null; + const center = computeArcCenterForUi(ent, a, b); + if (!center) return null; + return [{ x: center.x, y: center.y, type: 'point' }, { x: a.x || 0, y: a.y || 0, type: 'point' }]; + } + return null; + } + if (refs.length >= 2) { + const a = byId.get(refs[0]); + const b = byId.get(refs[1]); + if (a?.type !== 'point' || b?.type !== 'point') return null; + return [a, b]; + } + return null; +} + +function computeDimensionMeasurement(feature, constraint) { + const pts = getDimensionEndpoints(feature, constraint); + if (!pts) return NaN; + const [a, b] = pts; + const base = Math.hypot((b.x || 0) - (a.x || 0), (b.y || 0) - (a.y || 0)); + return isArcDimensionConstraint(feature, constraint) ? base * 2 : base; +} + +function clearDimensionDecorations3D(rec) { + if (!rec?.dimensionGroup) return; + while (rec.dimensionGroup.children.length) { + const child = rec.dimensionGroup.children[0]; + child.geometry?.dispose?.(); + if (Array.isArray(child.material)) { + for (const mat of child.material) mat?.dispose?.(); + } else { + child.material?.dispose?.(); + } + rec.dimensionGroup.remove(child); + } +} + +function worldPerPixelAt(rec, localPoint) { + const { camera, renderer } = space.internals(); + if (!camera || !renderer || !rec?.entitiesGroup) return null; + const viewHeightPx = renderer.domElement?.clientHeight || renderer.domElement?.height; + if (!viewHeightPx) return null; + const world = new THREE.Vector3(localPoint.x || 0, localPoint.y || 0, 0); + rec.entitiesGroup.localToWorld(world); + if (camera.isPerspectiveCamera) { + const distance = camera.position.distanceTo(world); + const fovRad = camera.fov * Math.PI / 180; + return (2 * Math.tan(fovRad / 2) * distance) / viewHeightPx; + } + if (camera.isOrthographicCamera) { + return ((camera.top - camera.bottom) / camera.zoom) / viewHeightPx; + } + return null; +} + +function screenToSketchLocal(rec, sx, sy) { + const { camera, renderer } = space.internals(); + if (!camera || !renderer || !rec?.entitiesGroup) return null; + const rect = renderer.domElement?.getBoundingClientRect?.(); + if (!rect || !rect.width || !rect.height) return null; + const ndc = new THREE.Vector2( + ((sx - rect.left) / rect.width) * 2 - 1, + -(((sy - rect.top) / rect.height) * 2 - 1) + ); + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(ndc, camera); + const origin = new THREE.Vector3(0, 0, 0); + rec.entitiesGroup.localToWorld(origin); + const normal = new THREE.Vector3(0, 0, 1).applyQuaternion(rec.entitiesGroup.getWorldQuaternion(new THREE.Quaternion())).normalize(); + const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(normal, origin); + const hit = new THREE.Vector3(); + const ok = raycaster.ray.intersectPlane(plane, hit); + if (!ok) return null; + rec.entitiesGroup.worldToLocal(hit); + return { x: hit.x || 0, y: hit.y || 0 }; +} + +function addLocalOffset(anchor, offset) { + return { + x: (anchor?.x || 0) + (offset?.x || 0), + y: (anchor?.y || 0) + (offset?.y || 0) + }; +} + +function getDimensionCenterLocal(rec, feature, constraint, drag = null) { + const anchor = getConstraintAnchorLocal.call(this, feature, constraint); + if (!anchor) return null; + if (drag?.constraintId === constraint?.id && drag?.currentLocal) { + return drag.currentLocal; + } + const localOff = constraint?.ui?.offset_local; + if (localOff && Number.isFinite(localOff.x) && Number.isFinite(localOff.y)) { + return addLocalOffset(anchor, localOff); + } + return anchor; +} + +function addDimensionDecoration3D(rec, c, a, b, opts = {}) { + if (!rec?.dimensionGroup) return; + const dx = (b.x || 0) - (a.x || 0); + const dy = (b.y || 0) - (a.y || 0); + const len = Math.hypot(dx, dy); + if (!Number.isFinite(len) || len < 1e-6) return; + const ux = dx / len; + const uy = dy / len; + const anchor = { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 }; + const center = opts?.centerLocal || anchor; + const proj = p => { + const rx = (p.x || 0) - center.x; + const ry = (p.y || 0) - center.y; + const t = rx * ux + ry * uy; + return { x: center.x + ux * t, y: center.y + uy * t, t }; + }; + const b1 = proj(a); + const b2 = proj(b); + const start = b1.t <= b2.t ? b1 : b2; + const end = b1.t <= b2.t ? b2 : b1; + const wpp = worldPerPixelAt(rec, center); + const offScale = Number.isFinite(wpp) ? wpp : 0.05; + const capLen = 6 * offScale; + const nx = -uy; + const ny = ux; + const mode = getDimensionMode(c); + const palette = opts?.colors || {}; + let color = mode === 'driven' + ? (palette.constraintGlyphDriven || 0x8e8e8e) + : (palette.constraintGlyphDerived || 0xc6c6c6); + if (opts?.hovered) color = 0xff9933; + if (opts?.selected) color = 0x5a9fd4; + + const makeLine = (p1, p2, z = 0.002) => { + const geom = new THREE.BufferGeometry().setFromPoints([ + new THREE.Vector3(p1.x || 0, p1.y || 0, z), + new THREE.Vector3(p2.x || 0, p2.y || 0, z) + ]); + const mat = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.95, + depthTest: true, + depthWrite: false + }); + const line = new THREE.Line(geom, mat); + line.renderOrder = 11; + rec.dimensionGroup.add(line); + }; + + const drawArrowHead = (tip, dir, size = capLen * 0.9) => { + const dlen = Math.hypot(dir.x || 0, dir.y || 0); + if (!Number.isFinite(dlen) || dlen < 1e-8) return; + const ux = (dir.x || 0) / dlen; + const uy = (dir.y || 0) / dlen; + const px = -uy; + const py = ux; + const backX = (tip.x || 0) - ux * size; + const backY = (tip.y || 0) - uy * size; + const wing = size * 0.55; + makeLine(tip, { x: backX + px * wing, y: backY + py * wing }); + makeLine(tip, { x: backX - px * wing, y: backY - py * wing }); + }; + + // Diameter dimensions: inside the circle draw full diameter with end arrows. + // Outside the circle draw a leader with a single arrow touching the circle. + const isArcDim = isArcDimensionConstraint(rec?.feature, c); + if (isArcDim) { + const centerPt = a; + const edgePt = b; + const rdx = (edgePt.x || 0) - (centerPt.x || 0); + const rdy = (edgePt.y || 0) - (centerPt.y || 0); + const radius = Math.hypot(rdx, rdy); + const odx = (center.x || 0) - (centerPt.x || 0); + const ody = (center.y || 0) - (centerPt.y || 0); + const off = Math.hypot(odx, ody); + if (Number.isFinite(radius) && radius > 1e-6 && Number.isFinite(off) && off > radius + 1e-6) { + const ux2 = odx / off; + const uy2 = ody / off; + const touch = { + x: (centerPt.x || 0) + ux2 * radius, + y: (centerPt.y || 0) + uy2 * radius + }; + makeLine(center, touch); + drawArrowHead(touch, { x: touch.x - (center.x || 0), y: touch.y - (center.y || 0) }); + return; + } + const ux2 = Number.isFinite(off) && off > 1e-6 ? odx / off : 1; + const uy2 = Number.isFinite(off) && off > 1e-6 ? ody / off : 0; + const d0 = { + x: (centerPt.x || 0) - ux2 * radius, + y: (centerPt.y || 0) - uy2 * radius + }; + const d1 = { + x: (centerPt.x || 0) + ux2 * radius, + y: (centerPt.y || 0) + uy2 * radius + }; + makeLine(d0, d1); + drawArrowHead(d0, { x: d0.x - d1.x, y: d0.y - d1.y }); + drawArrowHead(d1, { x: d1.x - d0.x, y: d1.y - d0.y }); + return; + } + + // extension lines + makeLine(a, b1); + makeLine(b, b2); + // baseline + makeLine(start, end); + // end caps + makeLine( + { x: start.x - nx * capLen * 0.5, y: start.y - ny * capLen * 0.5 }, + { x: start.x + nx * capLen * 0.5, y: start.y + ny * capLen * 0.5 } + ); + makeLine( + { x: end.x - nx * capLen * 0.5, y: end.y - ny * capLen * 0.5 }, + { x: end.x + nx * capLen * 0.5, y: end.y + ny * capLen * 0.5 } + ); +} + +function applySketchState(rec, getApi, colors) { + const feature = rec.feature || {}; + const visible = feature.visible !== false; + const forcedVisible = this.forcedVisibleIds?.has?.(feature.id) || false; + const hovered = this.hoveredId === feature.id; + const editing = this.editingId === feature.id; + const selected = this.selectedIds.has(feature.id); + + const showPlane = editing || hovered || selected; + const showEntities = visible || forcedVisible || hovered || editing || selected; + + rec.plane.setVisible(showPlane); + rec.entitiesGroup.visible = showEntities; + if (rec.dimensionGroup) { + rec.dimensionGroup.visible = showEntities; + } + + const mode = editing ? 'edit' : (hovered || selected ? 'hover' : 'default'); + this.applyPlaneStyle(rec.plane, mode, colors); + this.applyEntityStyle(rec, mode, colors); + this.applyPreviewLine(rec, mode, editing, colors); + this.applyPreviewFaceSegments(rec, mode, editing, colors); + this.applyPreviewExternalWorld(rec, mode, editing, colors); + this.applyPreviewArc(rec, mode, editing, colors); + this.applyPreviewRect(rec, mode, editing, colors); + this.applyPreviewStart(rec, mode, editing, colors); + this.applyPreviewEnd(rec, mode, editing, colors); + this.applyLabelState(rec, mode, showPlane, getApi, colors); +} + +function applyPlaneStyle(plane, mode, colors) { + const style = mode === 'edit' + ? colors.planeEdit + : mode === 'hover' + ? colors.planeHover + : colors.planeDefault; + plane.setColor(style.fill); + plane.setOpacity(style.fillOpacity); + plane.setOutlineColor(style.outline); + plane.setOutlineOpacity(style.outlineOpacity); +} + +function applyEntityStyle(rec, mode, colors) { + const baseLineColor = mode === 'edit' + ? colors.linesEdit + : mode === 'hover' + ? colors.linesHover + : colors.linesGray; + const basePointColor = colors.pointsGray; + + const hoveredId = rec.interaction?.hoveredId || null; + const selectedIds = rec.interaction?.selectedIds || new Set(); + const mirrorMode = !!rec.interaction?.mirrorMode; + const mirrorAxisId = rec.interaction?.mirrorAxisId || null; + const circularPatternMode = !!rec.interaction?.circularPatternMode; + const circularPatternCenterRef = rec.interaction?.circularPatternCenterRef || null; + const hoveredProfileId = rec.interaction?.hoveredProfileId || null; + const selectedProfileIds = rec.interaction?.selectedProfileIds || new Set(); + const constraintHighlight = this.getConstraintHoverHighlight(rec); + const entities = Array.isArray(rec?.feature?.entities) ? rec.feature.entities : []; + const pointAttachments = new Map(); + for (const entity of entities) { + if ((entity?.type !== 'line' && entity?.type !== 'arc') || !entity?.id) continue; + const aId = typeof entity?.a === 'string' ? entity.a : (typeof entity?.p1_id === 'string' ? entity.p1_id : null); + const bId = typeof entity?.b === 'string' ? entity.b : (typeof entity?.p2_id === 'string' ? entity.p2_id : null); + if (aId) { + if (!pointAttachments.has(aId)) pointAttachments.set(aId, []); + pointAttachments.get(aId).push(entity.id); + } + if (bId) { + if (!pointAttachments.has(bId)) pointAttachments.set(bId, []); + pointAttachments.get(bId).push(entity.id); + } + } + const sketchHovered = mode === 'hover'; + const idleRingColor = colors.pointsRingIdle || colors.linesGray || 0x747474; + const primitivePointCore = colors.pointsPrimitiveCore || 0x101010; + + for (const [id, view] of rec.entityViews.entries()) { + const selected = mode === 'edit' && selectedIds.has(id); + const constrained = mode === 'edit' && constraintHighlight.has(id) && !selected; + const hovered = mode === 'edit' && (hoveredId === id || constrained) && !selected; + + if (view.type === 'line' || view.type === 'arc') { + const isMirrorAxis = mirrorMode && view.type === 'line' && id === mirrorAxisId; + const color = isMirrorAxis + ? (colors.linesMirrorAxis || 0xb07cff) + : selected + ? colors.linesHover + : hovered + ? colors.linesHover + : baseLineColor; + view.object.material.color.setHex(color); + if (view.object.material?.isLineMaterial) { + const width = isMirrorAxis + ? (colors.lineWidths?.selected || 3.4) + : selected + ? (colors.lineWidths?.selected || 3.4) + : hovered + ? (colors.lineWidths?.hover || 3.0) + : (colors.lineWidths?.default || 1.2); + view.object.material.linewidth = width; + const { renderer } = space.internals(); + const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1; + const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1; + view.object.material.resolution?.set?.(w, h); + } + continue; + } + if (view.type === 'profile') { + const activeSelected = selectedProfileIds.has(id); + const activeHovered = hoveredProfileId === id; + const fill = view.object; + if (fill?.material?.color) { + if (activeHovered) { + fill.material.color.setHex(colors.profileFillHover || 0xff9933); + fill.material.opacity = colors.profileOpacityHover ?? 0.24; + } else if (activeSelected) { + fill.material.color.setHex(colors.profileFillSelected || 0x5a9fd4); + fill.material.opacity = colors.profileOpacitySelected ?? 0.28; + } else { + fill.material.color.setHex(colors.profileFillDefault || 0x8f8f8f); + fill.material.opacity = colors.profileOpacityDefault ?? 0.18; + } + // Active sketch profile picks must draw above coplanar solid faces. + const overlay = activeSelected || activeHovered; + fill.material.depthTest = !overlay; + fill.material.depthWrite = false; + fill.renderOrder = overlay ? 55 : 6; + } + continue; + } + if (view.type === 'arc-center') { + const parts = view.object.userData?._markerParts || {}; + const isPatternCenter = circularPatternMode && ( + circularPatternCenterRef === id || + circularPatternCenterRef === String(id || '').replace(/^arc-center:/, '') + ); + const activeEdit = mode === 'edit' && ( + hoveredId === id || + selectedIds.has(id) || + constrained || + isPatternCenter + ); + const coreColor = sketchHovered + ? (isPatternCenter ? (colors.linesMirrorAxis || 0xb07cff) : (colors.linesHover || colors.pointsHover)) + : baseLineColor; + view.object.visible = true; + if (parts.core?.material?.color) { + parts.core.material.color.setHex(coreColor); + } + if (parts.core?.material?.uniforms?.uCoreColor?.value?.setHex) { + parts.core.material.uniforms.uCoreColor.value.setHex(coreColor); + } + if (parts.ringBase) parts.ringBase.visible = false; + if (parts.core?.material?.uniforms?.uShowBaseRings) { + parts.core.material.uniforms.uShowBaseRings.value = 0; + } + if (parts.ringHighlight) { + parts.ringHighlight.visible = !!activeEdit; + if (parts.ringHighlight.color) { + parts.ringHighlight.color.setHex(isPatternCenter ? (colors.linesMirrorAxis || 0xb07cff) : (colors.linesHover || 0xff9933)); + } + } + if (parts.ringWhite?.material?.color) { + parts.ringWhite.material.color.setHex(mode === 'edit' ? 0xffffff : idleRingColor); + } + if (parts.ringBlack?.material?.color) { + parts.ringBlack.material.color.setHex(0x101010); + } + continue; + } + + if (view.type === 'point') { + const parts = view.object.userData?._markerParts || {}; + const attachments = pointAttachments.get(id) || []; + const attached = attachments.length > 0; + const isPatternCenter = circularPatternMode && ( + circularPatternCenterRef === id || + circularPatternCenterRef === `arc-center:${id}` + ); + const activeEdit = mode === 'edit' && (selected || hovered || constrained); + const pointColor = activeEdit + ? (isPatternCenter ? (colors.linesMirrorAxis || 0xb07cff) : (colors.linesHover || colors.pointsHover)) + : (attached + ? (sketchHovered + ? (isPatternCenter ? (colors.linesMirrorAxis || 0xb07cff) : (colors.linesHover || colors.pointsHover)) + : baseLineColor) + : primitivePointCore); + if (parts.core?.material?.color) { + parts.core.material.color.setHex(pointColor); + } + if (parts.core?.material?.uniforms?.uCoreColor?.value?.setHex) { + parts.core.material.uniforms.uCoreColor.value.setHex(pointColor); + } + if (parts.ringBase) parts.ringBase.visible = !attached; + if (parts.core?.material?.uniforms?.uShowBaseRings) { + parts.core.material.uniforms.uShowBaseRings.value = attached ? 0 : 1; + } + if (parts.ringHighlight) { + parts.ringHighlight.visible = !!activeEdit; + if (parts.ringHighlight.color) { + parts.ringHighlight.color.setHex(isPatternCenter ? (colors.linesMirrorAxis || 0xb07cff) : (colors.linesHover || 0xff9933)); + } + } + if (parts.ringWhite?.material?.color) { + parts.ringWhite.material.color.setHex(mode === 'edit' ? 0xffffff : idleRingColor); + } + if (parts.ringBlack?.material?.color) { + parts.ringBlack.material.color.setHex(0x101010); + } + } + } +} + +function getConstraintHoverHighlight(rec) { + const out = new Set(); + const hoveredConstraintId = rec?.interaction?.hoveredConstraintId || null; + if (!hoveredConstraintId) { + return out; + } + const constraints = Array.isArray(rec?.feature?.constraints) ? rec.feature.constraints : []; + const entities = Array.isArray(rec?.feature?.entities) ? rec.feature.entities : []; + const byId = new Map(entities.map(e => [e?.id, e])); + const addEntityRef = ref => { + if (!ref) return; + out.add(ref); + const ent = byId.get(ref); + if (ent?.type === 'arc') { + out.add(`arc-center:${ref}`); + } + }; + const c = constraints.find(cst => cst?.id === hoveredConstraintId); + if (!c) return out; + const refs = Array.isArray(c.refs) ? c.refs : []; + const visRefs = Array.isArray(c?.ui?.display_refs) && c.ui.display_refs.length + ? c.ui.display_refs + : refs; + if (c?.type === 'circular_pattern') { + const centerRef = typeof c?.data?.centerRef === 'string' ? c.data.centerRef : visRefs[0]; + if (centerRef) out.add(centerRef); + const sourceIds = Array.isArray(c?.data?.sourceIds) ? c.data.sourceIds : visRefs.slice(1); + for (const id of sourceIds) out.add(id); + return out; + } + if (c?.type === 'grid_pattern') { + const centerRef = typeof c?.data?.centerPointId === 'string' ? c.data.centerPointId : visRefs[0]; + if (centerRef) out.add(centerRef); + if (c?.data?.uLineId) out.add(c.data.uLineId); + if (c?.data?.vLineId) out.add(c.data.vLineId); + const sourceIds = Array.isArray(c?.data?.sourceIds) ? c.data.sourceIds : visRefs.slice(1); + for (const id of sourceIds) addEntityRef(id); + for (const rec of (Array.isArray(c?.data?.copies) ? c.data.copies : [])) { + const ids = Array.isArray(rec) + ? rec + : (Array.isArray(rec?.ids) ? rec.ids : []); + for (const id of ids) addEntityRef(id); + } + for (const rec of (Array.isArray(c?.data?.pointMaps) ? c.data.pointMaps : [])) { + const pairs = Array.isArray(rec) + ? rec + : (Array.isArray(rec?.pairs) ? rec.pairs : []); + for (const pair of pairs) { + if (!Array.isArray(pair) || pair.length < 2) continue; + addEntityRef(pair[0]); + addEntityRef(pair[1]); + } + } + return out; + } + if (c?.type === 'arc_center_coincident' && visRefs.length >= 2) { + const arcId = visRefs[0]; + const pointId = visRefs[1]; + if (typeof arcId === 'string' && arcId) { + out.add(`arc-center:${arcId}`); + } + if (pointId) out.add(pointId); + return out; + } + if (c?.type === 'arc_center_on_line' && visRefs.length >= 2) { + const arcId = visRefs.find(ref => byId.get(ref)?.type === 'arc') || visRefs[0]; + const lineId = visRefs.find(ref => byId.get(ref)?.type === 'line') || visRefs[1]; + if (typeof arcId === 'string' && arcId) out.add(`arc-center:${arcId}`); + if (lineId) out.add(lineId); + return out; + } + if (c?.type === 'arc_center_on_arc' && visRefs.length >= 2) { + const arcs = visRefs.filter(ref => byId.get(ref)?.type === 'arc'); + if (arcs.length >= 2) { + out.add(`arc-center:${arcs[0]}`); + out.add(arcs[1]); + return out; + } + } + if (c?.type === 'arc_center_fixed_origin' && visRefs.length >= 1) { + const arcId = visRefs[0]; + if (typeof arcId === 'string' && arcId) out.add(`arc-center:${arcId}`); + return out; + } + const pointRefs = []; + for (const ref of visRefs) { + if (!ref) continue; + out.add(ref); + const ent = byId.get(ref); + if (ent?.type === 'point') { + pointRefs.push(ref); + } + } + if (pointRefs.length) { + for (const ent of entities) { + if ((ent?.type !== 'line' && ent?.type !== 'arc') || !ent.id) continue; + if (pointRefs.includes(ent.a) || pointRefs.includes(ent.b)) { + out.add(ent.id); + } + } + } + return out; +} + +function setLineObjectPoints(lineObject, points = []) { + if (!lineObject || !Array.isArray(points) || points.length < 2) { + return; + } + if (lineObject.material?.isLineMaterial && lineObject.geometry?.setPositions) { + const flat = []; + for (const p of points) { + flat.push(p.x || 0, p.y || 0, p.z || 0); + } + // Debug mode: disable Line2 geometry reuse to isolate stateful buffer issues. + const NextGeometry = lineObject.geometry?.constructor; + const next = NextGeometry ? new NextGeometry() : null; + if (next?.setPositions) { + lineObject.geometry?.dispose?.(); + lineObject.geometry = next; + } + lineObject.geometry.setPositions(flat); + lineObject.computeLineDistances?.(); + lineObject.geometry.computeBoundingSphere?.(); + return; + } + const verts = points.map(p => new THREE.Vector3(p.x || 0, p.y || 0, p.z || 0)); + lineObject.geometry?.dispose?.(); + lineObject.geometry = new THREE.BufferGeometry().setFromPoints(verts); +} + +function setLineObjectStyle(lineObject, color, width, depthTest = false) { + if (!lineObject?.material) return; + if (lineObject.material.color) { + lineObject.material.color.setHex(color); + } + lineObject.material.depthTest = depthTest; + if (lineObject.material.isLineMaterial) { + if (Number.isFinite(width)) { + lineObject.material.linewidth = width; + } + const { renderer } = space.internals(); + const w = renderer?.domElement?.clientWidth || renderer?.domElement?.width || 1; + const h = renderer?.domElement?.clientHeight || renderer?.domElement?.height || 1; + lineObject.material.resolution?.set?.(w, h); + } +} + +function applyPreviewLine(rec, mode, editing, colors) { + if (!rec.previewLine) return; + const preview = rec.interaction?.previewLine; + const forceHover = !!preview?.forceHover; + if (!(editing || forceHover) || !preview?.a || !preview?.b) { + rec.previewLine.visible = false; + return; + } + const a = { x: preview.a.x || 0, y: preview.a.y || 0, z: 0 }; + const b = { x: preview.b.x || 0, y: preview.b.y || 0, z: 0 }; + setLineObjectPoints(rec.previewLine, [a, b]); + const useHover = !!preview.forceHover; + const projected = !!preview.projected; + const lineColor = projected + ? (colors.linesProjectedFace || 0x5a9fd4) + : (useHover ? colors.linesHover : (mode === 'edit' ? colors.linesEdit : colors.linesHover)); + setLineObjectStyle(rec.previewLine, lineColor, colors.lineWidths?.hover || 3.0, false); + rec.previewLine.visible = true; + rec.previewLine.renderOrder = 60; +} + +function applyPreviewExternalWorld(rec, mode, editing, colors) { + const line = rec.previewExternalWorldLine; + const segments = rec.previewExternalWorldSegments; + const point = rec.previewExternalWorldPoint; + const srcLine = rec.interaction?.previewExternalWorldLine; + const srcSegments = rec.interaction?.previewExternalWorldSegments; + const srcPoint = rec.interaction?.previewExternalWorldPoint; + const forceHover = !!(srcLine?.forceHover || srcPoint?.forceHover || (Array.isArray(srcSegments) && srcSegments.length)); + if (!(editing || forceHover)) { + if (line) line.visible = false; + if (segments) segments.visible = false; + if (point) point.visible = false; + return; + } + if (line && srcLine?.a && srcLine?.b) { + const a = new THREE.Vector3(srcLine.a.x || 0, srcLine.a.y || 0, srcLine.a.z || 0); + const b = new THREE.Vector3(srcLine.b.x || 0, srcLine.b.y || 0, srcLine.b.z || 0); + // srcLine points are scene/world-space; convert into this line's parent-local space. + if (line.parent?.worldToLocal) { + line.parent.updateMatrixWorld?.(true); + line.parent.worldToLocal(a); + line.parent.worldToLocal(b); + } + setLineObjectPoints(line, [a, b]); + setLineObjectStyle(line, colors.linesHover || 0xff9933, colors.lineWidths?.hover || 3.0, false); + line.visible = true; + line.renderOrder = 60; + } else if (line) { + line.visible = false; + } + if (segments && Array.isArray(srcSegments) && srcSegments.length) { + const verts = []; + const parent = segments.parent || null; + if (parent?.worldToLocal) parent.updateMatrixWorld?.(true); + for (const seg of srcSegments) { + const sa = seg?.a; + const sb = seg?.b; + if (!sa || !sb) continue; + const a = new THREE.Vector3(sa.x || 0, sa.y || 0, sa.z || 0); + const b = new THREE.Vector3(sb.x || 0, sb.y || 0, sb.z || 0); + if (parent?.worldToLocal) { + parent.worldToLocal(a); + parent.worldToLocal(b); + } + verts.push(a.x, a.y, a.z); + verts.push(b.x, b.y, b.z); + } + if (segments.material?.isLineMaterial && segments.geometry?.setPositions) { + // Debug mode: disable LineSegments2 geometry reuse to isolate stateful buffer issues. + const NextGeometry = segments.geometry?.constructor; + const next = NextGeometry ? new NextGeometry() : null; + if (next?.setPositions) { + segments.geometry?.dispose?.(); + segments.geometry = next; + } + segments.geometry.setPositions(verts); + segments.computeLineDistances?.(); + segments.geometry.computeBoundingSphere?.(); + } else { + segments.geometry?.dispose?.(); + segments.geometry = new THREE.BufferGeometry(); + segments.geometry.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + } + setLineObjectStyle(segments, colors.linesHover || 0xff9933, colors.lineWidths?.hover || 3.0, false); + segments.visible = true; + segments.renderOrder = 60; + } else if (segments) { + segments.visible = false; + } + if (point && srcPoint) { + const p = new THREE.Vector3(srcPoint.x || 0, srcPoint.y || 0, srcPoint.z || 0); + // srcPoint is scene/world-space; convert into marker parent-local space. + if (point.parent?.worldToLocal) { + point.parent.updateMatrixWorld?.(true); + point.parent.worldToLocal(p); + } + point.position.copy(p); + const parts = point.userData?._markerParts || {}; + if (parts.core?.material?.color) parts.core.material.color.setHex(colors.pointsDerivedActual || 0x39d7ff); + if (parts.core?.material) parts.core.material.depthTest = false; + if (parts.ringWhite?.material?.color) parts.ringWhite.material.color.setHex(0xffffff); + if (parts.ringWhite?.material) parts.ringWhite.material.depthTest = false; + if (parts.ringOuter?.material) parts.ringOuter.material.depthTest = false; + if (parts.ringInner?.material) parts.ringInner.material.depthTest = false; + if (parts.ringHighlight) parts.ringHighlight.visible = false; + point.visible = true; + point.renderOrder = 60; + } else if (point) { + point.visible = false; + } +} + +function applyPreviewFaceSegments(rec, mode, editing, colors) { + if (!rec.previewFaceSegments) return; + const segs = rec.interaction?.previewFaceSegments || null; + if (!editing || !Array.isArray(segs) || !segs.length) { + rec.previewFaceSegments.visible = false; + return; + } + const verts = []; + for (const seg of segs) { + const a = seg?.a; + const b = seg?.b; + if (!a || !b) continue; + verts.push(a.x || 0, a.y || 0, 0.002); + verts.push(b.x || 0, b.y || 0, 0.002); + } + if (!verts.length) { + rec.previewFaceSegments.visible = false; + return; + } + if (rec.previewFaceSegments.material?.isLineMaterial && rec.previewFaceSegments.geometry?.setPositions) { + // Debug mode: disable LineSegments2 geometry reuse to isolate stateful buffer issues. + const NextGeometry = rec.previewFaceSegments.geometry?.constructor; + const next = NextGeometry ? new NextGeometry() : null; + if (next?.setPositions) { + rec.previewFaceSegments.geometry?.dispose?.(); + rec.previewFaceSegments.geometry = next; + } + rec.previewFaceSegments.geometry.setPositions(verts); + rec.previewFaceSegments.computeLineDistances?.(); + rec.previewFaceSegments.geometry.computeBoundingSphere?.(); + } else { + rec.previewFaceSegments.geometry?.dispose?.(); + rec.previewFaceSegments.geometry = new THREE.BufferGeometry(); + rec.previewFaceSegments.geometry.setAttribute('position', new THREE.Float32BufferAttribute(verts, 3)); + } + setLineObjectStyle( + rec.previewFaceSegments, + colors.linesProjectedFace || 0x5a9fd4, + colors.lineWidths?.hover || 3.0, + false + ); + rec.previewFaceSegments.renderOrder = 55; + rec.previewFaceSegments.visible = true; +} + +function applyPreviewStart(rec, mode, editing, colors) { + if (!rec.previewStart) return; + const start = rec.interaction?.previewStart; + const forceHover = !!rec.interaction?.previewLine?.forceHover; + if (!(editing || forceHover) || !start) { + rec.previewStart.visible = false; + return; + } + rec.previewStart.position.set(start.x || 0, start.y || 0, 0); + const projected = !!start?.projected; + const parts = rec.previewStart.userData?._markerParts || {}; + if (parts.core?.material?.color) { + parts.core.material.color.setHex(colors.pointsGray); + parts.core.material.depthTest = false; + } + if (parts.ringHighlight) { + parts.ringHighlight.visible = true; + if (parts.ringHighlight.color) { + parts.ringHighlight.color.setHex(projected ? (colors.linesProjectedFace || 0x5a9fd4) : (colors.linesHover || 0xff9933)); + } + if (parts.ringHighlight.material) { + parts.ringHighlight.material.depthTest = false; + } + } + if (parts.ringOuter?.material) { + parts.ringOuter.material.depthTest = false; + } + if (parts.ringInner?.material) { + parts.ringInner.material.depthTest = false; + } + rec.previewStart.renderOrder = 60; + rec.previewStart.visible = true; +} + +function applyPreviewEnd(rec, mode, editing, colors) { + if (!rec.previewEnd) return; + const end = rec.interaction?.previewEnd; + const forceHover = !!rec.interaction?.previewLine?.forceHover; + if (!(editing || forceHover) || !end) { + rec.previewEnd.visible = false; + return; + } + rec.previewEnd.position.set(end.x || 0, end.y || 0, 0); + const parts = rec.previewEnd.userData?._markerParts || {}; + if (parts.core?.material?.color) { + parts.core.material.color.setHex(colors.pointsGray); + parts.core.material.depthTest = false; + } + if (parts.ringHighlight) { + parts.ringHighlight.visible = true; + if (parts.ringHighlight.color) { + parts.ringHighlight.color.setHex(colors.linesHover || 0xff9933); + } + if (parts.ringHighlight.material) { + parts.ringHighlight.material.depthTest = false; + } + } + if (parts.ringOuter?.material) { + parts.ringOuter.material.depthTest = false; + } + if (parts.ringInner?.material) { + parts.ringInner.material.depthTest = false; + } + rec.previewEnd.renderOrder = 60; + rec.previewEnd.visible = true; +} + +function applyPreviewArc(rec, mode, editing, colors) { + if (!rec.previewArc) return; + const preview = rec.interaction?.previewArc; + const forceHover = !!rec.interaction?.previewLine?.forceHover; + if (!(editing || forceHover) || !preview) { + rec.previewArc.visible = false; + if (rec.previewArcCenter) { + rec.previewArcCenter.visible = false; + } + return; + } + if (preview.mode === 'chord' && preview.a && preview.b) { + const a = { x: preview.a.x || 0, y: preview.a.y || 0, z: 0 }; + const b = { x: preview.b.x || 0, y: preview.b.y || 0, z: 0 }; + setLineObjectPoints(rec.previewArc, [a, b]); + setLineObjectStyle(rec.previewArc, mode === 'edit' ? colors.linesEdit : colors.linesHover, colors.lineWidths?.hover || 3.0, false); + rec.previewArc.visible = true; + if (rec.previewArcCenter) { + rec.previewArcCenter.visible = false; + } + return; + } + if ((preview.mode === 'arc' || preview.mode === 'circle') && Number.isFinite(preview.cx) && Number.isFinite(preview.cy)) { + const pts = this.getArcRenderPoints(preview, preview.a, preview.b, this.getArcSegmentsFor?.(preview, preview.a, preview.b, 'preview') || 48); + if (pts.length >= 2) { + setLineObjectPoints(rec.previewArc, pts.map(p => ({ x: p.x || 0, y: p.y || 0, z: 0 }))); + setLineObjectStyle(rec.previewArc, mode === 'edit' ? colors.linesEdit : colors.linesHover, colors.lineWidths?.hover || 3.0, false); + rec.previewArc.visible = true; + rec.previewArc.renderOrder = 60; + if (rec.previewArcCenter) { + rec.previewArcCenter.position.set(preview.cx || 0, preview.cy || 0, 0); + const parts = rec.previewArcCenter.userData?._markerParts || {}; + if (parts.ringHighlight) { + parts.ringHighlight.visible = true; + if (parts.ringHighlight.material) { + parts.ringHighlight.material.depthTest = false; + } + } + if (parts.core?.material) parts.core.material.depthTest = false; + if (parts.ringOuter?.material) parts.ringOuter.material.depthTest = false; + if (parts.ringInner?.material) parts.ringInner.material.depthTest = false; + rec.previewArcCenter.renderOrder = 60; + rec.previewArcCenter.visible = true; + } + return; + } + } + rec.previewArc.visible = false; + if (rec.previewArcCenter) { + rec.previewArcCenter.visible = false; + } +} + +function applyPreviewRect(rec, mode, editing, colors) { + if (!rec.previewRect) return; + const preview = rec.interaction?.previewRect; + const corners = Array.isArray(preview?.corners) ? preview.corners : null; + if (!editing || !corners || corners.length !== 4) { + rec.previewRect.visible = false; + return; + } + const pts = [ + { x: corners[0].x || 0, y: corners[0].y || 0, z: 0 }, + { x: corners[1].x || 0, y: corners[1].y || 0, z: 0 }, + { x: corners[2].x || 0, y: corners[2].y || 0, z: 0 }, + { x: corners[3].x || 0, y: corners[3].y || 0, z: 0 }, + { x: corners[0].x || 0, y: corners[0].y || 0, z: 0 } + ]; + setLineObjectPoints(rec.previewRect, pts); + setLineObjectStyle(rec.previewRect, mode === 'edit' ? colors.linesEdit : colors.linesHover, colors.lineWidths?.hover || 3.0, false); + rec.previewRect.visible = true; +} + +function applyLabelState(rec, mode, showPlane, getApi, colors) { + const api = getApi(); + const overlay = api.overlay; + if (!overlay) return; + + if (!showPlane) { + this.removeLabel(rec, getApi); + return; + } + + const text = rec.feature?.name || 'Sketch'; + const color = mode === 'edit' + ? colors.labelEdit + : mode === 'hover' + ? colors.labelHover + : colors.labelDefault; + const pos3d = this.getPlaneLabelPosition(rec.plane); + const id = rec.labelId; + + if (overlay.elements.has(id)) { + overlay.update(id, { pos3d, text, color }); + } else { + overlay.add(id, 'text', { + pos3d, + text, + color, + fontSize: 13, + anchor: 'start', + className: 'sketch-label' + }); + } +} + +function removeLabel(rec, getApi) { + const api = getApi(); + api.overlay?.remove(rec?.labelId); +} + +function getPlaneLabelPosition(plane) { + return plane.getTopLeftCorner(); +} + +function updatePointScreenScales(opts) { + const { pointScreenRadiusPx, pointBaseRadius } = opts; + const { camera, renderer } = space.internals(); + if (!camera || !renderer) return; + const viewHeightPx = renderer.domElement?.clientHeight || renderer.domElement?.height; + if (!viewHeightPx) return; + const tmp = this._tmpPointWorld || new THREE.Vector3(); + + const updateScale = object => { + if (object?.userData?._shaderPoint) { + return; + } + object.getWorldPosition(tmp); + let worldPerPixel; + if (camera.isPerspectiveCamera) { + const distance = camera.position.distanceTo(tmp); + const fovRad = camera.fov * Math.PI / 180; + worldPerPixel = (2 * Math.tan(fovRad / 2) * distance) / viewHeightPx; + } else if (camera.isOrthographicCamera) { + worldPerPixel = ((camera.top - camera.bottom) / camera.zoom) / viewHeightPx; + } else { + return; + } + const desiredWorldRadius = pointScreenRadiusPx * worldPerPixel; + const scale = Math.max(0.0001, desiredWorldRadius / pointBaseRadius); + object.scale.setScalar(scale); + }; + + for (const rec of this.sketches.values()) { + const syncLineRes = line => { + if (!line?.material?.isLineMaterial) return; + const { renderer: r } = space.internals(); + const w = r?.domElement?.clientWidth || r?.domElement?.width || 1; + const h = r?.domElement?.clientHeight || r?.domElement?.height || 1; + line.material.resolution?.set?.(w, h); + }; + for (const view of rec.entityViews.values()) { + if ((view.type === 'line' || view.type === 'arc') && view.object?.material?.isLineMaterial) { + const { renderer: r } = space.internals(); + const w = r?.domElement?.clientWidth || r?.domElement?.width || 1; + const h = r?.domElement?.clientHeight || r?.domElement?.height || 1; + view.object.material.resolution?.set?.(w, h); + } + if ((view.type !== 'point' && view.type !== 'arc-center') || !view.object) continue; + updateScale(view.object); + } + syncLineRes(rec.previewLine); + syncLineRes(rec.previewArc); + syncLineRes(rec.previewRect); + syncLineRes(rec.previewFaceSegments); + syncLineRes(rec.previewExternalWorldLine); + syncLineRes(rec.previewExternalWorldSegments); + if (rec.previewStart) updateScale(rec.previewStart); + if (rec.previewEnd) updateScale(rec.previewEnd); + if (rec.previewArcCenter) updateScale(rec.previewArcCenter); + if (rec.previewExternalWorldPoint) updateScale(rec.previewExternalWorldPoint); + } +} + +function getConstraintAnchorLocal(feature, constraint) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.map(e => [e?.id, e])); + const rawRefs = Array.isArray(constraint?.refs) ? constraint.refs : []; + const displayRefs = Array.isArray(constraint?.ui?.display_refs) ? constraint.ui.display_refs : []; + const refs = displayRefs.length ? displayRefs : rawRefs; + const lineTypes = new Set(['horizontal', 'vertical', 'horizontal_points', 'vertical_points', 'tangent', 'equal', 'collinear', 'dimension', 'min_distance', 'max_distance', 'arc_center_on_line', 'arc_center_on_arc', 'mirror_line']); + const pointLike = ref => { + if (!ref) return null; + if (ref === '__sketch-origin__') return { x: 0, y: 0 }; + if (typeof ref === 'string' && ref.startsWith('arc-center:')) { + const arcId = ref.substring('arc-center:'.length); + const ent = byId.get(arcId); + if (!ent || ent.type !== 'arc') return null; + const aId = typeof ent?.a === 'string' ? ent.a : (typeof ent?.p1_id === 'string' ? ent.p1_id : null); + const bId = typeof ent?.b === 'string' ? ent.b : (typeof ent?.p2_id === 'string' ? ent.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type !== 'point' || b?.type !== 'point') return null; + return computeArcCenterForUi(ent, a, b); + } + const p = byId.get(ref); + if (p?.type === 'point') return { x: p.x || 0, y: p.y || 0 }; + return null; + }; + + if (constraint?.type === 'circular_pattern') { + const centerRef = typeof constraint?.data?.centerRef === 'string' + ? constraint.data.centerRef + : refs[0]; + const center = pointLike(centerRef); + if (center) return center; + } + if (constraint?.type === 'grid_pattern') { + const centerRef = typeof constraint?.data?.centerPointId === 'string' + ? constraint.data.centerPointId + : refs[0]; + const center = pointLike(centerRef); + if (center) return center; + } + + if (constraint?.type === 'mirror_line') { + const src = byId.get(refs[1]); + const dst = byId.get(refs[2]); + const lineMid = line => { + if (line?.type !== 'line') return null; + const [a, b] = this.getLineEndpoints(line, byId); + if (!a || !b) return null; + return { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 }; + }; + const a = lineMid(src); + const b = lineMid(dst); + if (a && b) return { x: (a.x + b.x) * 0.5, y: (a.y + b.y) * 0.5 }; + if (a) return a; + if (b) return b; + } + + if (lineTypes.has(constraint?.type)) { + if (constraint?.type === 'dimension') { + const refs = Array.isArray(constraint?.ui?.display_refs) && constraint.ui.display_refs.length + ? constraint.ui.display_refs + : (Array.isArray(constraint?.refs) ? constraint.refs : []); + if (refs.length === 1) { + const ent = byId.get(refs[0]); + if (ent?.type === 'arc') { + const aId = typeof ent?.a === 'string' ? ent.a : (typeof ent?.p1_id === 'string' ? ent.p1_id : null); + const bId = typeof ent?.b === 'string' ? ent.b : (typeof ent?.p2_id === 'string' ? ent.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type === 'point' && b?.type === 'point') { + const center = computeArcCenterForUi(ent, a, b); + if (center) { + return { x: (center.x + (a.x || 0)) * 0.5, y: (center.y + (a.y || 0)) * 0.5 }; + } + } + } + } + } + const line = refs.map(id => byId.get(id)).find(e => e?.type === 'line'); + if (line) { + const [a, b] = this.getLineEndpoints(line, byId); + if (a && b) { + return { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 }; + } + } + if (constraint?.type === 'tangent') { + const arcRefs = refs.map(id => byId.get(id)).filter(e => e?.type === 'arc'); + if (arcRefs.length >= 2) { + const a1 = arcRefs[0]; + const a2 = arcRefs[1]; + const [p1a, p1b] = this.getArcEndpoints(a1, byId); + const [p2a, p2b] = this.getArcEndpoints(a2, byId); + const c1 = this.getArcCenterLocal(a1, p1a, p1b); + const c2 = this.getArcCenterLocal(a2, p2a, p2b); + if (c1 && c2) { + return { x: (c1.x + c2.x) * 0.5, y: (c1.y + c2.y) * 0.5 }; + } + } + } + if (constraint?.type === 'arc_center_on_arc') { + const arcRefs = refs.map(id => byId.get(id)).filter(e => e?.type === 'arc'); + if (arcRefs.length >= 2) { + const src = arcRefs[0]; + const dst = arcRefs[1]; + const [sa, sb] = this.getArcEndpoints(src, byId); + const [da, db] = this.getArcEndpoints(dst, byId); + const sc = this.getArcCenterLocal(src, sa, sb); + const dc = this.getArcCenterLocal(dst, da, db); + if (sc && dc) { + return { x: (sc.x + dc.x) * 0.5, y: (sc.y + dc.y) * 0.5 }; + } + if (sc) return sc; + if (dc) return dc; + } + } + } + + if (constraint?.type === 'mirror_arc') { + const arc = refs.map(id => byId.get(id)).find(e => e?.type === 'arc'); + if (arc) { + const aId = typeof arc?.a === 'string' ? arc.a : (typeof arc?.p1_id === 'string' ? arc.p1_id : null); + const bId = typeof arc?.b === 'string' ? arc.b : (typeof arc?.p2_id === 'string' ? arc.p2_id : null); + const a = byId.get(aId); + const b = byId.get(bId); + if (a?.type === 'point' && b?.type === 'point') { + const center = computeArcCenterForUi(arc, a, b); + if (center) return center; + } + } + } + + const points = refs.map(id => byId.get(id)).filter(e => e?.type === 'point'); + if (constraint?.type === 'horizontal_points' || constraint?.type === 'vertical_points') { + const p1 = pointLike(refs[0]); + const p2 = pointLike(refs[1]); + if (p1 && p2) { + return { + x: ((p1.x || 0) + (p2.x || 0)) * 0.5, + y: ((p1.y || 0) + (p2.y || 0)) * 0.5 + }; + } + } + if (constraint?.type === 'midpoint' && points.length >= 3) { + const a = points[1]; + const b = points[2]; + return { x: ((a.x || 0) + (b.x || 0)) * 0.5, y: ((a.y || 0) + (b.y || 0)) * 0.5 }; + } + if (points.length >= 2) { + return { + x: ((points[0].x || 0) + (points[1].x || 0)) * 0.5, + y: ((points[0].y || 0) + (points[1].y || 0)) * 0.5 + }; + } + if (points.length === 1) { + return { x: points[0].x || 0, y: points[0].y || 0 }; + } + if (constraint?.type === 'arc_center_fixed_origin') { + return { x: 0, y: 0 }; + } + return null; +} + +function projectConstraintAnchor(rec, local, getApi) { + if (!rec?.entitiesGroup || !local) { + return null; + } + const world = new THREE.Vector3(local.x || 0, local.y || 0, 0); + rec.entitiesGroup.localToWorld(world); + const proj = getApi().overlay.project3Dto2D(world); + if (!proj?.visible) { + return null; + } + return { x: proj.x, y: proj.y }; +} + +function tagPointMarker(marker, id) { + if (!marker) return; + marker.traverse(obj => { + obj.userData = obj.userData || {}; + obj.userData.sketchEntityId = id; + obj.userData.sketchEntityType = 'point'; + }); +} + +function applyConstraintOffset(constraint, screenPos, slotIndex = 0, slotCount = 1, opts = {}) { + const size = opts.glyphSizePx || 18; + const gap = opts.glyphGapPx || 4; + const base = constraint?.ui?.offset_px || { x: 0, y: -18 }; + const rowWidth = slotCount * size + Math.max(0, slotCount - 1) * gap; + const slotX = -rowWidth / 2 + (slotIndex + 0.5) * size + slotIndex * gap; + return { + x: screenPos.x + (base.x || 0) + slotX, + y: screenPos.y + (base.y || 0) + }; +} + +function updateConstraintGlyphs(getApi, opts = {}) { + const layer = this.ensureConstraintGlyphLayer(); + if (!layer) return; + layer.innerHTML = ''; + + const rec = this.getEditingRecord(); + if (!rec?.feature) { + for (const r of this.sketches.values()) { + clearDimensionDecorations3D(r); + } + return; + } + for (const r of this.sketches.values()) { + clearDimensionDecorations3D(r); + } + + const constraints = Array.isArray(rec.feature.constraints) ? rec.feature.constraints : []; + const entities = Array.isArray(rec.feature.entities) ? rec.feature.entities : []; + const entityById = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + if (!constraints.length) { + return; + } + + const selectedEntityIds = rec.interaction?.selectedIds || new Set(); + const hoveredEntityId = rec.interaction?.hoveredId || null; + const selectedConstraintIds = rec.interaction?.selectedConstraintIds || new Set(); + const hoveredConstraintId = rec.interaction?.hoveredConstraintId || null; + const draggingConstraintId = this._glyphDrag?.constraintId || null; + + const visible = []; + for (const constraint of constraints) { + const refs = Array.isArray(constraint?.refs) ? constraint.refs : []; + const visRefs = Array.isArray(constraint?.ui?.display_refs) && constraint.ui.display_refs.length + ? constraint.ui.display_refs + : refs; + let byEntity = visRefs.some(ref => selectedEntityIds.has(ref)); + let byHover = !!hoveredEntityId && visRefs.includes(hoveredEntityId); + if (constraint?.type === 'arc_center_coincident' && refs.length >= 2) { + const arcId = visRefs[0]; + const pointId = visRefs[1]; + const arcCenterKey = typeof arcId === 'string' ? `arc-center:${arcId}` : null; + byEntity = !!( + (pointId && selectedEntityIds.has(pointId)) || + (arcCenterKey && selectedEntityIds.has(arcCenterKey)) + ); + byHover = !!( + (pointId && hoveredEntityId === pointId) || + (arcCenterKey && hoveredEntityId === arcCenterKey) + ); + } + if (constraint?.type === 'arc_center_on_line' && refs.length >= 2) { + const arcId = visRefs.find(ref => entityById.get(ref)?.type === 'arc') || null; + const arcCenterKey = typeof arcId === 'string' ? `arc-center:${arcId}` : null; + byEntity = !!( + (arcCenterKey && selectedEntityIds.has(arcCenterKey)) || + visRefs.some(ref => selectedEntityIds.has(ref)) + ); + byHover = !!( + (arcCenterKey && hoveredEntityId === arcCenterKey) || + visRefs.includes(hoveredEntityId) + ); + } + if (constraint?.type === 'arc_center_on_arc' && refs.length >= 2) { + const arcIds = visRefs.filter(ref => entityById.get(ref)?.type === 'arc'); + const sourceArcId = arcIds[0] || null; + const sourceCenterKey = typeof sourceArcId === 'string' ? `arc-center:${sourceArcId}` : null; + byEntity = !!( + (sourceCenterKey && selectedEntityIds.has(sourceCenterKey)) || + arcIds.some(ref => selectedEntityIds.has(ref)) + ); + byHover = !!( + (sourceCenterKey && hoveredEntityId === sourceCenterKey) || + arcIds.includes(hoveredEntityId) + ); + } + if (constraint?.type === 'arc_center_fixed_origin' && refs.length >= 1) { + const arcId = visRefs[0]; + const arcCenterKey = typeof arcId === 'string' ? `arc-center:${arcId}` : null; + byEntity = !!( + (arcCenterKey && selectedEntityIds.has(arcCenterKey)) + ); + byHover = !!( + (arcCenterKey && hoveredEntityId === arcCenterKey) + ); + } + const byDrag = draggingConstraintId === constraint?.id; + const alwaysVisible = constraint?.type === 'dimension' || constraint?.type === 'circular_pattern' || constraint?.type === 'grid_pattern'; + if (alwaysVisible || byEntity || byHover || byDrag) { + visible.push(constraint); + } + } + if (hoveredConstraintId && !visible.some(c => c?.id === hoveredConstraintId)) { + const api = getApi(); + api.interact?.setHoveredSketchConstraint?.(null); + } + if (!visible.length) { + return; + } + + const clusters = new Map(); + for (const constraint of visible) { + const local = this.getConstraintAnchorLocal(rec.feature, constraint); + const screen = this.projectConstraintAnchor(rec, local, getApi); + if (!screen) continue; + const key = `${Math.round(screen.x / 10)}:${Math.round(screen.y / 10)}`; + if (!clusters.has(key)) { + clusters.set(key, { screen, items: [] }); + } + clusters.get(key).items.push(constraint); + } + + for (const { screen, items } of clusters.values()) { + for (let i = 0; i < items.length; i++) { + const c = items[i]; + if (c?.type === 'grid_pattern') { + const feature = rec.feature; + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(e => e?.id).map(e => [e.id, e])); + const centerId = c?.data?.centerPointId; + const center = byId.get(centerId); + const mkEndpoint = lineId => { + const line = byId.get(lineId); + if (!line || line.type !== 'line' || !center) return null; + const a = byId.get(line.a); + const b = byId.get(line.b); + if (!a || !b) return null; + return line.a === centerId ? b : line.b === centerId ? a : b; + }; + const hEnd = mkEndpoint(c?.data?.uLineId) || center || null; + const vEnd = mkEndpoint(c?.data?.vLineId) || center || null; + const hPos = hEnd ? this.projectConstraintAnchor(rec, { x: hEnd.x || 0, y: hEnd.y || 0 }, getApi) : null; + const vPos = vEnd ? this.projectConstraintAnchor(rec, { x: vEnd.x || 0, y: vEnd.y || 0 }, getApi) : null; + const entries = [ + { axis: 'h', label: `H${Math.max(1, Number(c?.data?.countH || 0) || 3)}`, pos: hPos || screen }, + { axis: 'v', label: `V${Math.max(1, Number(c?.data?.countV || 0) || 3)}`, pos: vPos || screen } + ]; + for (const ent of entries) { + const glyph = document.createElement('button'); + glyph.className = 'sketch-constraint-glyph'; + glyph.textContent = ent.label; + glyph.style.left = `${Math.round(ent.pos.x)}px`; + glyph.style.top = `${Math.round(ent.pos.y)}px`; + if (selectedConstraintIds.has(c.id)) glyph.classList.add('selected'); + else if (hoveredConstraintId === c.id) glyph.classList.add('hover'); + glyph.title = `${ent.axis === 'h' ? 'horizontal' : 'vertical'} copies - double-click edit`; + glyph.ondblclick = event => { + event.preventDefault(); + event.stopPropagation(); + const api = getApi(); + api.interact?.editSketchGridPatternConstraint?.(c.id, ent.axis); + }; + glyph.onmouseenter = () => { + const api = getApi(); + api.interact?.setHoveredSketchConstraint?.(c.id); + }; + glyph.onmouseleave = () => { + const api = getApi(); + api.interact?.setHoveredSketchConstraint?.(null); + }; + glyph.onmousedown = event => { + event.preventDefault(); + event.stopPropagation(); + const api = getApi(); + api.interact?.selectSketchConstraint?.(c.id, event); + }; + layer.appendChild(glyph); + } + continue; + } + const isDimension = c?.type === 'dimension'; + let pos; + if (isDimension) { + const centerLocal = getDimensionCenterLocal.call(this, rec, rec.feature, c, this._glyphDrag); + const centerScreen = centerLocal ? this.projectConstraintAnchor(rec, centerLocal, getApi) : null; + pos = centerScreen || this.applyConstraintOffset(c, screen, i, items.length, opts); + } else { + if (this._glyphDrag && this._glyphDrag.constraintId === c?.id && !this._glyphDrag.isDimension) { + const size = opts.glyphSizePx || 18; + const gap = opts.glyphGapPx || 4; + const rowWidth = items.length * size + Math.max(0, items.length - 1) * gap; + const slotX = -rowWidth / 2 + (i + 0.5) * size + i * gap; + const dragBase = this._glyphDrag.current || this._glyphDrag.base || { x: 0, y: -18 }; + pos = { + x: screen.x + (dragBase.x || 0) + slotX, + y: screen.y + (dragBase.y || 0) + }; + } else { + pos = this.applyConstraintOffset(c, screen, i, items.length, opts); + } + } + const glyph = document.createElement('button'); + glyph.className = 'sketch-constraint-glyph'; + const measured = isDimension ? computeDimensionMeasurement(rec.feature, c) : NaN; + const mode = isDimension ? getDimensionMode(c) : 'driving'; + const isArcDim = isDimension ? isArcDimensionConstraint(rec.feature, c) : false; + glyph.textContent = c?.type === 'circular_pattern' + ? String(Math.max(2, Number(c?.data?.count || 0) || 3)) + : (isDimension + ? (mode === 'driven' ? formatMeasuredValue(measured) : formatDimensionLabel(c)) + : this.constraintGlyphLabel(c.type)); + glyph.style.left = `${Math.round(pos.x)}px`; + glyph.style.top = `${Math.round(pos.y)}px`; + if (c?.type === 'circular_pattern') { + const leader = document.createElement('div'); + leader.className = 'sketch-constraint-leader'; + const dx = (pos.x || 0) - (screen.x || 0); + const dy = (pos.y || 0) - (screen.y || 0); + const len = Math.hypot(dx, dy); + const trim = 10; + if (len > trim + 1) { + leader.style.left = `${Math.round(screen.x)}px`; + leader.style.top = `${Math.round(screen.y)}px`; + leader.style.width = `${Math.round(Math.max(0, len - trim))}px`; + leader.style.transform = `rotate(${Math.atan2(dy, dx)}rad)`; + layer.appendChild(leader); + } + } + if (isDimension) { + glyph.classList.add('dimension'); + glyph.classList.toggle('driven', mode === 'driven'); + glyph.classList.toggle('driving', mode === 'driving'); + glyph.classList.toggle('radius', !!isArcDim); + glyph.dataset.mode = mode === 'driven' ? 'R' : 'D'; + const ends = getDimensionEndpoints(rec.feature, c); + if (ends) { + const centerLocal = getDimensionCenterLocal.call(this, rec, rec.feature, c, this._glyphDrag); + addDimensionDecoration3D(rec, c, ends[0], ends[1], { + selected: selectedConstraintIds.has(c.id), + hovered: hoveredConstraintId === c.id, + centerLocal, + colors: opts?.colors || null + }); + } + } + if (selectedConstraintIds.has(c.id)) { + glyph.classList.add('selected'); + } else if (hoveredConstraintId === c.id) { + glyph.classList.add('hover'); + } + glyph.title = isDimension + ? `${isArcDim ? 'diameter ' : ''}dimension (${mode}) - double-click edit, alt-click toggle driving/reference` + : (c?.type === 'circular_pattern' + ? 'circular pattern - double-click edit copy count' + : (c.type || 'constraint')); + glyph.ondblclick = event => { + if (c?.type === 'circular_pattern') { + event.preventDefault(); + event.stopPropagation(); + this._glyphDrag = null; + const api = getApi(); + api.interact?.editSketchCircularPatternConstraint?.(c.id); + return; + } + if (!isDimension || mode !== 'driving') return; + event.preventDefault(); + event.stopPropagation(); + this._glyphDrag = null; + const api = getApi(); + api.interact?.editSketchDimensionConstraint?.(c.id); + }; + glyph.onmouseenter = () => { + const api = getApi(); + api.interact?.setHoveredSketchConstraint?.(c.id); + }; + glyph.onmouseleave = () => { + const api = getApi(); + api.interact?.setHoveredSketchConstraint?.(null); + }; + glyph.onmousedown = event => { + event.preventDefault(); + event.stopPropagation(); + const api = getApi(); + if (isDimension && event.altKey) { + api.interact?.toggleSketchDimensionMode?.(c.id); + return; + } + if (c?.type === 'circular_pattern') { + const now = performance.now(); + const prev = this._glyphClick; + if (prev && prev.id === c.id && (now - prev.time) < 360) { + this._glyphClick = null; + this._glyphDrag = null; + api.interact?.editSketchCircularPatternConstraint?.(c.id); + return; + } + this._glyphClick = { id: c.id, time: now }; + } + if (isDimension) { + const now = performance.now(); + const prev = this._glyphClick; + if (mode === 'driving' && prev && prev.id === c.id && (now - prev.time) < 360) { + this._glyphClick = null; + this._glyphDrag = null; + api.interact?.editSketchDimensionConstraint?.(c.id); + return; + } + this._glyphClick = { id: c.id, time: now }; + } else if (c?.type !== 'circular_pattern') { + this._glyphClick = null; + } + api.interact?.selectSketchConstraint?.(c.id, event); + this._glyphDrag = { + featureId: rec.feature.id, + constraintId: c.id, + isDimension, + startX: event.clientX, + startY: event.clientY, + base: c?.ui?.offset_px ? { x: c.ui.offset_px.x || 0, y: c.ui.offset_px.y || 0 } : { x: 0, y: -18 }, + current: c?.ui?.offset_px ? { x: c.ui.offset_px.x || 0, y: c.ui.offset_px.y || 0 } : { x: 0, y: -18 }, + currentLocal: getDimensionCenterLocal.call(this, rec, rec.feature, c, null), + localDelta: null, + moved: false + }; + if (isDimension) { + const mouseLocal = screenToSketchLocal(rec, event.clientX, event.clientY); + if (mouseLocal && this._glyphDrag.currentLocal) { + this._glyphDrag.localDelta = { + x: (this._glyphDrag.currentLocal.x || 0) - (mouseLocal.x || 0), + y: (this._glyphDrag.currentLocal.y || 0) - (mouseLocal.y || 0) + }; + } + } + }; + layer.appendChild(glyph); + } + } +} + +function updateConstraintDrag(event, done = false, getApi) { + if (!this._glyphDrag) return; + const drag = this._glyphDrag; + const dx = (event?.clientX || 0) - drag.startX; + const dy = (event?.clientY || 0) - drag.startY; + const moved = Math.hypot(dx, dy) > 0.5; + drag.moved = drag.moved || moved; + const next = { x: drag.base.x + dx, y: drag.base.y + dy }; + drag.current = next; + if (drag.isDimension) { + const rec = this.getRecord?.(drag.featureId) || null; + const mouseLocal = rec ? screenToSketchLocal(rec, event?.clientX || 0, event?.clientY || 0) : null; + if (mouseLocal) { + const delta = drag.localDelta || { x: 0, y: 0 }; + drag.currentLocal = { + x: (mouseLocal.x || 0) + (delta.x || 0), + y: (mouseLocal.y || 0) + (delta.y || 0) + }; + } + } + this.updateConstraintGlyphs(getApi); + space.update(); + if (done) { + if (drag.moved) { + const api = getApi(); + api.features.mutateTransient(drag.featureId, sketch => { + sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : []; + const c = sketch.constraints.find(cst => cst?.id === drag.constraintId); + if (!c) return; + c.ui = c.ui || {}; + if (drag.isDimension) { + const anchor = getConstraintAnchorLocal.call(this, sketch, c); + const center = drag.currentLocal; + if (anchor && center) { + c.ui.offset_local = { + x: (center.x || 0) - (anchor.x || 0), + y: (center.y || 0) - (anchor.y || 0) + }; + } + } else { + c.ui.offset_px = next; + } + }); + api.features.commit(drag.featureId, { + opType: 'feature.update', + payload: { field: 'constraints.ui.move', id: drag.constraintId } + }); + } + this._glyphDrag = null; + space.update(); + } +} + +export { + constraintGlyphLabel, + applySketchState, + applyPlaneStyle, + applyEntityStyle, + getConstraintHoverHighlight, + applyPreviewLine, + applyPreviewFaceSegments, + applyPreviewExternalWorld, + applyPreviewStart, + applyPreviewEnd, + applyPreviewArc, + applyPreviewRect, + applyLabelState, + removeLabel, + getPlaneLabelPosition, + updatePointScreenScales, + getConstraintAnchorLocal, + projectConstraintAnchor, + tagPointMarker, + applyConstraintOffset, + updateConstraintGlyphs, + updateConstraintDrag +}; diff --git a/src/void/sketch/tools.js b/src/void/sketch/tools.js new file mode 100644 index 00000000..02232851 --- /dev/null +++ b/src/void/sketch/tools.js @@ -0,0 +1,543 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from '../api.js'; +import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js'; + +function getEditingSketchFeature() { + const sketchId = api.sketchRuntime?.editingId; + if (!sketchId) return null; + const feature = api.features.findById(sketchId); + return feature?.type === 'sketch' ? feature : null; +} + +function isSketchEditing() { + return !!this.getEditingSketchFeature(); +} + +function setSketchTool(tool = 'select') { + if (tool === 'arc') tool = 'arc-3pt'; + if (tool === 'circle') tool = 'circle-center'; + const allowed = new Set([ + 'select', + 'point', + 'line', + 'arc', + 'arc-3pt', + 'arc-center', + 'arc-tangent', + 'circle', + 'circle-center', + 'circle-3pt', + 'rect', + 'rect-center' + ]); + const next = allowed.has(tool) ? tool : 'select'; + if (this.sketchTool === next) { + if (next === 'circle' || next === 'circle-center' || next === 'circle-3pt') { + this.cancelSketchCircle(); + this.sketchPointerDown = null; + this.sketchDrag = null; + this.updateSketchInteractionVisuals(); + } + return; + } + this.sketchTool = next; + if (next !== 'select') { + this.stopSketchMirrorMode?.(); + this.stopSketchCircularPatternMode?.(); + this.stopSketchGridPatternMode?.(); + } + if (next !== 'line') { + this.cancelSketchLine(); + } + if (next !== 'arc' && next !== 'arc-3pt' && next !== 'arc-center' && next !== 'arc-tangent') { + this.cancelSketchArc(); + } + if (next !== 'circle' && next !== 'circle-center' && next !== 'circle-3pt') { + this.cancelSketchCircle(); + } + if (next !== 'rect' && next !== 'rect-center') { + this.cancelSketchRect(); + } + this.sketchRectCenterMode = next === 'rect-center'; + this.updateSketchInteractionVisuals(); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function getSketchTool() { + return this.sketchTool || 'select'; +} + +function cancelSketchLine() { + this.sketchLineStart = null; + this.sketchLineStartRefId = null; + this.sketchLineStartSeq = null; + this.sketchLinePreview = null; +} + +function cancelSketchArc() { + this.sketchArcStart = null; + this.sketchArcStartRefId = null; + this.sketchArcEnd = null; + this.sketchArcEndRefId = null; + this.sketchArcPreview = null; +} + +function cancelSketchCircle() { + this.sketchCircleCenter = null; + this.sketchCircleSecond = null; + this.sketchCircleCenterRefId = null; + this.sketchCircleSecondRefId = null; + this.sketchCircleStartSeq = null; + this.sketchArcPreview = null; +} + +function cancelSketchRect() { + this.sketchRectStart = null; + this.sketchRectStartRefId = null; + this.sketchRectStartSeq = null; + this.sketchRectPreview = null; +} + +function clearSketchSelection() { + this.selectedSketchEntities.clear(); + this.selectedSketchArcCenters?.clear?.(); + this.selectedSketchConstraints.clear(); + this.selectedSketchProfiles?.clear?.(); + this.hoveredSketchProfileKey = null; + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + this.hoveredSketchEntityId = null; + this.hoveredDerivedCandidate = null; + this.selectedDerivedSelections?.clear?.(); + this.selectedSolidFaceKeys?.clear?.(); + api.solids?.clearFaceSelection?.(); + this.hoveredSketchConstraintId = null; + this.sketchLinePreview = null; + this.sketchArcPreview = null; + this.sketchRectPreview = null; + this.clearSketchMarquee(); + this.updateSketchInteractionVisuals(); +} + +function getSelectedSketchMirrorAxis(feature) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const selected = this.selectedSketchEntities instanceof Set ? this.selectedSketchEntities : new Set(); + const lines = entities.filter(entity => entity?.type === 'line' && selected.has(entity.id)); + return lines.length === 1 ? lines[0] : null; +} + +function startSketchMirrorMode() { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const axis = this.getSelectedSketchMirrorAxis(feature); + if (!axis?.id) return false; + this.sketchMirrorMode = true; + this.sketchMirrorAxisId = axis.id; + this.stopSketchCircularPatternMode?.(); + this.setSketchTool('select'); + this.updateSketchInteractionVisuals(); + return true; +} + +function stopSketchMirrorMode() { + if (!this.sketchMirrorMode && !this.sketchMirrorAxisId) return false; + this.sketchMirrorMode = false; + this.sketchMirrorAxisId = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function getSelectedSketchPatternCenter(feature) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity])); + const refs = []; + for (const id of this.selectedSketchEntities || []) { + if (id === SKETCH_VIRTUAL_ORIGIN_ID) { + refs.push(SKETCH_VIRTUAL_ORIGIN_ID); + continue; + } + const ent = byId.get(id); + if (ent?.type === 'point') refs.push(id); + } + for (const arcId of this.selectedSketchArcCenters || []) { + if (typeof arcId === 'string' && arcId) { + refs.push(`arc-center:${arcId}`); + } + } + const unique = [...new Set(refs)]; + return unique.length === 1 ? unique[0] : null; +} + +function startSketchCircularPatternMode() { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const centerRef = this.getSelectedSketchPatternCenter(feature); + if (!centerRef) return false; + this.sketchCircularPatternMode = true; + this.sketchCircularPatternCenterRef = centerRef; + this.stopSketchMirrorMode?.(); + this.setSketchTool('select'); + this.updateSketchInteractionVisuals(); + return true; +} + +function stopSketchCircularPatternMode() { + if (!this.sketchCircularPatternMode && !this.sketchCircularPatternCenterRef) return false; + this.sketchCircularPatternMode = false; + this.sketchCircularPatternCenterRef = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function getSelectedSketchGridAnchor(feature) { + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity])); + const pointIds = []; + for (const id of this.selectedSketchEntities || []) { + const ent = byId.get(id); + if (ent?.type === 'point') pointIds.push(id); + } + const unique = [...new Set(pointIds)]; + return unique.length === 1 ? unique[0] : null; +} + +function startSketchGridPatternMode() { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const centerPointId = this.getSelectedSketchGridAnchor(feature); + if (!centerPointId) return false; + this.sketchGridPatternMode = true; + this.sketchGridPatternCenterRef = centerPointId; + this.stopSketchMirrorMode?.(); + this.stopSketchCircularPatternMode?.(); + this.setSketchTool('select'); + this.updateSketchInteractionVisuals(); + return true; +} + +function stopSketchGridPatternMode() { + if (!this.sketchGridPatternMode && !this.sketchGridPatternCenterRef) return false; + this.sketchGridPatternMode = false; + this.sketchGridPatternCenterRef = null; + this.updateSketchInteractionVisuals(); + return true; +} + +function editSketchCircularPatternConstraint(constraintId) { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + const constraints = Array.isArray(feature.constraints) ? feature.constraints : []; + const found = constraints.find(c => c?.id === constraintId && c?.type === 'circular_pattern'); + if (!found) return false; + const current = Math.max(2, Number(found?.data?.count || 0) || 3); + const input = window.prompt('Pattern copies', String(current)); + if (input === null) return false; + const value = Math.floor(Number(input)); + if (!Number.isFinite(value) || value < 2 || value > 256) return false; + return !!this.updateCircularPatternConstraintCopies?.(constraintId, value); +} + +function editSketchGridPatternConstraint(constraintId, axis = 'h') { + const feature = this.getEditingSketchFeature(); + if (!feature || !constraintId) return false; + const constraints = Array.isArray(feature.constraints) ? feature.constraints : []; + const found = constraints.find(c => c?.id === constraintId && c?.type === 'grid_pattern'); + if (!found) return false; + const key = axis === 'v' ? 'countV' : 'countH'; + const current = Math.max(1, Number(found?.data?.[key] || 0) || 3); + const input = window.prompt(`${axis === 'v' ? 'Vertical' : 'Horizontal'} copies`, String(current)); + if (input === null) return false; + const value = Math.floor(Number(input)); + if (!Number.isFinite(value) || value < 1 || value > 256) return false; + return !!this.updateGridPatternConstraintCopies?.(constraintId, axis, value); +} + +function handleSketchKeyDown(event) { + if (!this.isSketchEditing()) { + return false; + } + + const activeTag = document.activeElement?.tagName; + const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable; + if (editingInput) { + return false; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + + if (event.code === 'Escape') { + this.sketchPointerDown = null; + this.sketchDrag = null; + if (api?.sketchRuntime) { + api.sketchRuntime._glyphDrag = null; + } + const hadMarquee = !!this.sketchMarquee; + if (hadMarquee) { + this.clearSketchMarquee(); + } + const hadLine = !!this.sketchLineStart; + const hadArc = !!this.sketchArcStart || !!this.sketchArcEnd; + const hadRect = !!this.sketchRectStart; + if (hadLine) { + this.cancelSketchLine(); + } + if (hadArc) { + this.cancelSketchArc(); + } + if (hadRect) { + this.cancelSketchRect(); + } + if (this.getSketchTool() !== 'select') { + this.setSketchTool('select'); + return true; + } + if (this.stopSketchMirrorMode?.()) { + return true; + } + if (this.stopSketchCircularPatternMode?.()) { + return true; + } + if (this.stopSketchGridPatternMode?.()) { + return true; + } + return hadLine || hadArc || hadRect || hadMarquee; + } + + const toggleTool = tool => { + const curr = this.getSketchTool(); + this.setSketchTool(curr === tool ? 'select' : tool); + return true; + }; + + if (event.code === 'KeyL' && !event.shiftKey) { + return toggleTool('line'); + } + if (event.code === 'KeyA' && !event.shiftKey) { + return toggleTool('arc-3pt'); + } + if (event.code === 'KeyC' && !event.shiftKey) { + return toggleTool('circle-center'); + } + if (event.code === 'KeyR' && !event.shiftKey) { + return toggleTool('rect-center'); + } + if (event.code === 'KeyG' && !event.shiftKey) { + return toggleTool('rect'); + } + if (event.code === 'KeyS' && event.shiftKey) { + return toggleTool('point'); + } + if (event.code === 'KeyQ' && !event.shiftKey) { + return this.toggleSelectedConstruction(); + } + if (event.code === 'KeyU' && !event.shiftKey) { + return this.useHoveredDerivedEdge(); + } + if (event.code === 'KeyH' && !event.shiftKey) { + this.applySketchConstraint('horizontal'); + return true; + } + if (event.code === 'KeyV' && !event.shiftKey) { + this.applySketchConstraint('vertical'); + return true; + } + if (event.code === 'KeyL' && event.shiftKey) { + this.applySketchConstraint('perpendicular'); + return true; + } + if (event.code === 'KeyE' && !event.shiftKey) { + this.applySketchConstraint('equal'); + return true; + } + if (event.code === 'KeyD' && !event.shiftKey) { + this.applySketchConstraint('dimension'); + return true; + } + if (event.code === 'KeyT' && !event.shiftKey) { + this.applySketchConstraint('tangent'); + return true; + } + if (event.code === 'KeyI' && !event.shiftKey) { + this.applySketchConstraint('coincident'); + return true; + } + if (event.code === 'KeyM' && event.shiftKey) { + this.applySketchConstraint('midpoint'); + return true; + } + if (event.code === 'KeyJ' && event.shiftKey) { + this.applySketchConstraint('fixed'); + return true; + } + if (event.code === 'KeyM' && !event.shiftKey) { + if (this.sketchMirrorMode) { + return this.stopSketchMirrorMode?.(); + } + return this.startSketchMirrorMode?.(); + } + if (event.code === 'Delete' || event.code === 'Backspace') { + if (this.selectedSketchConstraints?.size) { + return this.deleteSelectedSketchConstraints(); + } + return this.deleteSelectedSketchEntities(); + } + return false; +} + +function selectSketchConstraint(constraintId, event = {}) { + if (!constraintId) { + return false; + } + const multi = !!(event.ctrlKey || event.metaKey || event.shiftKey); + if (!multi) { + if (this.selectedSketchConstraints.size === 1 && this.selectedSketchConstraints.has(constraintId)) { + return false; + } + this.selectedSketchConstraints.clear(); + this.selectedSketchConstraints.add(constraintId); + } else { + if (this.selectedSketchConstraints.has(constraintId)) { + this.selectedSketchConstraints.delete(constraintId); + } else { + this.selectedSketchConstraints.add(constraintId); + } + } + this.hoveredSketchConstraintId = constraintId; + this.updateSketchInteractionVisuals(); + return true; +} + +function setHoveredSketchConstraint(constraintId) { + const next = constraintId || null; + if (this.hoveredSketchConstraintId === next) { + return false; + } + this.hoveredSketchConstraintId = next; + this.updateSketchInteractionVisuals(); + return true; +} + +function useHoveredDerivedEdge() { + const feature = this.getEditingSketchFeature(); + if (!feature) return false; + const selectionMap = this.selectedDerivedSelections instanceof Map + ? this.selectedDerivedSelections + : new Map(); + const selectedEdges = []; + const selectedPoints = []; + for (const sel of selectionMap.values()) { + if (sel?.type === 'edge') selectedEdges.push(sel); + if (sel?.type === 'point') selectedPoints.push(sel); + } + const selectedFaces = Array.from(this.selectedSolidFaceKeys || []); + const hovered = this.hoveredDerivedCandidate || null; + if (!selectedEdges.length && !selectedPoints.length && !selectedFaces.length) { + // `u` should prioritize the actively hovered derived edge candidate. + if (hovered?.aLocal && hovered?.bLocal) { + if (Array.isArray(hovered?.pathLocalSegments) && hovered.pathLocalSegments.length > 1) { + const worldSegs = Array.isArray(hovered?.pathWorldSegments) ? hovered.pathWorldSegments : []; + const segKeys = Array.isArray(hovered?.pathSegmentKeys) ? hovered.pathSegmentKeys : []; + const segEntityIds = Array.isArray(hovered?.pathSegmentEntityIds) ? hovered.pathSegmentEntityIds : []; + for (let i = 0; i < hovered.pathLocalSegments.length; i++) { + const seg = hovered.pathLocalSegments[i]; + const wseg = worldSegs[i] || null; + const segKey = String(segKeys[i] || ''); + const segEntityId = String(segEntityIds[i] || ''); + const segKeyParts = segKey ? segKey.split(':') : []; + const segIndex = segKeyParts.length >= 4 ? Number(segKeyParts[segKeyParts.length - 1]) : NaN; + if (!seg?.a || !seg?.b) continue; + selectedEdges.push({ + type: 'edge', + aLocal: seg.a, + bLocal: seg.b, + source: { + ...(hovered.source || {}), + entity: segEntityId + ? { kind: 'boundary-segment', id: segEntityId } + : (hovered?.source?.entity || null), + boundary_segment_id: segEntityId || String(hovered?.source?.boundary_segment_id || ''), + edge_key: segKey || String(hovered?.source?.edge_key || ''), + edge_index: Number.isFinite(segIndex) + ? segIndex + : Number(hovered?.source?.edge_index ?? i), + a: wseg?.a || hovered?.source?.a || null, + b: wseg?.b || hovered?.source?.b || null, + local_a: seg.a, + local_b: seg.b + } + }); + } + } else { + selectedEdges.push({ + type: 'edge', + aLocal: hovered.aLocal, + bLocal: hovered.bLocal, + source: hovered.source || null + }); + } + } else if (this.hoveredSolidFaceKey) { + // Face derive is fallback only when no discrete edge is hovered. + selectedFaces.push(this.hoveredSolidFaceKey); + } + } + const payload = { + edges: selectedEdges, + points: selectedPoints, + faces: selectedFaces + }; + const created = this.deriveSelectionsAtomic(feature, payload); + if (!created) return false; + // Prevent immediate face-boundary re-highlight after `u` when cursor is still nearby. + this.hoveredSolidFaceKey = null; + api.solids?.setHoveredFace?.(null); + this.clearSketchSelection?.(); + return true; +} + +function useHoveredDerivedPoint() { + const feature = this.getEditingSketchFeature(); + const candidate = this.hoveredDerivedCandidate || null; + if (!feature || !candidate) { + return false; + } + const local = candidate?.hoverPoint?.local || candidate?.midLocal || null; + if (!local) return false; + const created = this.createDerivedSketchPoint(feature, local, { + ...(candidate.source || {}), + local_point: null, + point_kind: candidate?.hoverPoint?.kind || 'mid' + }); + if (!created) return false; + this.clearSketchSelection?.(); + return true; +} + +export { + getEditingSketchFeature, + isSketchEditing, + setSketchTool, + getSketchTool, + cancelSketchLine, + cancelSketchArc, + cancelSketchCircle, + cancelSketchRect, + clearSketchSelection, + getSelectedSketchMirrorAxis, + startSketchMirrorMode, + stopSketchMirrorMode, + getSelectedSketchPatternCenter, + startSketchCircularPatternMode, + stopSketchCircularPatternMode, + getSelectedSketchGridAnchor, + startSketchGridPatternMode, + stopSketchGridPatternMode, + editSketchCircularPatternConstraint, + editSketchGridPatternConstraint, + handleSketchKeyDown, + selectSketchConstraint, + setHoveredSketchConstraint, + useHoveredDerivedEdge, + useHoveredDerivedPoint +}; diff --git a/src/void/solid/chamfer.js b/src/void/solid/chamfer.js new file mode 100644 index 00000000..1e1f0c81 --- /dev/null +++ b/src/void/solid/chamfer.js @@ -0,0 +1,710 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { booleanMeshes } from './kernel.js'; + +function vec3(x = 0, y = 0, z = 0) { + return { x: Number(x) || 0, y: Number(y) || 0, z: Number(z) || 0 }; +} + +function add(a, b) { + return vec3(a.x + b.x, a.y + b.y, a.z + b.z); +} + +function sub(a, b) { + return vec3(a.x - b.x, a.y - b.y, a.z - b.z); +} + +function mul(a, s) { + return vec3(a.x * s, a.y * s, a.z * s); +} + +function dot(a, b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +function cross(a, b) { + return vec3( + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x + ); +} + +function length(a) { + return Math.hypot(a.x, a.y, a.z); +} + +function normalize(a) { + const len = length(a) || 1; + return vec3(a.x / len, a.y / len, a.z / len); +} + +function distanceSq(a, b) { + const d = sub(a, b); + return dot(d, d); +} + +function centroidFromPositions(pos) { + const count = Math.floor((pos?.length || 0) / 3); + if (!count) return vec3(0, 0, 0); + let sx = 0, sy = 0, sz = 0; + for (let i = 0; i < pos.length; i += 3) { + sx += Number(pos[i] || 0); + sy += Number(pos[i + 1] || 0); + sz += Number(pos[i + 2] || 0); + } + return vec3(sx / count, sy / count, sz / count); +} + +function pointFromPositions(pos, vi) { + const i = vi * 3; + return vec3(pos[i], pos[i + 1], pos[i + 2]); +} + +function normalForTriangle(pos, i0, i1, i2) { + const a = pointFromPositions(pos, i0); + const b = pointFromPositions(pos, i1); + const c = pointFromPositions(pos, i2); + const ab = sub(b, a); + const ac = sub(c, a); + const n = cross(ab, ac); + const len = length(n); + return len > 1e-12 ? mul(n, 1 / len) : vec3(0, 0, 1); +} + +function edgeKey(a, b) { + return a < b ? `${a}:${b}` : `${b}:${a}`; +} + +function buildMeshAdjacency(mesh) { + const pos = mesh?.positions; + const idx = mesh?.indices; + if (!pos?.length || !idx?.length) return null; + const edgeToTris = new Map(); + const edgeVerts = new Map(); + const triCount = Math.floor(idx.length / 3); + for (let t = 0; t < triCount; t++) { + const i0 = idx[t * 3]; + const i1 = idx[t * 3 + 1]; + const i2 = idx[t * 3 + 2]; + const edges = [[i0, i1], [i1, i2], [i2, i0]]; + for (const [va, vb] of edges) { + const k = edgeKey(va, vb); + const list = edgeToTris.get(k); + if (list) list.push(t); + else edgeToTris.set(k, [t]); + if (!edgeVerts.has(k)) edgeVerts.set(k, [va, vb]); + } + } + return { positions: pos, indices: idx, edgeToTris, edgeVerts, centroid: centroidFromPositions(pos) }; +} + +function edgeEndpointScore(a, b, ea, eb) { + const d1 = Math.sqrt(distanceSq(a, ea)) + Math.sqrt(distanceSq(b, eb)); + const d2 = Math.sqrt(distanceSq(a, eb)) + Math.sqrt(distanceSq(b, ea)); + return Math.min(d1, d2); +} + +function makeTriPrismMesh(a0, a1, a2, b0, b1, b2) { + const positions = new Float32Array([ + a0.x, a0.y, a0.z, + a1.x, a1.y, a1.z, + a2.x, a2.y, a2.z, + b0.x, b0.y, b0.z, + b1.x, b1.y, b1.z, + b2.x, b2.y, b2.z + ]); + const indices = new Uint32Array([ + 0, 2, 1, + 3, 4, 5, + 0, 1, 4, + 0, 4, 3, + 1, 2, 5, + 1, 5, 4, + 2, 0, 3, + 2, 3, 5 + ]); + return { positions, indices }; +} + +function getEdgeRecordByKey(meshInfo, key) { + const parts = String(key || '').split(':'); + if (parts.length !== 2) return null; + const va = Number(parts[0]); + const vb = Number(parts[1]); + if (!Number.isFinite(va) || !Number.isFinite(vb)) return null; + const ek = edgeKey(va, vb); + const rep = meshInfo?.edgeVerts?.get?.(ek) || null; + const tris = meshInfo?.edgeToTris?.get?.(ek) || null; + if (!rep || !Array.isArray(tris) || tris.length < 2) return null; + return { + va: Number(rep[0]), + vb: Number(rep[1]), + tris + }; +} + +function chooseTrianglePair(meshInfo, tris, va, vb, strictCrease = false) { + const positions = meshInfo.positions; + const indices = meshInfo.indices; + const creaseDotMax = Math.cos(30 * Math.PI / 180); + let pair = null; + let pairDot = 1; + for (let i = 0; i < tris.length; i++) { + for (let j = i + 1; j < tris.length; j++) { + const t0 = tris[i]; + const t1 = tris[j]; + const t0i0 = indices[t0 * 3]; + const t0i1 = indices[t0 * 3 + 1]; + const t0i2 = indices[t0 * 3 + 2]; + const t1i0 = indices[t1 * 3]; + const t1i1 = indices[t1 * 3 + 1]; + const t1i2 = indices[t1 * 3 + 2]; + const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2); + const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2); + const d = Math.max(-1, Math.min(1, dot(n1, n2))); + if (strictCrease && d > creaseDotMax) continue; + if (d < pairDot) { + pairDot = d; + pair = { t0, t1 }; + } + } + } + return pair; +} + +function buildCutterFromResolvedEdge(meshInfo, va, vb, tris, distance) { + const positions = meshInfo.positions; + const indices = meshInfo.indices; + const triPair = chooseTrianglePair(meshInfo, tris, va, vb, false); + if (!triPair) return null; + const t0 = triPair.t0; + const t1 = triPair.t1; + const t0i0 = indices[t0 * 3]; + const t0i1 = indices[t0 * 3 + 1]; + const t0i2 = indices[t0 * 3 + 2]; + const t1i0 = indices[t1 * 3]; + const t1i1 = indices[t1 * 3 + 1]; + const t1i2 = indices[t1 * 3 + 2]; + const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2); + const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2); + const a = pointFromPositions(positions, va); + const b = pointFromPositions(positions, vb); + const edge = sub(b, a); + const edgeLen = length(edge); + if (edgeLen <= 1e-8) return null; + const e = mul(edge, 1 / edgeLen); + let u1 = sub(n1, mul(e, dot(n1, e))); + let u2 = sub(n2, mul(e, dot(n2, e))); + if (length(u1) <= 1e-8 || length(u2) <= 1e-8) return null; + u1 = normalize(u1); + u2 = normalize(u2); + const normalDelta = Math.max(-1, Math.min(1, dot(u1, u2))); + if (normalDelta > 0.997) return null; + let i1 = mul(u1, -1); + let i2 = mul(u2, -1); + const mid = mul(add(a, b), 0.5); + const toCenter = sub(meshInfo.centroid, mid); + if (dot(i1, toCenter) < 0 && dot(i2, toCenter) < 0) { + i1 = mul(i1, -1); + i2 = mul(i2, -1); + } + const ext = distance * 1.4; + const spineA = add(a, mul(e, -ext)); + const spineB = add(b, mul(e, ext)); + let out = mul(add(i1, i2), -1); + if (length(out) <= 1e-8) { + out = mul(i1, -1); + } else { + out = normalize(out); + } + const insideScale = distance * 1.8; + const outsideScale = distance * 0.7; + const a0 = add(spineA, mul(out, outsideScale)); + const a1 = add(spineA, mul(i1, insideScale)); + const a2 = add(spineA, mul(i2, insideScale)); + const b0 = add(spineB, mul(out, outsideScale)); + const b1 = add(spineB, mul(i1, insideScale)); + const b2 = add(spineB, mul(i2, insideScale)); + const area = length(cross(sub(a1, a0), sub(a2, a0))); + if (area <= 1e-8) return null; + return { + key: edgeKey(va, vb), + mesh: makeTriPrismMesh(a0, a1, a2, b0, b1, b2) + }; +} + +function buildCutterForMeshEdgeKey(meshInfo, meshEdgeKey, distance) { + const rec = getEdgeRecordByKey(meshInfo, meshEdgeKey); + if (!rec) return null; + return buildCutterFromResolvedEdge(meshInfo, rec.va, rec.vb, rec.tris, distance); +} + +function buildCutterForSegment(meshInfo, aPoint, bPoint, distance) { + if (!meshInfo || !aPoint || !bPoint || !(distance > 0)) return null; + const positions = meshInfo.positions; + let best = null; + let bestScore = Infinity; + const creaseDotMax = Math.cos(30 * Math.PI / 180); + const requestedLen = Math.sqrt(distanceSq(aPoint, bPoint)); + if (!(requestedLen > 1e-9)) return null; + const reqDir = normalize(sub(bPoint, aPoint)); + const reqMid = mul(add(aPoint, bPoint), 0.5); + const findBest = (relaxed = false) => { + let localBest = null; + let localBestScore = Infinity; + for (const [ek, tris] of meshInfo.edgeToTris.entries()) { + if (!Array.isArray(tris) || tris.length < 2) continue; + const parts = String(ek).split(':'); + if (parts.length !== 2) continue; + const rep = meshInfo.edgeVerts?.get?.(ek) || null; + const va = Number(rep?.[0]); + const vb = Number(rep?.[1]); + if (!Number.isFinite(va) || !Number.isFinite(vb) || va === vb) continue; + const ea = pointFromPositions(positions, va); + const eb = pointFromPositions(positions, vb); + const cand = sub(eb, ea); + const candLen = length(cand); + if (!(candLen > 1e-9)) continue; + const candDir = mul(cand, 1 / candLen); + const dirAlign = Math.abs(dot(reqDir, candDir)); + const candMid = mul(add(ea, eb), 0.5); + const midDist = Math.sqrt(distanceSq(reqMid, candMid)); + if (!relaxed) { + if (dirAlign < 0.5) continue; + const maxMidDist = Math.max(1.5, requestedLen * 0.8, candLen * 0.8); + if (midDist > maxMidDist) continue; + } + // Find a usable face-pair on this edge. + const pair = chooseTrianglePair(meshInfo, tris, va, vb, false); + const pairDot = (() => { + if (!pair) return 1; + const idx = meshInfo.indices; + const t0i0 = idx[pair.t0 * 3]; + const t0i1 = idx[pair.t0 * 3 + 1]; + const t0i2 = idx[pair.t0 * 3 + 2]; + const t1i0 = idx[pair.t1 * 3]; + const t1i1 = idx[pair.t1 * 3 + 1]; + const t1i2 = idx[pair.t1 * 3 + 2]; + const n1 = normalForTriangle(meshInfo.positions, t0i0, t0i1, t0i2); + const n2 = normalForTriangle(meshInfo.positions, t1i0, t1i1, t1i2); + return Math.max(-1, Math.min(1, dot(n1, n2))); + })(); + if (!pair) continue; + if (!relaxed && pairDot > creaseDotMax) continue; + const score = edgeEndpointScore(aPoint, bPoint, ea, eb) + midDist * 0.5 + (1 - dirAlign) * (relaxed ? 0.1 : 2); + if (score < localBestScore) { + localBestScore = score; + localBest = { va, vb, tris: [pair.t0, pair.t1] }; + } + } + return { best: localBest, score: localBestScore }; + }; + const strict = findBest(false); + if (strict.best) { + best = strict.best; + bestScore = strict.score; + } else { + const relaxed = findBest(true); + best = relaxed.best; + bestScore = relaxed.score; + } + if (!best) return null; + return buildCutterFromResolvedEdge(meshInfo, best.va, best.vb, best.tris, distance); +} + +function segmentsFromEdgeRef(edgeRef) { + if (!edgeRef) return []; + const path = Array.isArray(edgeRef.path) ? edgeRef.path : null; + if (path?.length >= 2) { + let pts = path.map(p => vec3(p.x, p.y, p.z)); + // Remove duplicated closing point if present. + if (pts.length > 2) { + const first = pts[0]; + const last = pts[pts.length - 1]; + if (Math.sqrt(distanceSq(first, last)) <= 1e-7) { + pts = pts.slice(0, -1); + } + } + // Decimate very dense loops for cutter robustness/perf. + const maxPts = 49; // => at most 49 segments for closed loops + if (pts.length > maxPts) { + const reduced = []; + for (let i = 0; i < maxPts; i++) { + const t = i / (maxPts - 1); + const idx = Math.round(t * (pts.length - 1)); + reduced.push(pts[Math.max(0, Math.min(pts.length - 1, idx))]); + } + pts = reduced; + } + const out = []; + for (let i = 0; i < pts.length - 1; i++) { + const a = pts[i]; + const b = pts[i + 1]; + if (!a || !b) continue; + out.push([a, b]); + } + // Close if this came from a loop path. + if (pts.length > 2) { + out.push([pts[pts.length - 1], pts[0]]); + } + return out; + } + const a = edgeRef?.a; + const b = edgeRef?.b; + if (a && b) return [[vec3(a.x, a.y, a.z), vec3(b.x, b.y, b.z)]]; + return []; +} + +function concatMeshes(meshes = []) { + if (!Array.isArray(meshes) || !meshes.length) return null; + let posLen = 0; + let idxLen = 0; + for (const mesh of meshes) { + if (!mesh?.positions?.length || !mesh?.indices?.length) continue; + posLen += mesh.positions.length; + idxLen += mesh.indices.length; + } + if (!posLen || !idxLen) return null; + const positions = new Float32Array(posLen); + const indices = new Uint32Array(idxLen); + let po = 0; + let io = 0; + let vBase = 0; + for (const mesh of meshes) { + if (!mesh?.positions?.length || !mesh?.indices?.length) continue; + positions.set(mesh.positions, po); + for (let i = 0; i < mesh.indices.length; i++) { + indices[io + i] = Number(mesh.indices[i] || 0) + vBase; + } + po += mesh.positions.length; + io += mesh.indices.length; + vBase += Math.floor(mesh.positions.length / 3); + } + return { positions, indices }; +} + +function scaleMeshAroundCentroid(mesh, scale = 1.001) { + if (!mesh?.positions?.length || !mesh?.indices?.length) return null; + const c = centroidFromPositions(mesh.positions); + const out = new Float32Array(mesh.positions.length); + for (let i = 0; i < mesh.positions.length; i += 3) { + const x = Number(mesh.positions[i] || 0); + const y = Number(mesh.positions[i + 1] || 0); + const z = Number(mesh.positions[i + 2] || 0); + out[i] = c.x + (x - c.x) * scale; + out[i + 1] = c.y + (y - c.y) * scale; + out[i + 2] = c.z + (z - c.z) * scale; + } + return { + positions: out, + indices: mesh.indices instanceof Uint32Array ? mesh.indices : new Uint32Array(mesh.indices || []) + }; +} + +function cloneMesh(mesh) { + if (!mesh?.positions?.length || !mesh?.indices?.length) return null; + return { + positions: mesh.positions instanceof Float32Array ? new Float32Array(mesh.positions) : new Float32Array(mesh.positions || []), + indices: mesh.indices instanceof Uint32Array ? new Uint32Array(mesh.indices) : new Uint32Array(mesh.indices || []) + }; +} + +function makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, reason = 'no-op') { + const nextId = makeBodyId(feature.id, bodySeqRef.value++); + const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids) + ? targetSolid.source.sketch_ids.slice() + : []; + return { + id: nextId, + name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + pass_through: true, + reason, + sketch_ids: sketchIds + }, + provenance: { + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + pass_through: true, + reason + }, + parents: [solidId] + }, + mesh: { + tri_count: targetSolid?.mesh?.tri_count || 0, + vert_count: targetSolid?.mesh?.vert_count || 0 + }, + status: 'manifold_chamfer_passthrough' + }; +} + +function parseBoundarySegmentRef(boundarySegmentId) { + const raw = String(boundarySegmentId || ''); + if (!raw) return null; + const parts = raw.split(':'); + if (raw.startsWith('segment:') && parts.length >= 5) { + const segInLoop = Number(parts[parts.length - 1]); + const loopIndex = Number(parts[parts.length - 2]); + const faceId = Number(parts[parts.length - 3]); + const solidId = parts.slice(1, -3).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex) || !Number.isFinite(segInLoop)) return null; + return { kind: 'segment', id: raw, solidId, faceId, loopIndex, segInLoop }; + } + if (raw.startsWith('boundary:') && parts.length >= 4) { + const loopIndex = Number(parts[parts.length - 1]); + const faceId = Number(parts[parts.length - 2]); + const solidId = parts.slice(1, -2).join(':'); + if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex)) return null; + return { kind: 'boundary', id: raw, solidId, faceId, loopIndex }; + } + return null; +} + +async function applyChamferFeature(solids, meshCache, feature, makeBodyId, bodySeqRef) { + const refs = Array.isArray(feature?.input?.edges) ? feature.input.edges : []; + const distance = Math.max(0.0001, Math.abs(Number(feature?.params?.distance ?? 1))); + const showCutters = feature?.params?.showCutters === true; + if (!refs.length || !(distance > 0)) return false; + + const bySolid = new Map(); + for (const ref of refs) { + let solidId = String(ref?.solidId || ref?.solid_id || ''); + if (!solidId) { + const parsed = parseBoundarySegmentRef(ref?.boundary_segment_id || ref?.entity?.id || ref?.key || null); + solidId = String(parsed?.solidId || ''); + } + if (!solidId) continue; + const list = bySolid.get(solidId); + if (list) list.push(ref); + else bySolid.set(solidId, [ref]); + } + if (!bySolid.size) return false; + + let changed = false; + for (const [solidId, solidRefs] of bySolid.entries()) { + const targetSolid = solids.find(s => s?.id === solidId) || null; + const targetMesh = meshCache.get(solidId); + if (!targetSolid || !targetMesh?.positions?.length || !targetMesh?.indices?.length) continue; + const adj = buildMeshAdjacency(targetMesh); + if (!adj) continue; + const tools = []; + const usedEdgeKeys = new Set(); + for (const ref of solidRefs) { + const meshEdgeKeys = Array.isArray(ref?.meshEdgeKeys) ? ref.meshEdgeKeys.filter(Boolean) : []; + if (meshEdgeKeys.length) { + for (const mek of meshEdgeKeys) { + const built = buildCutterForMeshEdgeKey(adj, mek, distance); + const cutter = built?.mesh || null; + const cutterKey = String(built?.key || mek || ''); + if (cutterKey && usedEdgeKeys.has(cutterKey)) continue; + if (cutter?.positions?.length && cutter?.indices?.length) { + if (cutterKey) usedEdgeKeys.add(cutterKey); + tools.push(cutter); + } + } + continue; + } + if (ref?.meshEdgeKey && !(Array.isArray(ref.path) && ref.path.length >= 2)) { + const built = buildCutterForMeshEdgeKey(adj, ref.meshEdgeKey, distance); + const cutter = built?.mesh || null; + const cutterKey = String(built?.key || ''); + if (cutterKey && usedEdgeKeys.has(cutterKey)) continue; + if (cutter?.positions?.length && cutter?.indices?.length) { + if (cutterKey) usedEdgeKeys.add(cutterKey); + tools.push(cutter); + continue; + } + } + const segs = segmentsFromEdgeRef(ref); + for (const [a, b] of segs) { + const built = buildCutterForSegment(adj, a, b, distance); + const cutter = built?.mesh || null; + const cutterKey = String(built?.key || ''); + if (cutterKey && usedEdgeKeys.has(cutterKey)) continue; + if (cutter?.positions?.length && cutter?.indices?.length) { + if (cutterKey) usedEdgeKeys.add(cutterKey); + tools.push(cutter); + } + } + } + if (!tools.length) { + console.warn('void.chamfer.no_cutters', { featureId: feature?.id, solidId, refs: solidRefs.length }); + const targetIndex = solids.findIndex(s => s?.id === solidId); + if (targetIndex >= 0) { + const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'no_cutters'); + const copied = cloneMesh(targetMesh); + if (copied?.positions?.length && copied?.indices?.length) { + solids[targetIndex] = nextSolid; + meshCache.delete(solidId); + meshCache.set(nextSolid.id, copied); + changed = true; + } + } + continue; + } + if (showCutters) { + const merged = concatMeshes(tools); + if (merged?.positions?.length && merged?.indices?.length) { + const nextId = makeBodyId(feature.id, bodySeqRef.value++); + const nextSolid = { + id: nextId, + name: `${feature.name || 'Chamfer'}-cutters`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + debug: 'cutters', + distance + }, + provenance: { + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + debug: 'cutters' + }, + parents: [solidId] + }, + mesh: { + tri_count: (merged.indices.length || 0) / 3, + vert_count: (merged.positions.length || 0) / 3 + }, + status: 'manifold_chamfer_debug_cutters' + }; + solids.push(nextSolid); + meshCache.set(nextId, merged); + console.log('void.chamfer.debug_cutters', { + featureId: feature?.id, + solidId, + cutters: tools.length, + tri: Math.floor((merged.indices.length || 0) / 3) + }); + changed = true; + } + continue; + } + + let result = await booleanMeshes({ + mode: 'subtract', + targets: [targetMesh], + tools + }); + if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) { + // Fallback: sequential subtract can be more robust than bulk subtract. + let current = targetMesh; + let applied = 0; + for (const tool of tools) { + const step = await booleanMeshes({ + mode: 'subtract', + targets: [current], + tools: [tool] + }); + if (step?.mesh?.positions?.length && step?.mesh?.indices?.length) { + current = step.mesh; + applied++; + continue; + } + // Retry with tiny perturbation to avoid coplanar/not-manifold failures. + const grown = scaleMeshAroundCentroid(tool, 1.001); + if (grown) { + const stepGrown = await booleanMeshes({ + mode: 'subtract', + targets: [current], + tools: [grown] + }); + if (stepGrown?.mesh?.positions?.length && stepGrown?.mesh?.indices?.length) { + current = stepGrown.mesh; + applied++; + } + } + } + if (applied > 0) { + result = { mesh: current }; + console.warn('void.chamfer.bulk_failed_sequential_used', { + featureId: feature?.id, + solidId, + cutters: tools.length, + applied + }); + } + } + if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) { + console.warn('void.chamfer.boolean_failed', { featureId: feature?.id, solidId, cutters: tools.length }); + const targetIndex = solids.findIndex(s => s?.id === solidId); + if (targetIndex >= 0) { + const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'boolean_failed'); + const copied = cloneMesh(targetMesh); + if (copied?.positions?.length && copied?.indices?.length) { + solids[targetIndex] = nextSolid; + meshCache.delete(solidId); + meshCache.set(nextSolid.id, copied); + changed = true; + } + } + continue; + } + + const targetIndex = solids.findIndex(s => s?.id === solidId); + if (targetIndex < 0) continue; + const nextId = makeBodyId(feature.id, bodySeqRef.value++); + const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids) + ? targetSolid.source.sketch_ids.slice() + : []; + const nextSolid = { + id: nextId, + name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + edges: solidRefs.map(ref => ({ + key: ref?.key || null, + boundary_segment_id: ref?.boundary_segment_id || null, + solidId: ref?.solidId || null, + edgeIndex: ref?.edgeIndex ?? null + })), + distance, + sketch_ids: sketchIds + }, + provenance: { + source: { + feature_id: feature.id, + feature_type: feature.type, + parent: solidId, + distance + }, + parents: [solidId] + }, + mesh: { + tri_count: (result.mesh.indices.length || 0) / 3, + vert_count: (result.mesh.positions.length || 0) / 3 + }, + status: 'manifold_chamfer_ready' + }; + solids.splice(targetIndex, 1, nextSolid); + meshCache.delete(solidId); + meshCache.set(nextId, result.mesh); + console.log('void.chamfer.applied', { + featureId: feature?.id, + solidId, + cutters: tools.length, + triIn: Math.floor((targetMesh.indices?.length || 0) / 3), + triOut: Math.floor((result.mesh.indices?.length || 0) / 3) + }); + changed = true; + } + return changed; +} + +export { + applyChamferFeature +}; diff --git a/src/void/solid/kernel.js b/src/void/solid/kernel.js new file mode 100644 index 00000000..c2c162a4 --- /dev/null +++ b/src/void/solid/kernel.js @@ -0,0 +1,158 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import manifold from '../../ext/manifold.js'; + +let _instance = null; +let _initPromise = null; + +function locateFile(path) { + return `../wasm/${path}`; +} + +async function ensureKernel() { + if (_instance) return _instance; + if (_initPromise) return _initPromise; + _initPromise = manifold({ locateFile }).then(inst => { + inst.setup(); + _instance = inst; + return _instance; + }).catch(error => { + console.warn('void.solid.kernel init failed', error); + return null; + }); + return _initPromise; +} + +function isReady() { + return !!_instance; +} + +function getInstance() { + return _instance; +} + +async function extrudePolygons(polygons, height = 1) { + const inst = await ensureKernel(); + if (!inst?.Manifold || !Array.isArray(polygons) || !polygons.length) { + return null; + } + try { + const man = inst.Manifold.extrude(polygons, Number(height) || 1); + const mesh = man.getMesh(); + // Callers are responsible for consuming mesh data and deleting manifold. + return { manifold: man, mesh }; + } catch (error) { + console.warn('void.solid.kernel extrude failed', error); + return null; + } +} + +function toKernelMesh(inst, meshData) { + const positions = meshData?.positions; + const indices = meshData?.indices; + if (!positions?.length || !indices?.length) return null; + const vertCount = Math.floor(positions.length / 3); + const props = new Float32Array(vertCount * 3); + props.set(positions); + return new inst.Mesh({ + numProp: 3, + vertProperties: props, + triVerts: Uint32Array.from(indices) + }); +} + +function fromKernelMesh(mesh) { + const numProp = Math.max(3, Number(mesh?.numProp || 3)); + const verts = mesh?.vertProperties; + const triVerts = mesh?.triVerts; + if (!verts?.length || !triVerts?.length) return null; + const vertCount = Math.floor(verts.length / numProp); + const positions = new Float32Array(vertCount * 3); + for (let i = 0; i < vertCount; i++) { + const src = i * numProp; + const dst = i * 3; + positions[dst] = Number(verts[src] || 0); + positions[dst + 1] = Number(verts[src + 1] || 0); + positions[dst + 2] = Number(verts[src + 2] || 0); + } + return { + positions, + indices: Uint32Array.from(triVerts) + }; +} + +async function booleanMeshes(input, mode = 'add') { + const inst = await ensureKernel(); + if (!inst?.Manifold) { + return null; + } + const options = Array.isArray(input) + ? { meshes: input, mode } + : { ...(input || {}), mode: String((input || {}).mode || mode || 'add') }; + const op = String(options.mode || 'add'); + const meshes = Array.isArray(options.meshes) ? options.meshes : []; + const targetMeshes = Array.isArray(options.targets) ? options.targets : null; + const toolMeshes = Array.isArray(options.tools) ? options.tools : null; + const manifolds = []; + let result = null; + try { + const toManifold = meshData => { + const kernelMesh = toKernelMesh(inst, meshData); + return kernelMesh ? new inst.Manifold(kernelMesh) : null; + }; + const combine = (list, kind = 'add') => { + if (!Array.isArray(list) || !list.length) return null; + if (list.length === 1) return list[0]; + if (kind === 'intersect') return inst.Manifold.intersection(list); + if (kind === 'subtract') return inst.Manifold.difference(list); + return inst.Manifold.union(list); + }; + + if (op === 'subtract' && targetMeshes && toolMeshes) { + const targetMfs = targetMeshes.map(toManifold).filter(Boolean); + const toolMfs = toolMeshes.map(toManifold).filter(Boolean); + manifolds.push(...targetMfs, ...toolMfs); + if (!targetMfs.length || !toolMfs.length) { + return null; + } + const targetUnion = combine(targetMfs, 'add'); + const toolUnion = combine(toolMfs, 'add'); + if (!targetUnion || !toolUnion) return null; + if (!targetMfs.includes(targetUnion)) manifolds.push(targetUnion); + if (!toolMfs.includes(toolUnion)) manifolds.push(toolUnion); + result = inst.Manifold.difference([targetUnion, toolUnion]); + } else { + for (const meshData of meshes) { + const manifold = toManifold(meshData); + if (!manifold) continue; + manifolds.push(manifold); + } + if (manifolds.length < 2) { + return null; + } + if (op === 'intersect') { + result = inst.Manifold.intersection(manifolds); + } else { + result = inst.Manifold.union(manifolds); + } + } + const mesh = result?.getMesh?.(); + return mesh ? { mesh: fromKernelMesh(mesh) } : null; + } catch (error) { + console.warn('void.solid.kernel boolean failed', error); + return null; + } finally { + for (const manifold of manifolds) { + manifold?.delete?.(); + } + result?.delete?.(); + } +} + +export { + ensureKernel, + isReady, + getInstance, + extrudePolygons, + booleanMeshes +}; diff --git a/src/void/solid/provenance.js b/src/void/solid/provenance.js new file mode 100644 index 00000000..4730dc4c --- /dev/null +++ b/src/void/solid/provenance.js @@ -0,0 +1,22 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function buildSeedProvenance(feature, profileTarget, bodyIndex = 0) { + return { + source: { + feature_id: feature?.id || null, + feature_type: feature?.type || null, + profile: profileTarget || null + }, + faces: [ + { role: 'cap_start', source: profileTarget || null }, + { role: 'cap_end', source: profileTarget || null }, + { role: 'side', source: profileTarget || null } + ], + body_index: bodyIndex + }; +} + +export { + buildSeedProvenance +}; + diff --git a/src/void/solid/rebuild.js b/src/void/solid/rebuild.js new file mode 100644 index 00000000..09d9f203 --- /dev/null +++ b/src/void/solid/rebuild.js @@ -0,0 +1,564 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { buildSeedProvenance } from './provenance.js'; +import { extrudePolygons, booleanMeshes } from './kernel.js'; +import { ClipperLib } from '../../ext/clip2.esm.js'; +import { applyChamferFeature } from './chamfer.js'; + +const CLIPPER_SCALE = 100000; + +function resolveProfileTargetRef(profileTarget = {}) { + const regionId = String(profileTarget?.region_id || ''); + const match = regionId.match(/^profile:([^:]+):([^:]+)$/); + if (!match) return { regionId: null, sketchId: null, profileId: null, key: null }; + const sketchId = match[1]; + const profileId = match[2]; + return { regionId, sketchId, profileId, key: regionId }; +} + +function profileLoopsFromRuntime(api, profileTarget) { + const { sketchId, profileId } = resolveProfileTargetRef(profileTarget); + if (!sketchId || !profileId) return null; + const rec = api.sketchRuntime?.getRecord?.(sketchId); + const view = rec?.entityViews?.get?.(profileId); + const loops = view?.object?.userData?.sketchProfileLoops || view?.entity?.loops || null; + if (Array.isArray(loops) && loops.length) { + const out = loops.filter(loop => Array.isArray(loop) && loop.length >= 3); + return out.length ? out : null; + } + const loop = view?.object?.userData?.sketchProfileLoop || view?.entity?.loop || null; + if (Array.isArray(loop) && loop.length >= 3) return [loop]; + return null; +} + +function profileLoopsFromSnapshot(snapshot, profileTarget) { + const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget); + if (!key || !sketchId || !profileId) return null; + const map = snapshot?.profileLoops || {}; + const loops = map[key]; + if (!Array.isArray(loops) || !loops.length) return null; + return loops + .filter(loop => Array.isArray(loop) && loop.length >= 3) + .map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) }))); +} + +function makeBodyId(featureId, index) { + return `${featureId}:body:${index}`; +} + +function getSketchIdsForSolid(solid) { + const ids = new Set(); + const add = value => { + if (value) ids.add(value); + }; + add(solid?.source?.profile?.sketchId); + for (const sid of solid?.source?.sketch_ids || []) { + add(sid); + } + add(solid?.provenance?.source?.profile?.sketchId); + for (const face of solid?.provenance?.faces || []) { + add(face?.source?.sketchId); + } + return ids; +} + +function basisFromPlaneFrame(frame) { + const origin = { + x: Number(frame?.origin?.x ?? 0), + y: Number(frame?.origin?.y ?? 0), + z: Number(frame?.origin?.z ?? 0) + }; + const normalRaw = { + x: Number(frame?.normal?.x ?? 0), + y: Number(frame?.normal?.y ?? 0), + z: Number(frame?.normal?.z ?? 1) + }; + const nxLen = Math.hypot(normalRaw.x, normalRaw.y, normalRaw.z) || 1; + const normal = { + x: normalRaw.x / nxLen, + y: normalRaw.y / nxLen, + z: normalRaw.z / nxLen + }; + const xAxisRaw = { + x: Number(frame?.x_axis?.x ?? 1), + y: Number(frame?.x_axis?.y ?? 0), + z: Number(frame?.x_axis?.z ?? 0) + }; + // remove normal component + const xDotN = xAxisRaw.x * normal.x + xAxisRaw.y * normal.y + xAxisRaw.z * normal.z; + let xAxis = { + x: xAxisRaw.x - normal.x * xDotN, + y: xAxisRaw.y - normal.y * xDotN, + z: xAxisRaw.z - normal.z * xDotN + }; + const xLen = Math.hypot(xAxis.x, xAxis.y, xAxis.z) || 1; + xAxis = { x: xAxis.x / xLen, y: xAxis.y / xLen, z: xAxis.z / xLen }; + // y = n x x + const yAxis = { + x: normal.y * xAxis.z - normal.z * xAxis.y, + y: normal.z * xAxis.x - normal.x * xAxis.z, + z: normal.x * xAxis.y - normal.y * xAxis.x + }; + return { origin, xAxis, yAxis, normal }; +} + +function transformMeshToWorld(mesh, basis, zShift = 0) { + const numProp = Math.max(3, Number(mesh?.numProp || 3)); + const verts = mesh?.vertProperties; + const triVerts = mesh?.triVerts; + if (!verts?.length || !triVerts?.length) return null; + const vertCount = Math.floor(verts.length / numProp); + const positions = new Float32Array(vertCount * 3); + const { origin, xAxis, yAxis, normal } = basis; + for (let i = 0; i < vertCount; i++) { + const o = i * numProp; + const lx = Number(verts[o] || 0); + const ly = Number(verts[o + 1] || 0); + const lz = Number(verts[o + 2] || 0) + (Number(zShift) || 0); + const wx = origin.x + xAxis.x * lx + yAxis.x * ly + normal.x * lz; + const wy = origin.y + xAxis.y * lx + yAxis.y * ly + normal.y * lz; + const wz = origin.z + xAxis.z * lx + yAxis.z * ly + normal.z * lz; + const p = i * 3; + positions[p] = wx; + positions[p + 1] = wy; + positions[p + 2] = wz; + } + return { + positions, + indices: Uint32Array.from(triVerts) + }; +} + +function polygonSignedArea(loop) { + if (!Array.isArray(loop) || loop.length < 3) return 0; + let area2 = 0; + for (let i = 0; i < loop.length; i++) { + const a = loop[i]; + const b = loop[(i + 1) % loop.length]; + area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0); + } + return area2 * 0.5; +} + +function ensureLoopWinding(loop, ccw = true) { + if (!Array.isArray(loop) || loop.length < 3) return loop; + const isCCW = polygonSignedArea(loop) > 0; + if ((ccw && isCCW) || (!ccw && !isCCW)) return loop; + return loop.slice().reverse(); +} + +function toClipperPath(loop) { + if (!Array.isArray(loop) || loop.length < 3) return null; + const path = []; + for (const p of loop) { + path.push({ + X: Math.round((p?.x || 0) * CLIPPER_SCALE), + Y: Math.round((p?.y || 0) * CLIPPER_SCALE) + }); + } + return path.length >= 3 ? path : null; +} + +function fromClipperPath(path) { + if (!Array.isArray(path) || path.length < 3) return null; + return path.map(pt => ({ + x: Number(pt?.X || 0) / CLIPPER_SCALE, + y: Number(pt?.Y || 0) / CLIPPER_SCALE + })); +} + +function unionSelectedRegions(profileLoopsList) { + if (!Array.isArray(profileLoopsList) || !profileLoopsList.length || !ClipperLib?.Clipper) { + return []; + } + const subject = []; + for (const loops of profileLoopsList) { + if (!Array.isArray(loops)) continue; + for (const loop of loops) { + const path = toClipperPath(loop); + if (path) subject.push(path); + } + } + if (!subject.length) return []; + const clip = new ClipperLib.Clipper(); + clip.AddPaths(subject, ClipperLib.PolyType.ptSubject, true); + const tree = new ClipperLib.PolyTree(); + const ok = clip.Execute( + ClipperLib.ClipType.ctUnion, + tree, + ClipperLib.PolyFillType.pftEvenOdd, + ClipperLib.PolyFillType.pftEvenOdd + ); + if (!ok) return []; + const exPolys = ClipperLib.JS?.PolyTreeToExPolygons + ? ClipperLib.JS.PolyTreeToExPolygons(tree) + : []; + const out = []; + for (const ex of exPolys || []) { + const outer = fromClipperPath(ex?.outer); + if (!outer || outer.length < 3) continue; + const holes = []; + for (const hole of ex?.holes || []) { + const loop = fromClipperPath(hole); + if (loop && loop.length >= 3) holes.push(loop); + } + out.push({ + outer: ensureLoopWinding(outer, true), + holes: holes.map(loop => ensureLoopWinding(loop, false)) + }); + } + return out; +} + +function pointInLoop(point, loop) { + if (!point || !Array.isArray(loop) || loop.length < 3) return false; + let inside = false; + for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) { + const xi = Number(loop[i]?.x || 0); + const yi = Number(loop[i]?.y || 0); + const xj = Number(loop[j]?.x || 0); + const yj = Number(loop[j]?.y || 0); + const intersects = ((yi > point.y) !== (yj > point.y)) + && (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || 1e-12) + xi); + if (intersects) inside = !inside; + } + return inside; +} + +function pointInRegion(point, region) { + if (!point || !region?.outer) return false; + if (!pointInLoop(point, region.outer)) return false; + const holes = Array.isArray(region.holes) ? region.holes : []; + for (const hole of holes) { + if (pointInLoop(point, hole)) return false; + } + return true; +} + +function loopCentroid(loop) { + if (!Array.isArray(loop) || loop.length < 3) return null; + let sx = 0; + let sy = 0; + let n = 0; + for (const p of loop) { + if (!p) continue; + sx += Number(p.x || 0); + sy += Number(p.y || 0); + n++; + } + if (!n) return null; + return { x: sx / n, y: sy / n }; +} + +async function rebuildGeneratedSolidsFromSnapshot(snapshot, options = {}) { + const builtFeatures = Array.isArray(snapshot?.builtFeatures) ? snapshot.builtFeatures : []; + const sketchPlanes = snapshot?.sketchPlanes || {}; + const solids = []; + const meshCache = new Map(); + let bodySeq = 0; + + for (const feature of builtFeatures) { + if (feature?.type === 'extrude') { + const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : []; + if (!profiles.length) continue; + const params = feature?.params || {}; + const depth = Math.max(0.0001, Math.abs(Number(params.depth ?? params.distance ?? 1))); + const symmetric = params.symmetric === true; + const direction = params.direction === 'reverse' ? 'reverse' : 'normal'; + const operation = ['new', 'add', 'subtract'].includes(String(params.operation || 'new')) + ? String(params.operation || 'new') + : 'new'; + const localZShift = symmetric ? (-depth / 2) : (direction === 'reverse' ? -depth : 0); + const createdBodyIds = []; + const bySketch = new Map(); + for (const profileTarget of profiles) { + const { sketchId, profileId } = resolveProfileTargetRef(profileTarget); + if (!sketchId || !profileId) continue; + const profileLoops = profileLoopsFromSnapshot(snapshot, profileTarget); + if (!profileLoops?.length) continue; + const basis = basisFromPlaneFrame(sketchPlanes?.[sketchId] || {}); + if (!bySketch.has(sketchId)) { + bySketch.set(sketchId, { sketchId, basis, entries: [] }); + } + bySketch.get(sketchId).entries.push({ profileTarget, profileLoops }); + } + + for (const sketchPack of bySketch.values()) { + const resolvedRegions = unionSelectedRegions(sketchPack.entries.map(e => e.profileLoops)); + for (const region of resolvedRegions) { + const polygons = [ + region.outer, + ...(region.holes || []) + ].map(loop => loop.map(p => [p.x || 0, p.y || 0])); + if (!polygons.length) continue; + const bodyIndex = bodySeq++; + const id = makeBodyId(feature.id, bodyIndex); + let primaryTarget = null; + const contributingProfileKeys = []; + for (const entry of sketchPack.entries) { + const { key } = resolveProfileTargetRef(entry?.profileTarget || {}); + if (!key) continue; + let contributes = false; + const loops = Array.isArray(entry?.profileLoops) ? entry.profileLoops : []; + for (const loop of loops) { + const sample = loopCentroid(loop); + if (sample && pointInRegion(sample, region)) { + contributes = true; + break; + } + } + if (contributes) { + contributingProfileKeys.push(key); + if (!primaryTarget) primaryTarget = entry.profileTarget || null; + } + } + if (!primaryTarget) { + primaryTarget = sketchPack.entries[0]?.profileTarget || null; + } + const primaryRef = resolveProfileTargetRef(primaryTarget || {}); + if (!contributingProfileKeys.length && primaryRef?.key) { + contributingProfileKeys.push(primaryRef.key); + } + const body = { + id, + name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + profile: primaryTarget, + profile_keys: contributingProfileKeys + }, + provenance: buildSeedProvenance(feature, primaryTarget, bodyIndex), + mesh: null, + status: 'pending_manifold' + }; + const result = await extrudePolygons(polygons, depth); + if (result?.mesh) { + const meshWorld = transformMeshToWorld(result.mesh, sketchPack.basis, localZShift); + body.status = 'manifold_mesh_ready'; + body.mesh = { + tri_count: (result.mesh?.triVerts?.length || 0) / 3, + vert_count: (result.mesh?.vertProperties?.length || 0) / Math.max(1, result.mesh?.numProp || 3) + }; + body.extrude = { depth, direction, symmetric }; + if (meshWorld) { + meshCache.set(id, meshWorld); + createdBodyIds.push(id); + } + result.manifold?.delete?.(); + } + solids.push(body); + } + } + + if ((operation === 'add' || operation === 'subtract') && createdBodyIds.length) { + const targetIds = Array.isArray(feature?.input?.targets) + ? feature.input.targets.map(id => String(id || '')).filter(Boolean) + : []; + const createdSolids = createdBodyIds.map(id => solids.find(s => s?.id === id)).filter(Boolean); + const targetSolids = targetIds.map(id => solids.find(s => s?.id === id)).filter(Boolean); + const toolMeshes = createdSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + let merge = null; + if (operation === 'add') { + const meshes = [...targetMeshes, ...toolMeshes]; + if (meshes.length >= 2) merge = await booleanMeshes(meshes, 'add'); + } else if (operation === 'subtract') { + if (targetMeshes.length && toolMeshes.length) { + merge = await booleanMeshes({ mode: 'subtract', targets: targetMeshes, tools: toolMeshes }); + } + } + if (merge?.mesh?.positions?.length && merge?.mesh?.indices?.length) { + const consumed = new Set([...targetSolids.map(s => s.id), ...createdSolids.map(s => s.id)]); + const sketchIds = new Set(); + for (const solid of [...targetSolids, ...createdSolids]) { + for (const sid of getSketchIdsForSolid(solid)) { + sketchIds.add(sid); + } + meshCache.delete(solid?.id); + } + const kept = solids.filter(s => !consumed.has(s?.id)); + solids.length = 0; + solids.push(...kept); + const bodyIndex = bodySeq++; + const id = makeBodyId(feature.id, bodyIndex); + const body = { + id, + name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + operation, + targets: targetIds, + tools: createdBodyIds, + sketch_ids: Array.from(sketchIds) + }, + provenance: { + source: { + feature_id: feature.id, + feature_type: feature.type, + operation, + targets: targetIds, + tools: createdBodyIds + }, + parents: [...targetIds, ...createdBodyIds] + }, + mesh: { + tri_count: (merge.mesh?.indices?.length || 0) / 3, + vert_count: (merge.mesh?.positions?.length || 0) / 3 + }, + status: 'manifold_extrude_boolean_ready' + }; + meshCache.set(id, merge.mesh); + solids.push(body); + } + } + continue; + } + + if (feature?.type === 'boolean') { + const mode = String(feature?.params?.mode || 'add'); + const targets = Array.isArray(feature?.input?.targets) + ? feature.input.targets.map(id => String(id || '')).filter(Boolean) + : []; + const tools = Array.isArray(feature?.input?.tools) + ? feature.input.tools.map(id => String(id || '')).filter(Boolean) + : []; + const selectedIds = mode === 'subtract' + ? Array.from(new Set([...targets, ...tools])) + : targets.slice(); + if (!selectedIds.length) continue; + const selectedSet = new Set(selectedIds); + const targetSolids = targets.map(id => solids.find(s => s?.id === id)).filter(Boolean); + const toolSolids = tools.map(id => solids.find(s => s?.id === id)).filter(Boolean); + if (mode === 'subtract') { + if (!targetSolids.length || !toolSolids.length) continue; + } else if (targetSolids.length < 2) { + continue; + } + const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + const toolMeshes = toolSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + if (mode === 'subtract') { + if (!targetMeshes.length || !toolMeshes.length) continue; + } else if (targetMeshes.length < 2) { + continue; + } + const sketchIds = new Set(); + for (const solid of [...targetSolids, ...toolSolids]) { + for (const sid of getSketchIdsForSolid(solid)) sketchIds.add(sid); + } + const result = mode === 'subtract' + ? await booleanMeshes({ mode, targets: targetMeshes, tools: toolMeshes }) + : await booleanMeshes(targetMeshes, mode); + const kept = solids.filter(s => !selectedSet.has(s?.id)); + for (const target of [...targetSolids, ...toolSolids]) meshCache.delete(target?.id); + solids.length = 0; + solids.push(...kept); + if (result?.mesh?.positions?.length && result?.mesh?.indices?.length) { + const bodyIndex = bodySeq++; + const id = makeBodyId(feature.id, bodyIndex); + const body = { + id, + name: `${feature.name || 'Boolean'}-${bodyIndex + 1}`, + visible: feature.visible !== false, + source: { + feature_id: feature.id, + feature_type: feature.type, + targets, + tools, + solids: selectedIds, + mode, + sketch_ids: Array.from(sketchIds) + }, + provenance: { + source: { + feature_id: feature.id, + feature_type: feature.type, + targets, + tools, + solids: selectedIds, + mode + }, + parents: selectedIds + }, + mesh: { + tri_count: (result.mesh?.indices?.length || 0) / 3, + vert_count: (result.mesh?.positions?.length || 0) / 3 + }, + status: 'manifold_boolean_ready' + }; + meshCache.set(id, result.mesh); + solids.push(body); + } + continue; + } + + if (feature?.type === 'chamfer') { + const bodySeqRef = { value: bodySeq }; + const changed = await applyChamferFeature( + solids, + meshCache, + feature, + makeBodyId, + bodySeqRef + ); + bodySeq = bodySeqRef.value; + if (changed) { + // keep processing downstream features against updated solids + } + } + } + return { solids, meshCache }; +} + +async function rebuildGeneratedSolids(api, options = {}) { + const doc = api.document.current; + if (!doc) return { solids: [], meshCache: new Map() }; + const builtFeatures = api.features.listBuilt(); + const sketchPlanes = {}; + for (const feature of (api.features.list() || [])) { + if (feature?.type === 'sketch' && feature?.id) { + sketchPlanes[feature.id] = feature.plane || {}; + } + } + const profileLoops = {}; + for (const feature of builtFeatures) { + if (feature?.type !== 'extrude') continue; + const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : []; + for (const profileTarget of profiles) { + const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget); + if (!sketchId || !profileId) continue; + const loops = profileLoopsFromRuntime(api, profileTarget); + if (!loops?.length) continue; + profileLoops[key] = loops; + } + } + const { solids, meshCache } = await rebuildGeneratedSolidsFromSnapshot({ + builtFeatures, + sketchPlanes, + profileLoops + }, options); + + doc.generated = doc.generated || {}; + doc.generated.solids = solids; + if (options.persist !== false) { + await api.document.save({ + kind: 'micro', + opType: 'solid.rebuild', + undoable: false, + clearRedo: false, + payload: { + reason: options.reason || 'rebuild', + solids: solids.length + } + }); + } + return { solids, meshCache }; +} + +export { + rebuildGeneratedSolids, + rebuildGeneratedSolidsFromSnapshot +}; diff --git a/src/void/toolbar.js b/src/void/toolbar.js new file mode 100644 index 00000000..eb3273c6 --- /dev/null +++ b/src/void/toolbar.js @@ -0,0 +1,1451 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { $ } from '../moto/webui.js'; +import { api } from './api.js'; +import { tree } from './tree.js'; +import { space } from '../moto/space.js'; +import { properties } from './properties.js'; +import { encode as objEncode } from '../load/obj.js'; +import { encodeASCII as stlEncodeASCII } from '../load/stl.js'; +import { encode as tmfEncode } from '../load/3mf.js'; +import { meshToSTEPWithFaces } from '../load/step.js'; +import { JSZip } from '../ext/jszip-esm.js'; + +const toolbar = { + buttons: [], + cameraToggleBtn: null, + sketchBtn: null, + extrudeBtn: null, + chamferBtn: null, + booleanBtn: null, + sketchToolButtons: null, + sketchToolMenus: null, + sketchToolMenuItems: null, + sketchConstraintButtons: null, + sketchConstraintMenu: null, + sketchEditGroupEl: null, + solidOpsGroupEl: null, + docNameEl: null, + openDialogEl: null, + openDialogListEl: null, + hotkeysDialogEl: null, + preferencesDialogEl: null, + preferencesInputs: null, + preferencesState: null, + exportDialogEl: null, + exportDialogInfoEl: null, + exportFilenameEl: null, + exportStlZipEl: null, + preferencesStorageKey: 'void_preferences', + preferencesAdminKey: 'preferences', + + build() { + const container = $('top-bar'); + if (!container) return; + + container.innerHTML = ''; + this.buttons = []; + + // Logo / title + const title = document.createElement('div'); + title.className = 'toolbar-title'; + title.textContent = 'Void:Form'; + container.appendChild(title); + + // Separator + container.appendChild(this.separator()); + + // Main tools + this.addMenu(container, 'File', [ + { key: 'new', label: 'New', onClick: async () => { + await api.document.createAndSelect(); + this.updateDocumentTitle(); + tree.render(); + } }, + { key: 'open', label: 'Open', onClick: () => { + this.showOpenDialog(); + } }, + { key: 'export', label: 'Export…', onClick: () => { + this.showExportDialog(); + } } + ]); + + container.appendChild(this.separator()); + + this.addButton(container, 'Undo', async () => { + const ok = await api.document.undo(); + if (ok) { + this.updateDocumentTitle(); + tree.render(); + } + }); + + this.addButton(container, 'Redo', async () => { + const ok = await api.document.redo(); + if (ok) { + this.updateDocumentTitle(); + tree.render(); + } + }); + + container.appendChild(this.separator()); + + // Sketch tools + this.sketchBtn = this.addButton(container, 'Sketch', () => { + const target = api.interact.resolveSketchTargetFromSelection(); + if (!target) { + return; + } + const sketch = api.sketch.createFromTarget(target); + if (!sketch) { + return; + } + tree.selectedFeatureId = sketch.id; + tree.selectedFeatureIds = new Set([sketch.id]); + tree.selectedSolidIds = new Set(); + api.solids?.setSelected?.([]); + properties.showFeature(sketch, { + onChange: () => tree.render() + }); + api.interact?.setSketchTool?.('select'); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + }, { id: 'btn-sketch' }); + const sketchEditGroup = document.createElement('div'); + sketchEditGroup.className = 'toolbar-mode-group toolbar-mode-group-sketch'; + this.sketchEditGroupEl = sketchEditGroup; + container.appendChild(sketchEditGroup); + + this.sketchToolButtons = { + point: this.addButton(sketchEditGroup, 'Point', () => { + const current = api.interact.getSketchTool?.() || 'select'; + api.interact.setSketchTool(current === 'point' ? 'select' : 'point'); + }), + line: this.addButton(sketchEditGroup, 'Line', () => { + const current = api.interact.getSketchTool?.() || 'select'; + api.interact.setSketchTool(current === 'line' ? 'select' : 'line'); + }) + }; + const arcMenu = this.addMenu(sketchEditGroup, 'Arc', [ + { key: 'arc-3pt', label: '3 Point Arc', onClick: () => { + api.interact.setSketchTool('arc-3pt'); + } }, + { key: 'arc-center', label: 'Center Point Arc', onClick: () => { + api.interact.setSketchTool('arc-center'); + } }, + { key: 'arc-tangent', label: 'Tangent Arc', onClick: () => { + api.interact.setSketchTool('arc-tangent'); + } } + ]); + const circleMenu = this.addMenu(sketchEditGroup, 'Circle', [ + { key: 'circle-center', label: 'Center Point Circle', onClick: () => { + api.interact.setSketchTool('circle-center'); + } }, + { key: 'circle-3pt', label: '3 Point Circle', onClick: () => { + api.interact.setSketchTool('circle-3pt'); + } } + ]); + const rectMenu = this.addMenu(sketchEditGroup, 'Rect', [ + { key: 'rect', label: 'Corner Rect', onClick: () => { + api.interact.setSketchTool('rect'); + } }, + { key: 'rect-center', label: 'Center Rect', onClick: () => { + api.interact.setSketchTool('rect-center'); + } } + ]); + const polyMenu = this.addMenu(sketchEditGroup, 'Polygon', [ + { key: 'inscribed', label: 'Inscribed', onClick: () => { + api.interact.createSketchPolygonFromSelectedCircle?.('inscribed'); + } }, + { key: 'circumscribed', label: 'Circumscribed', onClick: () => { + api.interact.createSketchPolygonFromSelectedCircle?.('circumscribed'); + } } + ]); + const patternMenu = this.addMenu(sketchEditGroup, 'Pattern', [ + { key: 'mirror', label: 'Mirror', onClick: () => api.interact.startSketchMirrorMode?.() }, + { key: 'circular', label: 'Circular', onClick: () => api.interact.startSketchCircularPatternMode?.() }, + { key: 'grid', label: 'Grid', onClick: () => api.interact.startSketchGridPatternMode?.() } + ]); + this.sketchToolMenus = { + arc: arcMenu, + circle: circleMenu, + rect: rectMenu, + polygon: polyMenu, + pattern: patternMenu + }; + this.sketchToolMenuItems = { + ...arcMenu.items, + ...circleMenu.items, + ...rectMenu.items, + ...polyMenu.items, + ...patternMenu.items + }; + this.sketchConstraintMenu = this.addMenu(sketchEditGroup, 'Constraints', [ + { key: 'horizontal', label: 'Horizontal', onClick: () => api.interact.applySketchConstraint?.('horizontal') }, + { key: 'vertical', label: 'Vertical', onClick: () => api.interact.applySketchConstraint?.('vertical') }, + { key: 'perpendicular', label: 'Perpendicular', onClick: () => api.interact.applySketchConstraint?.('perpendicular') }, + { key: 'equal', label: 'Equal', onClick: () => api.interact.applySketchConstraint?.('equal') }, + { key: 'collinear', label: 'Collinear', onClick: () => api.interact.applySketchConstraint?.('collinear') }, + { key: 'dimension', label: 'Dimension', onClick: () => api.interact.applySketchConstraint?.('dimension') }, + { key: 'min-distance', label: 'Min Distance', onClick: () => api.interact.applySketchConstraint?.('min_distance') }, + { key: 'max-distance', label: 'Max Distance', onClick: () => api.interact.applySketchConstraint?.('max_distance') }, + { key: 'tangent', label: 'Tangent', onClick: () => api.interact.applySketchConstraint?.('tangent') }, + { key: 'midpoint', label: 'Midpoint', onClick: () => api.interact.applySketchConstraint?.('midpoint') }, + { key: 'coincident', label: 'Coincident', onClick: () => api.interact.applySketchConstraint?.('coincident') }, + { key: 'fixed', label: 'Fixed', onClick: () => api.interact.applySketchConstraint?.('fixed') } + ]); + this.sketchConstraintButtons = this.sketchConstraintMenu.items; + sketchEditGroup.appendChild(this.separator()); + + const solidOpsGroup = document.createElement('div'); + solidOpsGroup.className = 'toolbar-mode-group toolbar-mode-group-solid'; + this.solidOpsGroupEl = solidOpsGroup; + container.appendChild(solidOpsGroup); + + this.extrudeBtn = this.addButton(solidOpsGroup, 'Extrude', () => { + this.onExtrudeButton(); + }, { id: 'btn-extrude', disabled: true }); + this.chamferBtn = this.addButton(solidOpsGroup, 'Chamfer', () => { + this.onChamferButton(); + }, { id: 'btn-chamfer', disabled: true }); + this.booleanBtn = this.addButton(solidOpsGroup, 'Boolean', () => { + this.onBooleanButton(); + }, { id: 'btn-boolean', disabled: true }); + solidOpsGroup.appendChild(this.separator()); + + // View tools + this.addMenu(container, 'View', [ + { key: 'fit', label: 'Fit', onClick: () => space.view.fit(null, { tween: true }) }, + { key: 'top', label: 'Top', onClick: () => space.view.top() }, + { key: 'bottom', label: 'Bottom', onClick: () => space.view.bottom() }, + { key: 'front', label: 'Front', onClick: () => space.view.front() }, + { key: 'back', label: 'Back', onClick: () => space.view.back() }, + { key: 'right', label: 'Right', onClick: () => space.view.right() }, + { key: 'left', label: 'Left', onClick: () => space.view.left() } + ]); + + container.appendChild(this.separator()); + + this.cameraToggleBtn = this.addButton(container, this.getProjectionLabel(), () => { + const current = space.view.getProjection(); + const next = current === 'perspective' ? 'orthographic' : 'perspective'; + space.view.setProjection(next); + // setProjection recreates controls/camera; restore void bindings/hooks. + space.view.setCtrl('void'); + api.overlay.onProjectionChanged(); + // Persist after projection/control settles to avoid stale scale snapshots. + if (api.db?.admin) { + setTimeout(() => { + api.db.admin.put('camera', { + place: space.view.save(), + focus: space.view.getFocus(), + projection: space.view.getProjection() + }); + }, 120); + } + this.updateProjectionLabel(); + }, { id: 'btn-camera-toggle' }); + this.addButton(container, '⚙', () => { + this.togglePreferencesDialog(); + }, { id: 'btn-preferences' }); + this.addButton(container, '?', () => { + this.toggleHotkeysDialog(); + }, { id: 'btn-hotkeys' }); + + const spacer = document.createElement('div'); + spacer.className = 'toolbar-spacer'; + container.appendChild(spacer); + + this.docNameEl = document.createElement('div'); + this.docNameEl.className = 'toolbar-doc-name'; + this.docNameEl.onclick = async () => { + const current = api.document.current; + if (!current) return; + const next = window.prompt('Rename document', current.name || 'Untitled'); + if (next === null) return; + await api.document.rename(next); + this.updateDocumentTitle(); + }; + container.appendChild(this.docNameEl); + + this.buildOpenDialog(); + this.buildExportDialog(); + this.buildPreferencesDialog(); + this.buildHotkeysDialog(); + this.updateDocumentTitle(); + this.updateSketchControls(); + this.loadPreferences(); + window.addEventListener('void-state-change', () => this.updateSketchControls()); + window.addEventListener('keydown', event => { + const activeTag = document.activeElement?.tagName; + const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable; + if (editingInput) return; + if (event.code === 'Slash' && event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) { + this.toggleHotkeysDialog(); + event.preventDefault(); + } else if (event.code === 'Escape' && !event.ctrlKey && !event.metaKey && !event.altKey) { + this.hideHotkeysDialog(); + this.hidePreferencesDialog(); + } + }); + + console.log({ toolbar_built: true }); + }, + + updateSketchControls() { + const editing = !!api.sketchRuntime?.editingId; + const canCreate = !editing && !!api.interact.resolveSketchTargetFromSelection(); + const canExtrude = (this.getSelectedExtrudeTargets().length > 0) || (!editing && !!this.getSelectedSolidSourceExtrudeFeature()); + const canChamfer = !editing && (this.getSelectedChamferEdges().length > 0 || !!this.getSelectedSolidSourceChamferFeature()); + const canBoolean = !editing && (this.getSelectedBooleanTargets().length >= 2 || !!this.getSelectedSolidSourceBooleanFeature()); + + if (this.sketchBtn) { + this.sketchBtn.disabled = !canCreate; + this.sketchBtn.classList.toggle('active', canCreate && !editing); + } + if (this.sketchEditGroupEl) { + this.sketchEditGroupEl.classList.toggle('hidden', !editing); + } + if (this.solidOpsGroupEl) { + this.solidOpsGroupEl.classList.toggle('hidden', editing); + } + if (this.extrudeBtn) { + this.extrudeBtn.disabled = !canExtrude; + } + if (this.chamferBtn) { + this.chamferBtn.disabled = !canChamfer; + } + if (this.booleanBtn) { + this.booleanBtn.disabled = !canBoolean; + } + + const rawTool = api.interact.getSketchTool ? api.interact.getSketchTool() : 'select'; + const tool = rawTool === 'arc' ? 'arc-3pt' : (rawTool === 'circle' ? 'circle-center' : rawTool); + if (this.sketchToolButtons) { + for (const [name, btn] of Object.entries(this.sketchToolButtons)) { + const enabled = editing; + btn.disabled = !enabled; + btn.classList.toggle('active', enabled && name === tool); + } + } + if (this.sketchToolMenus) { + for (const menu of Object.values(this.sketchToolMenus)) { + if (!menu?.trigger) continue; + menu.trigger.disabled = !editing; + } + } + if (this.sketchToolMenuItems) { + const toolKeys = ['arc-3pt', 'arc-center', 'arc-tangent', 'circle-center', 'circle-3pt', 'rect', 'rect-center', 'inscribed', 'circumscribed', 'mirror', 'circular', 'grid']; + for (const key of toolKeys) { + const btn = this.sketchToolMenuItems[key]; + if (!btn) continue; + btn.disabled = !editing; + btn.classList.toggle('active', editing && key === tool); + if (key === 'inscribed' || key === 'circumscribed') { + btn.classList.remove('active'); + } + if (key === 'mirror') { + btn.classList.toggle('active', editing && !!api.interact?.sketchMirrorMode); + } + if (key === 'circular') { + btn.classList.toggle('active', editing && !!api.interact?.sketchCircularPatternMode); + } + if (key === 'grid') { + btn.classList.toggle('active', editing && !!api.interact?.sketchGridPatternMode); + } + } + } + if (this.sketchConstraintButtons) { + for (const btn of Object.values(this.sketchConstraintButtons)) { + btn.disabled = !editing; + } + } + if (this.sketchConstraintMenu?.trigger) { + this.sketchConstraintMenu.trigger.disabled = !editing; + } + }, + + getSelectedExtrudeTargets() { + const profiles = Array.from(api.interact?.selectedSketchProfiles || []); + const out = []; + for (const key of profiles) { + const [sketchId, profileId] = String(key || '').split(':'); + if (!sketchId || !profileId) continue; + const sketch = api.features.findById(sketchId); + if (!sketch || sketch.type !== 'sketch') continue; + out.push({ + region_id: `profile:${sketchId}:${profileId}` + }); + } + return out; + }, + + createExtrudeFeatureFromTargets(targets) { + if (!targets.length) return null; + const doc = api.document.current; + if (!doc) return null; + const extrudeCount = (doc.features || []).filter(f => f?.type === 'extrude').length; + const id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const feature = { + id, + type: 'extrude', + name: `Extrude ${extrudeCount + 1}`, + created_at: Date.now(), + suppressed: false, + visible: true, + input: { + profiles: targets + }, + params: { + depth: 10, + distance: 10, + direction: 'normal', + symmetric: false, + operation: 'new' + }, + result: null + }; + api.features.add(feature); + tree.selectedFeatureId = feature.id; + tree.selectedFeatureIds = new Set([feature.id]); + tree.selectedSolidIds = new Set(); + api.solids?.setSelected?.([]); + properties.showFeature(feature, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return feature; + }, + + createExtrudeFeatureFromSelection() { + return this.createExtrudeFeatureFromTargets(this.getSelectedExtrudeTargets()); + }, + + getSelectedSolidSourceExtrudeFeature() { + const solidIds = Array.from(tree.selectedSolidIds || []); + if (solidIds.length !== 1) return null; + const solidId = solidIds[0]; + const solid = (api.solids?.list?.() || []).find(item => item?.id === solidId); + const sourceFeatureId = solid?.source?.feature_id || null; + if (!sourceFeatureId) return null; + const feature = api.features.findById(sourceFeatureId); + if (!feature || feature.type !== 'extrude') return null; + return feature; + }, + + getSelectedBooleanTargets() { + const faceKeys = api.solids?.getSelectedFaceKeys?.() || []; + const faceSolidIds = new Set(); + for (const key of faceKeys) { + const raw = String(key || ''); + const split = raw.lastIndexOf(':'); + if (split <= 0) continue; + const solidId = raw.substring(0, split); + if (solidId) faceSolidIds.add(solidId); + } + const solidIds = faceSolidIds.size ? Array.from(faceSolidIds) : Array.from(tree.selectedSolidIds || []); + const solids = api.solids?.list?.() || []; + return solidIds.filter(id => solids.some(solid => solid?.id === id)); + }, + + getSelectedChamferEdges() { + const keys = api.solids?.getSelectedEdgeKeys?.() || []; + const normalized = []; + const seen = new Set(); + for (const key of keys) { + const promoted = api.solids?.getPromotedLoopEdgeKeyForSelection?.(key) || key; + const k = String(promoted || '').trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + normalized.push(k); + } + return normalized.map(key => { + const edge = api.solids?.getEdgeByKey?.(key); + if (!edge) return null; + const edgeEntity = api.solids?.resolveCanonicalEdgeEntity?.(key) || null; + const boundarySegmentId = String(edgeEntity?.id || ''); + if (!boundarySegmentId) return null; + const out = { + key, + boundary_segment_id: boundarySegmentId, + entity: { + kind: String(edgeEntity?.kind || 'boundary-segment'), + id: boundarySegmentId + }, + solidId: edge.solidId, + edgeIndex: edge.index, + meshEdgeKey: edge.meshEdgeKey || null + }; + if (Array.isArray(edge?.meshEdgeKeys) && edge.meshEdgeKeys.length) { + out.meshEdgeKeys = edge.meshEdgeKeys.slice(); + } + if (Array.isArray(edge?.pathWorld) && edge.pathWorld.length >= 2) { + out.path = edge.pathWorld.map(p => ({ + x: Number(p?.x || 0), + y: Number(p?.y || 0), + z: Number(p?.z || 0) + })); + } + return out; + }).filter(Boolean); + }, + + createChamferFeatureFromSelection() { + const edges = this.getSelectedChamferEdges(); + if (!edges.length) return null; + const doc = api.document.current; + if (!doc) return null; + const chamferCount = (doc.features || []).filter(f => f?.type === 'chamfer').length; + const id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const feature = { + id, + type: 'chamfer', + name: `Chamfer ${chamferCount + 1}`, + created_at: Date.now(), + suppressed: false, + visible: true, + input: { + edges + }, + params: { + distance: 1, + showCutters: false + }, + result: null + }; + api.features.add(feature); + tree.selectedFeatureId = feature.id; + tree.selectedFeatureIds = new Set([feature.id]); + tree.selectedSolidIds = new Set(); + properties.showFeature(feature, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return feature; + }, + + getSelectedSolidSourceChamferFeature() { + const solidIds = Array.from(tree.selectedSolidIds || []); + if (solidIds.length !== 1) return null; + const solidId = solidIds[0]; + const solid = (api.solids?.list?.() || []).find(item => item?.id === solidId); + const sourceFeatureId = solid?.source?.feature_id || null; + if (!sourceFeatureId) return null; + const feature = api.features.findById(sourceFeatureId); + if (!feature || feature.type !== 'chamfer') return null; + return feature; + }, + + createBooleanFeatureFromSelection() { + const targets = this.getSelectedBooleanTargets(); + if (targets.length < 2) return null; + const doc = api.document.current; + if (!doc) return null; + const booleanCount = (doc.features || []).filter(f => f?.type === 'boolean').length; + const id = (typeof crypto !== 'undefined' && crypto.randomUUID) + ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) + : `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + const feature = { + id, + type: 'boolean', + name: `Boolean ${booleanCount + 1}`, + created_at: Date.now(), + suppressed: false, + visible: true, + input: { + targets: targets.slice(), + tools: [] + }, + params: { + mode: 'add' + }, + result: null + }; + api.features.add(feature); + tree.selectedFeatureId = feature.id; + tree.selectedFeatureIds = new Set([feature.id]); + tree.selectedSolidIds = new Set(targets); + api.solids?.setSelected?.(targets); + properties.showFeature(feature, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return feature; + }, + + getSelectedSolidSourceBooleanFeature() { + const solidIds = Array.from(tree.selectedSolidIds || []); + if (solidIds.length !== 1) return null; + const solidId = solidIds[0]; + const solid = (api.solids?.list?.() || []).find(item => item?.id === solidId); + const sourceFeatureId = solid?.source?.feature_id || null; + if (!sourceFeatureId) return null; + const feature = api.features.findById(sourceFeatureId); + if (!feature || feature.type !== 'boolean') return null; + return feature; + }, + + async onExtrudeButton() { + const sketchEditingId = api.sketchRuntime?.editingId || null; + const selectedTargets = this.getSelectedExtrudeTargets(); + if (sketchEditingId && selectedTargets.length) { + if (properties.currentFeatureId) { + await properties.hide('accept'); + } else { + api.sketchRuntime?.setEditing(null); + } + this.createExtrudeFeatureFromTargets(selectedTargets); + return; + } + const existing = this.getSelectedSolidSourceExtrudeFeature(); + if (existing) { + tree.selectedSolidIds = new Set(); + tree.selectedFeatureIds = new Set([existing.id]); + tree.selectedFeatureId = existing.id; + properties.showFeature(existing, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + this.createExtrudeFeatureFromSelection(); + }, + + onBooleanButton() { + const existing = this.getSelectedSolidSourceBooleanFeature(); + if (existing) { + const mode = String(existing?.params?.mode || 'add'); + const targets = Array.isArray(existing?.input?.targets) + ? existing.input.targets.filter(Boolean) + : []; + const tools = Array.isArray(existing?.input?.tools) ? existing.input.tools.filter(Boolean) : []; + const selected = mode === 'subtract' ? Array.from(new Set([...targets, ...tools])) : targets; + tree.selectedSolidIds = new Set(selected); + tree.selectedFeatureIds = new Set([existing.id]); + tree.selectedFeatureId = existing.id; + api.solids?.setSelected?.(selected); + properties.showFeature(existing, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + this.createBooleanFeatureFromSelection(); + }, + + onChamferButton() { + const existing = this.getSelectedSolidSourceChamferFeature(); + if (existing) { + tree.selectedSolidIds = new Set(); + tree.selectedFeatureIds = new Set([existing.id]); + tree.selectedFeatureId = existing.id; + properties.showFeature(existing, { + onChange: () => tree.render() + }); + tree.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + return; + } + this.createChamferFeatureFromSelection(); + }, + + getProjectionLabel() { + const mode = space.view.getProjection(); + return mode === 'perspective' ? 'Ortho' : 'Persp'; + }, + + updateProjectionLabel() { + if (this.cameraToggleBtn) { + this.cameraToggleBtn.textContent = this.getProjectionLabel(); + } + }, + + updateDocumentTitle() { + const name = api.document.current?.name || 'Untitled'; + if (this.docNameEl) { + this.docNameEl.textContent = name; + this.docNameEl.title = name; + } + document.title = `${name} - Void:Form`; + }, + + buildOpenDialog() { + if (this.openDialogEl) return; + const backdrop = document.createElement('div'); + backdrop.className = 'doc-dialog-backdrop hidden'; + + const dialog = document.createElement('div'); + dialog.className = 'doc-dialog'; + + const header = document.createElement('div'); + header.className = 'doc-dialog-header'; + header.textContent = 'Documents'; + + const list = document.createElement('div'); + list.className = 'doc-dialog-list'; + + const actions = document.createElement('div'); + actions.className = 'doc-dialog-actions'; + + const newBtn = this.addButton(actions, 'New', async () => { + await api.document.createAndSelect(); + this.updateDocumentTitle(); + tree.render(); + this.hideOpenDialog(); + }); + newBtn.classList.add('compact'); + + const closeBtn = this.addButton(actions, 'Close', () => { + this.hideOpenDialog(); + }); + closeBtn.classList.add('compact'); + + dialog.appendChild(header); + dialog.appendChild(list); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', event => { + if (event.target === backdrop) { + this.hideOpenDialog(); + } + }); + + this.openDialogEl = backdrop; + this.openDialogListEl = list; + }, + + async showOpenDialog() { + if (!this.openDialogEl) { + this.buildOpenDialog(); + } + const docs = await api.document.list(); + this.renderOpenDialogList(docs); + this.openDialogEl.classList.remove('hidden'); + }, + + hideOpenDialog() { + if (this.openDialogEl) { + this.openDialogEl.classList.add('hidden'); + } + }, + + buildExportDialog() { + if (this.exportDialogEl) return; + const backdrop = document.createElement('div'); + backdrop.className = 'doc-dialog-backdrop hidden'; + + const dialog = document.createElement('div'); + dialog.className = 'doc-dialog export-dialog'; + + const header = document.createElement('div'); + header.className = 'doc-dialog-header'; + header.textContent = 'Export Solids'; + + const list = document.createElement('div'); + list.className = 'doc-dialog-list'; + + const info = document.createElement('div'); + info.className = 'doc-dialog-meta'; + info.style.padding = '4px 0 10px 0'; + list.appendChild(info); + + const nameRow = document.createElement('div'); + nameRow.className = 'doc-dialog-row'; + const nameInfo = document.createElement('div'); + nameInfo.className = 'doc-dialog-info'; + const nameLabel = document.createElement('div'); + nameLabel.className = 'doc-dialog-name'; + nameLabel.textContent = 'Filename'; + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = 'void-export'; + nameInput.style.width = '100%'; + nameInput.style.marginTop = '6px'; + nameInfo.appendChild(nameLabel); + nameInfo.appendChild(nameInput); + nameRow.appendChild(nameInfo); + list.appendChild(nameRow); + + const stlOptsRow = document.createElement('div'); + stlOptsRow.className = 'doc-dialog-row'; + const stlOptsInfo = document.createElement('div'); + stlOptsInfo.className = 'doc-dialog-info'; + const stlOptsLabel = document.createElement('div'); + stlOptsLabel.className = 'doc-dialog-name'; + stlOptsLabel.textContent = 'STL Output'; + const stlZipWrap = document.createElement('label'); + stlZipWrap.className = 'doc-dialog-meta'; + stlZipWrap.style.display = 'inline-flex'; + stlZipWrap.style.gap = '8px'; + stlZipWrap.style.alignItems = 'center'; + stlZipWrap.style.marginTop = '6px'; + const stlZip = document.createElement('input'); + stlZip.type = 'checkbox'; + stlZip.checked = false; + const stlZipText = document.createElement('span'); + stlZipText.textContent = 'zip with file per solid'; + stlZipWrap.appendChild(stlZip); + stlZipWrap.appendChild(stlZipText); + stlOptsInfo.appendChild(stlOptsLabel); + stlOptsInfo.appendChild(stlZipWrap); + stlOptsRow.appendChild(stlOptsInfo); + list.appendChild(stlOptsRow); + + const actions = document.createElement('div'); + actions.className = 'doc-dialog-actions'; + + const mk = (label, fmt) => { + const btn = this.addButton(actions, label, () => this.exportSolids(fmt)); + btn.classList.add('compact'); + return btn; + }; + mk('OBJ', 'obj'); + mk('STL', 'stl'); + mk('3MF', '3mf'); + mk('STEP', 'step'); + const closeBtn = this.addButton(actions, 'Close', () => this.hideExportDialog()); + closeBtn.classList.add('compact'); + + dialog.appendChild(header); + dialog.appendChild(list); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', event => { + if (event.target === backdrop) { + this.hideExportDialog(); + } + }); + + this.exportDialogEl = backdrop; + this.exportDialogInfoEl = info; + this.exportFilenameEl = nameInput; + this.exportStlZipEl = stlZip; + }, + + getExportTargetSolidIds() { + const selected = Array.from(tree.selectedSolidIds || []); + if (selected.length) return selected; + return (api.solids?.list?.() || []).map(s => s?.id).filter(Boolean); + }, + + showExportDialog() { + if (!this.exportDialogEl) { + this.buildExportDialog(); + } + const selected = Array.from(tree.selectedSolidIds || []); + const ids = this.getExportTargetSolidIds(); + if (this.exportDialogInfoEl) { + this.exportDialogInfoEl.textContent = selected.length + ? `Exporting ${ids.length} selected solid(s)` + : `No solids selected. Exporting all ${ids.length} solid(s)`; + } + this.exportDialogEl.classList.remove('hidden'); + this.exportFilenameEl?.focus?.(); + this.exportFilenameEl?.select?.(); + }, + + hideExportDialog() { + if (this.exportDialogEl) { + this.exportDialogEl.classList.add('hidden'); + } + }, + + sanitizeExportFilename(name, ext) { + const base = String(name || 'void-export').trim().replace(/[\\/:*?"<>|]+/g, '-'); + const stem = base || 'void-export'; + return stem.toLowerCase().endsWith(`.${ext}`) ? stem : `${stem}.${ext}`; + }, + + sanitizeStem(name) { + return String(name || 'solid').trim().replace(/[\\/:*?"<>|]+/g, '-') || 'solid'; + }, + + downloadExport(data, filename, mime = 'application/octet-stream') { + const blob = data instanceof Blob ? data : new Blob([data], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); + }, + + recsToStepTriangles(recs) { + const out = []; + for (const rec of recs) { + const varr = rec?.varr || []; + for (let i = 0; i + 8 < varr.length; i += 9) { + out.push({ + v1: { x: varr[i], y: varr[i + 1], z: varr[i + 2] }, + v2: { x: varr[i + 3], y: varr[i + 4], z: varr[i + 5] }, + v3: { x: varr[i + 6], y: varr[i + 7], z: varr[i + 8] } + }); + } + } + return out; + }, + + async exportSolids(format = 'obj') { + const ids = this.getExportTargetSolidIds(); + const recs = api.solids?.getExportRecords?.(ids) || []; + if (!recs.length) { + window.alert('No solids to export'); + return; + } + const ext = String(format || 'obj').toLowerCase(); + const base = this.exportFilenameEl?.value || 'void-export'; + const filename = this.sanitizeExportFilename(base, ext); + if (ext === 'obj') { + const data = objEncode(recs, '# Generated by Void:Form'); + this.downloadExport(data, filename, 'text/plain;charset=utf-8'); + } else if (ext === 'stl') { + const zipPerSolid = !!this.exportStlZipEl?.checked; + if (zipPerSolid) { + const zip = new JSZip(); + for (const rec of recs) { + const one = stlEncodeASCII([rec], rec?.file || rec?.id || 'solid'); + const stem = this.sanitizeStem(rec.file || rec.id || 'solid'); + zip.file(`${stem}.stl`, one); + } + const zipBlob = await zip.generateAsync({ + type: 'blob', + compression: 'DEFLATE', + compressionOptions: { level: 6 } + }); + const zipName = this.sanitizeExportFilename(base, 'zip'); + this.downloadExport(zipBlob, zipName, 'application/zip'); + } else { + const data = stlEncodeASCII(recs, api.document.current?.name || 'void-export'); + this.downloadExport(data, filename, 'text/plain;charset=utf-8'); + } + } else if (ext === '3mf') { + const blob = await tmfEncode(recs, { title: api.document.current?.name || 'Void Export' }); + this.downloadExport(blob, filename, 'model/3mf'); + } else if (ext === 'step') { + const tris = this.recsToStepTriangles(recs); + const data = meshToSTEPWithFaces(tris, { productName: api.document.current?.name || 'void-export' }); + this.downloadExport(data, filename, 'application/step'); + } else { + window.alert(`Unsupported format: ${format}`); + return; + } + this.hideExportDialog(); + }, + + hotkeys() { + return [ + { + title: 'General', + items: [ + { key: 'Shift+/', desc: 'Toggle this hotkeys dialog' }, + { key: 'Space', desc: 'Clear selection' }, + { key: 'N', desc: 'View normal to hovered/selected face or plane' }, + { key: 'P', desc: 'Toggle datum plane visibility' }, + { key: 'F', desc: 'Fit visible elements' }, + { key: 'Ctrl/Cmd+Z', desc: 'Undo' }, + { key: 'Ctrl/Cmd+Y', desc: 'Redo' }, + { key: 'Shift+Ctrl/Cmd+Z', desc: 'Redo' } + ] + }, + { + title: 'Sketch Tools', + items: [ + { key: 'L', desc: 'Toggle line tool' }, + { key: 'A', desc: 'Toggle 3 point arc tool' }, + { key: 'C', desc: 'Toggle center point circle tool' }, + { key: 'G', desc: 'Toggle corner rectangle tool' }, + { key: 'R', desc: 'Toggle center rectangle tool' }, + { key: 'Shift+S', desc: 'Toggle point tool' }, + { key: 'U', desc: 'Use (project/convert) hovered/selected references' }, + { key: 'Q', desc: 'Toggle construction on selected lines/arcs' }, + { key: 'Esc', desc: 'Cancel line mode / close dialogs' } + ] + }, + { + title: 'Sketch Constraints', + items: [ + { key: 'H', desc: 'Horizontal constraint (selected line(s))' }, + { key: 'V', desc: 'Vertical constraint (selected line(s) or points)' }, + { key: 'Shift+L', desc: 'Perpendicular (exactly 2 selected lines)' }, + { key: 'E', desc: 'Equal length (selected line pair/group)' }, + { key: 'D', desc: 'Dimension (1 line or 2 points)' }, + { key: '(Menu)', desc: 'Min distance (circle/arc to point or line)' }, + { key: '(Menu)', desc: 'Max distance (circle/arc to point or line)' }, + { key: 'T', desc: 'Tangent (line+arc/circle or arc/circle pair)' }, + { key: 'I', desc: 'Coincident (points, point-line, point-arc, center-point)' }, + { key: 'Shift+M', desc: 'Midpoint' }, + { key: 'Shift+J', desc: 'Fixed (selected point(s))' }, + { key: 'M', desc: 'Toggle mirror mode (requires one selected line axis)' }, + { key: '(Menu)', desc: 'Circular pattern mode (requires one selected center point)' } + ] + }, + { + title: 'Sketch Selection', + items: [ + { key: 'Delete/Backspace', desc: 'Delete selected sketch entities/constraints' } + ] + }, + { + title: 'Reserved (Not Yet Implemented)', + items: [ + { key: 'Shift+O', desc: 'Concentric' }, + { key: 'Shift+U', desc: 'Curvature' }, + { key: 'X', desc: 'Extend' }, + { key: 'Shift+A', desc: 'Line/arc create-mode toggle' }, + { key: 'Shift+K', desc: 'Normal constraint' }, + { key: 'O', desc: 'Offset' }, + { key: 'B', desc: 'Parallel' }, + { key: 'Shift+G', desc: 'Pierce' }, + { key: 'Shift+F', desc: 'Sketch fillet' }, + { key: '(TBD)', desc: 'Symmetric' }, + { key: '(TBD)', desc: 'Trim' } + ] + } + ]; + }, + + buildHotkeysDialog() { + if (this.hotkeysDialogEl) return; + const backdrop = document.createElement('div'); + backdrop.className = 'doc-dialog-backdrop hidden'; + + const dialog = document.createElement('div'); + dialog.className = 'doc-dialog hotkeys-dialog'; + + const header = document.createElement('div'); + header.className = 'doc-dialog-header'; + header.textContent = 'Hotkeys'; + + const list = document.createElement('div'); + list.className = 'doc-dialog-list'; + for (const section of this.hotkeys()) { + const title = document.createElement('div'); + title.className = 'hotkeys-section'; + title.textContent = section.title; + list.appendChild(title); + for (const item of section.items) { + const row = document.createElement('div'); + row.className = 'hotkeys-row'; + const key = document.createElement('div'); + key.className = 'hotkeys-key'; + key.textContent = item.key; + const desc = document.createElement('div'); + desc.className = 'hotkeys-desc'; + desc.textContent = item.desc; + row.appendChild(key); + row.appendChild(desc); + list.appendChild(row); + } + } + + const actions = document.createElement('div'); + actions.className = 'doc-dialog-actions'; + const closeBtn = this.addButton(actions, 'Close', () => { + this.hideHotkeysDialog(); + }); + closeBtn.classList.add('compact'); + + dialog.appendChild(header); + dialog.appendChild(list); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', event => { + if (event.target === backdrop) { + this.hideHotkeysDialog(); + } + }); + + this.hotkeysDialogEl = backdrop; + }, + + getDefaultPreferences() { + return { + edgeLoopPromotionSegments: 10, + edgeHoverLineWidth: 2.5, + edgeSelectedLineWidth: 3.25, + sketchArcSegmentLength: 2.5, + fitPaddingPerspective: 0.5, + fitPaddingOrthographic: 0.9 + }; + }, + + normalizePreferences(raw = {}) { + const d = this.getDefaultPreferences(); + return { + 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), + edgeSelectedLineWidth: Math.max(0.5, Number(raw.edgeSelectedLineWidth ?? d.edgeSelectedLineWidth) || d.edgeSelectedLineWidth), + sketchArcSegmentLength: Math.max( + 0.05, + Number(raw.sketchArcSegmentLength ?? raw.sketchArcSegments ?? d.sketchArcSegmentLength) || d.sketchArcSegmentLength + ), + fitPaddingPerspective: Math.max(0.01, Number(raw.fitPaddingPerspective ?? d.fitPaddingPerspective) || d.fitPaddingPerspective), + fitPaddingOrthographic: Math.max(0.01, Number(raw.fitPaddingOrthographic ?? d.fitPaddingOrthographic) || d.fitPaddingOrthographic) + }; + }, + + async loadPreferences() { + const fallback = this.getDefaultPreferences(); + let next = null; + try { + const raw = localStorage.getItem(this.preferencesStorageKey); + if (raw) { + next = JSON.parse(raw); + } + } catch {} + try { + const fromDb = await api.db?.admin?.get?.(this.preferencesAdminKey); + if (fromDb && typeof fromDb === 'object') { + next = { ...(next || {}), ...fromDb }; + } + } catch {} + this.applyPreferences(next || fallback, { persist: false, updateFields: true }); + }, + + async savePreferences(next = {}) { + const prefs = this.normalizePreferences(next); + try { + localStorage.setItem(this.preferencesStorageKey, JSON.stringify(prefs)); + } catch {} + try { + await api.db?.admin?.put?.(this.preferencesAdminKey, prefs); + } catch {} + }, + + applyPreferences(next = {}, options = {}) { + const prefs = this.normalizePreferences(next); + this.preferencesState = prefs; + api.solids?.setRenderPreferences?.({ + edgeLoopPromotionSegments: prefs.edgeLoopPromotionSegments, + edgeHoverLineWidth: prefs.edgeHoverLineWidth, + edgeSelectedLineWidth: prefs.edgeSelectedLineWidth + }); + api.sketchRuntime?.setRenderPreferences?.({ + arcSegmentLength: prefs.sketchArcSegmentLength + }); + space.view.setFitPadding({ + perspective: prefs.fitPaddingPerspective, + orthographic: prefs.fitPaddingOrthographic + }); + if (options.updateFields !== false) { + this.syncPreferencesFields(); + } + if (options.persist !== false) { + this.savePreferences(prefs); + } + }, + + syncPreferencesFields() { + const prefs = this.preferencesState || this.getDefaultPreferences(); + const inputs = this.preferencesInputs || {}; + if (inputs.edgeLoopPromotionSegments) inputs.edgeLoopPromotionSegments.value = String(prefs.edgeLoopPromotionSegments); + if (inputs.edgeHoverLineWidth) inputs.edgeHoverLineWidth.value = String(prefs.edgeHoverLineWidth); + if (inputs.edgeSelectedLineWidth) inputs.edgeSelectedLineWidth.value = String(prefs.edgeSelectedLineWidth); + if (inputs.sketchArcSegmentLength) inputs.sketchArcSegmentLength.value = String(prefs.sketchArcSegmentLength); + if (inputs.fitPaddingPerspective) inputs.fitPaddingPerspective.value = String(prefs.fitPaddingPerspective); + if (inputs.fitPaddingOrthographic) inputs.fitPaddingOrthographic.value = String(prefs.fitPaddingOrthographic); + }, + + buildPreferencesDialog() { + if (this.preferencesDialogEl) return; + const backdrop = document.createElement('div'); + backdrop.className = 'doc-dialog-backdrop hidden'; + + const dialog = document.createElement('div'); + dialog.className = 'doc-dialog preferences-dialog'; + + const header = document.createElement('div'); + header.className = 'doc-dialog-header'; + header.textContent = 'Preferences'; + + const list = document.createElement('div'); + list.className = 'doc-dialog-list'; + + const makeNumberRow = (label, key, step = '1', 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 = 'number'; + input.step = String(step); + 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( + 'Edge Loop Promotion Segments', + 'edgeLoopPromotionSegments', + '1', + 'Controls when a face boundary should be treated as one continuous edge loop instead of individual edge segments. Higher values reduce accidental full-loop picks on simple polygon faces.' + ); + makeNumberRow( + 'Edge Hover Line Width', + 'edgeHoverLineWidth', + '0.1', + 'Screen-space thickness (pixels) for hovered solid edges. Increase for easier visibility while inspecting dense geometry.' + ); + makeNumberRow( + 'Edge Selected Line Width', + 'edgeSelectedLineWidth', + '0.1', + 'Screen-space thickness (pixels) for selected solid edges. Typically slightly larger than hover for stronger feedback.' + ); + makeNumberRow( + 'Sketch Arc Segment Length', + 'sketchArcSegmentLength', + '0.05', + 'Target segment length (in sketch units) used to tessellate arcs/circles. Lower values increase smoothness at higher cost.' + ); + makeNumberRow( + 'Fit Padding (Perspective)', + 'fitPaddingPerspective', + '0.01', + 'Extra margin used by Fit view in perspective mode. Lower values fit tighter; higher values leave more border.' + ); + makeNumberRow( + 'Fit Padding (Orthographic)', + 'fitPaddingOrthographic', + '0.01', + 'Extra margin used by Fit view in orthographic mode. Tune this separately from perspective for CAD-like framing.' + ); + const actions = document.createElement('div'); + actions.className = 'doc-dialog-actions'; + const defaultsBtn = this.addButton(actions, 'Defaults', () => { + this.applyPreferences(this.getDefaultPreferences(), { persist: true, updateFields: true }); + }); + defaultsBtn.classList.add('compact'); + const applyBtn = this.addButton(actions, 'Apply', () => { + this.applyPreferences({ + edgeLoopPromotionSegments: Number(this.preferencesInputs?.edgeLoopPromotionSegments?.value), + edgeHoverLineWidth: Number(this.preferencesInputs?.edgeHoverLineWidth?.value), + edgeSelectedLineWidth: Number(this.preferencesInputs?.edgeSelectedLineWidth?.value), + sketchArcSegmentLength: Number(this.preferencesInputs?.sketchArcSegmentLength?.value), + fitPaddingPerspective: Number(this.preferencesInputs?.fitPaddingPerspective?.value), + fitPaddingOrthographic: Number(this.preferencesInputs?.fitPaddingOrthographic?.value) + }, { persist: true, updateFields: true }); + }); + applyBtn.classList.add('compact'); + const closeBtn = this.addButton(actions, 'Close', () => { + this.hidePreferencesDialog(); + }); + closeBtn.classList.add('compact'); + + dialog.appendChild(header); + dialog.appendChild(list); + dialog.appendChild(actions); + backdrop.appendChild(dialog); + document.body.appendChild(backdrop); + + backdrop.addEventListener('click', event => { + if (event.target === backdrop) { + this.hidePreferencesDialog(); + } + }); + + this.preferencesDialogEl = backdrop; + this.preferencesState = this.getDefaultPreferences(); + this.syncPreferencesFields(); + }, + + togglePreferencesDialog() { + if (!this.preferencesDialogEl) { + this.buildPreferencesDialog(); + } + this.preferencesDialogEl.classList.toggle('hidden'); + if (!this.preferencesDialogEl.classList.contains('hidden')) { + this.syncPreferencesFields(); + } + }, + + hidePreferencesDialog() { + if (this.preferencesDialogEl) { + this.preferencesDialogEl.classList.add('hidden'); + } + }, + + toggleHotkeysDialog() { + if (!this.hotkeysDialogEl) { + this.buildHotkeysDialog(); + } + this.hotkeysDialogEl.classList.toggle('hidden'); + }, + + hideHotkeysDialog() { + if (this.hotkeysDialogEl) { + this.hotkeysDialogEl.classList.add('hidden'); + } + }, + + renderOpenDialogList(docs) { + if (!this.openDialogListEl) return; + this.openDialogListEl.innerHTML = ''; + + if (!docs.length) { + const empty = document.createElement('div'); + empty.className = 'doc-dialog-empty'; + empty.textContent = 'No documents'; + this.openDialogListEl.appendChild(empty); + return; + } + + for (const doc of docs) { + const row = document.createElement('div'); + row.className = 'doc-dialog-row'; + + if (doc.id === api.document.current?.id) { + row.classList.add('active'); + } + + const info = document.createElement('div'); + info.className = 'doc-dialog-info'; + const name = document.createElement('div'); + name.className = 'doc-dialog-name'; + name.textContent = doc.name || 'Untitled'; + const meta = document.createElement('div'); + meta.className = 'doc-dialog-meta'; + meta.textContent = `Updated ${this.formatTime(doc.modified_at)}`; + info.appendChild(name); + info.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'doc-dialog-row-actions'; + const openBtn = this.addButton(actions, 'Open', async () => { + await api.document.open(doc.id); + this.updateDocumentTitle(); + tree.render(); + this.hideOpenDialog(); + }); + openBtn.classList.add('compact'); + const delBtn = this.addButton(actions, 'Delete', async () => { + const ok = window.confirm(`Delete "${doc.name || 'Untitled'}"?`); + if (!ok) return; + await api.document.delete(doc.id); + this.updateDocumentTitle(); + tree.render(); + const nextDocs = await api.document.list(); + this.renderOpenDialogList(nextDocs); + }); + delBtn.classList.add('compact', 'danger'); + + row.appendChild(info); + row.appendChild(actions); + this.openDialogListEl.appendChild(row); + } + }, + + formatTime(ts) { + if (!ts) return 'unknown'; + try { + return new Date(ts).toLocaleString(); + } catch (e) { + return 'unknown'; + } + }, + + addButton(container, label, onclick, options = {}) { + const btn = document.createElement('button'); + btn.className = 'toolbar-btn'; + btn.textContent = label; + btn.onclick = onclick; + if (options.id) { + btn.id = options.id; + } + if (options.disabled) { + btn.disabled = true; + } + container.appendChild(btn); + this.buttons.push(btn); + return btn; + }, + + addMenu(container, label, entries = []) { + const menu = document.createElement('div'); + menu.className = 'toolbar-menu'; + + const trigger = document.createElement('button'); + trigger.className = 'toolbar-btn toolbar-menu-trigger'; + trigger.type = 'button'; + trigger.textContent = label; + trigger.title = label; + + const pop = document.createElement('div'); + pop.className = 'toolbar-menu-pop'; + const panel = document.createElement('div'); + panel.className = 'toolbar-menu-panel'; + const items = {}; + + for (const entry of entries) { + const item = document.createElement('button'); + item.className = 'toolbar-menu-item'; + item.type = 'button'; + item.textContent = entry.label; + if (entry.disabled) { + item.disabled = true; + } + item.onclick = () => { + if (item.disabled) return; + entry.onClick?.(); + // Prevent :focus-within from pinning the hover menu open after click. + item.blur(); + trigger.blur(); + }; + panel.appendChild(item); + if (entry.key) { + items[entry.key] = item; + } + } + + menu.addEventListener('mouseleave', () => { + // Ensure hover menus dismiss when pointer exits after click selection. + trigger.blur(); + if (document.activeElement && panel.contains(document.activeElement)) { + document.activeElement.blur(); + } + }); + + pop.appendChild(panel); + menu.appendChild(trigger); + menu.appendChild(pop); + container.appendChild(menu); + this.buttons.push(trigger); + return { menu, trigger, pop, panel, items }; + }, + + separator() { + const sep = document.createElement('div'); + sep.className = 'toolbar-separator'; + return sep; + } +}; + +export { toolbar }; diff --git a/src/void/tree.js b/src/void/tree.js new file mode 100644 index 00000000..322af631 --- /dev/null +++ b/src/void/tree.js @@ -0,0 +1,82 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { $ } from '../moto/webui.js'; +import { api } from './api.js'; +import { properties } from './properties.js'; +import * as modelOps from './tree/model.js'; +import * as renderOps from './tree/render.js'; + +const tree = { + container: null, + defaultGeometryExpanded: true, + featuresExpanded: true, + solidsExpanded: true, + selectedFeatureId: null, + selectedFeatureIds: new Set(), + selectedSolidIds: new Set(), + searchQuery: '', + _searchCaret: 0, + _searchRestorePending: false, + _boundRuntimeChanges: false, + + build() { + this.container = $('left-panel'); + if (!this.container) return; + + this.bindRuntimeChanges(); + window.addEventListener('void-state-change', () => this.render()); + window.addEventListener('void-clear-selection', () => { + this.selectedFeatureId = null; + this.selectedFeatureIds.clear(); + this.selectedSolidIds.clear(); + api.solids?.setSelected?.([]); + api.sketchRuntime?.setSelected([]); + this.render(); + }); + this.container.addEventListener('mouseleave', () => { + const planes = api.datum?.getPlanes?.() || []; + for (const plane of planes) { + plane.setHovered(false); + } + api.sketchRuntime?.setHovered(null); + api.solids?.setHovered?.([]); + this.render(); + }); + window.addEventListener('keydown', event => { + const isDelete = event.key === 'Delete' || event.key === 'Backspace'; + if (!isDelete) return; + if (event.defaultPrevented) return; + const activeTag = document.activeElement?.tagName; + const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable; + if (editing) return; + if (api.interact?.isSketchEditing?.()) return; + const ids = Array.from(this.selectedFeatureIds || []); + if (!ids.length) return; + for (const id of ids) { + const feature = api.features.findById(id); + if (!feature) continue; + api.features.remove(feature); + if (properties.currentFeatureId === id) { + properties.hide(); + } + if (api.sketchRuntime?.editingId === id) { + api.sketchRuntime.setEditing(null); + api.interact?.clearSketchSelection?.(); + } + } + this.selectedFeatureId = null; + this.selectedFeatureIds.clear(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + event.preventDefault(); + }); + this.render(); + + console.log({ tree_built: true }); + } +}; + +Object.assign(tree, modelOps); +Object.assign(tree, renderOps); + +export { tree }; diff --git a/src/void/tree/model.js b/src/void/tree/model.js new file mode 100644 index 00000000..5a8203b1 --- /dev/null +++ b/src/void/tree/model.js @@ -0,0 +1,836 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { api } from '../api.js'; +import { properties } from '../properties.js'; + +const treePlaneHoverState = new WeakMap(); + +function getPlaneBaseVisible(plane) { + if (!plane) return true; + if (typeof plane.getBaseVisible === 'function') { + return !!plane.getBaseVisible(); + } + return !!plane.getGroup()?.visible; +} + +function isPlaneTreeHovered(plane) { + return !!treePlaneHoverState.get(plane); +} + +function setPlaneTreeHovered(plane, hovered) { + treePlaneHoverState.set(plane, !!hovered); +} + +function applyPlaneTreeVisibility(plane) { + const baseVisible = getPlaneBaseVisible(plane); + const hovered = isPlaneTreeHovered(plane); + const selected = !!api.interact?.selectedPlanes?.has?.(plane); + const group = plane.getGroup?.(); + if (group) { + group.visible = baseVisible || hovered || selected; + } +} + +function bindRuntimeChanges() { + if (this._boundRuntimeChanges) return; + this._boundRuntimeChanges = true; + + api.datum.onChange(() => this.render()); + for (const plane of api.datum.getPlanes()) { + plane.onChange(() => this.render()); + } + api.origin.onChange(() => this.render()); +} + +function getActiveEditFeatureId() { + const sketchEditingId = api.sketchRuntime?.editingId || null; + if (sketchEditingId) return sketchEditingId; + return properties.currentFeatureId || null; +} + +function getActiveEditIndex() { + const activeId = getActiveEditFeatureId(); + if (!activeId) return -1; + const features = api.features.list(); + return features.findIndex(feature => feature?.id === activeId); +} + +function ensureEditingSketchIsRenderable() { + const editingId = api.sketchRuntime?.editingId; + if (!editingId) return; + const feature = api.features.findById(editingId); + if (!feature || feature.type !== 'sketch' || feature.suppressed === true || !api.features.isBuilt(editingId)) { + api.sketchRuntime?.setEditing(null); + api.interact?.clearSketchSelection?.(); + } +} + +function getSolidIdsForFeature(featureId) { + if (!featureId) return []; + return (api.solids?.list?.() || []) + .filter(solid => solid?.source?.feature_id === featureId) + .map(solid => solid.id) + .filter(Boolean); +} + +function getSketchIdsForSolid(solid) { + const ids = new Set(); + const add = value => { + if (value) ids.add(value); + }; + add(solid?.source?.profile?.sketchId); + for (const sid of solid?.source?.sketch_ids || []) { + add(sid); + } + add(solid?.provenance?.source?.profile?.sketchId); + for (const face of solid?.provenance?.faces || []) { + add(face?.source?.sketchId); + } + return ids; +} + +function getHoverContext() { + const hoveredSolidIds = new Set(); + const hoveredSketchIds = new Set(); + const hoveredFeatureIds = new Set(); + const hoveredProfileKey = api.interact?.hoveredSketchProfileKey || null; + if (hoveredProfileKey) { + const sketchId = String(hoveredProfileKey).split(':')[0]; + if (sketchId) hoveredSketchIds.add(sketchId); + } + const hoveredFaceKey = api.interact?.hoveredSolidFaceKey || null; + if (hoveredFaceKey) { + const raw = String(hoveredFaceKey); + const split = raw.lastIndexOf(':'); + if (split > 0) { + hoveredSolidIds.add(raw.substring(0, split)); + } + } + const hoveredEdgeKey = api.interact?.hoveredSolidEdgeKey || null; + if (hoveredEdgeKey) { + const raw = String(hoveredEdgeKey); + const split = raw.lastIndexOf(':'); + if (split > 0) { + hoveredSolidIds.add(raw.substring(0, split)); + } + } + if (hoveredSolidIds.size) { + const solids = api.solids?.list?.() || []; + for (const solidId of hoveredSolidIds) { + const solid = solids.find(s => s?.id === solidId); + if (!solid) continue; + const sourceFeatureId = solid?.source?.feature_id || null; + if (sourceFeatureId) hoveredFeatureIds.add(sourceFeatureId); + for (const sketchId of getSketchIdsForSolid(solid)) { + hoveredSketchIds.add(sketchId); + } + } + } + return { hoveredSolidIds, hoveredSketchIds, hoveredFeatureIds }; +} + +function getFaceSelectionContext() { + const faceKeys = api.solids?.getSelectedFaceKeys?.() || []; + const edgeKeys = api.solids?.getSelectedEdgeKeys?.() || []; + const selectedSolidIds = new Set(); + const selectedFeatureIds = new Set(); + for (const key of [...faceKeys, ...edgeKeys]) { + const raw = String(key || ''); + const split = raw.lastIndexOf(':'); + if (split <= 0) continue; + const solidId = raw.substring(0, split); + if (!solidId) continue; + selectedSolidIds.add(solidId); + } + if (selectedSolidIds.size) { + const solids = api.solids?.list?.() || []; + for (const solidId of selectedSolidIds) { + const solid = solids.find(s => s?.id === solidId); + const featureId = solid?.source?.feature_id || null; + if (featureId) selectedFeatureIds.add(featureId); + } + } + return { selectedSolidIds, selectedFeatureIds }; +} + +function render() { + if (!this.container) return; + this.container.innerHTML = ''; + this.container.appendChild(this.createSearchHeader({ + value: this.searchQuery || '', + onInput: event => { + const value = event?.target?.value || ''; + this.searchQuery = String(value); + this._searchRestorePending = true; + this._searchCaret = Number.isFinite(event?.target?.selectionStart) ? event.target.selectionStart : this.searchQuery.length; + this.render(); + }, + onClear: () => { + this.searchQuery = ''; + this._searchRestorePending = true; + this._searchCaret = 0; + this.render(); + }, + onFocus: () => {} + })); + this.container.appendChild(this.createDivider()); + this.renderDefaultGeometrySection(); + this.container.appendChild(this.createDivider()); + this.renderFeaturesSection(); + this.container.appendChild(this.createDivider()); + this.renderSolidsSection(); + if (this._searchRestorePending) { + const input = this.container.querySelector('.tree-search-input'); + if (input) { + input.focus(); + const caret = Number.isFinite(this._searchCaret) ? this._searchCaret : String(input.value || '').length; + input.setSelectionRange(caret, caret); + } + this._searchRestorePending = false; + } +} + +function onFeatureSelected(feature) { + if (!this.selectedFeatureIds) { + this.selectedFeatureIds = new Set(); + } + const id = feature?.id || null; + if (!id) return; + if (this.selectedFeatureIds.has(id)) { + this.selectedFeatureIds.delete(id); + } else { + this.selectedFeatureIds.add(id); + } + this.selectedSolidIds?.clear?.(); + api.solids?.setSelected?.([]); + this.selectedFeatureId = this.selectedFeatureIds.values().next().value || null; + api.sketchRuntime?.setEditing(null); + api.interact?.selectedSketchProfiles?.clear?.(); + api.interact.hoveredSketchProfileKey = null; + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + const selectedSketchIds = Array.from(this.selectedFeatureIds).filter(fid => api.features.findById(fid)?.type === 'sketch'); + api.sketchRuntime?.setSelected(selectedSketchIds); + api.interact?.clearSketchSelection?.(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function onFeatureEdit(feature) { + this.selectedFeatureId = null; + if (!this.selectedFeatureIds) { + this.selectedFeatureIds = new Set(); + } + this.selectedFeatureIds.clear(); + if (!this.selectedSolidIds) { + this.selectedSolidIds = new Set(); + } else { + this.selectedSolidIds.clear(); + } + api.interact?.deselectAll?.(); + api.solids?.setSelected?.([]); + api.solids?.clearFaceSelection?.(); + api.interact?.selectedSketchProfiles?.clear?.(); + api.interact.hoveredSketchProfileKey = null; + api.sketchRuntime?.setSelectedProfiles?.([]); + api.sketchRuntime?.setHoveredProfile?.(null); + api.sketchRuntime?.setSelected([]); + if (feature?.type === 'sketch') { + api.sketchRuntime?.setEditing(feature.id); + api.interact?.clearSketchSelection?.(); + api.interact?.setSketchTool?.('select'); + } else if (feature?.type === 'extrude') { + api.sketchRuntime?.setEditing(null); + api.interact?.clearSketchSelection?.(); + } else if (feature?.type === 'boolean') { + api.sketchRuntime?.setEditing(null); + api.interact?.clearSketchSelection?.(); + } else { + api.sketchRuntime?.setEditing(null); + api.interact?.clearSketchSelection?.(); + } + properties.showFeature(feature, { + onChange: () => this.render() + }); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); +} + +function renderDefaultGeometrySection() { + const datum = api.datum; + const row = this.createRow({ + label: 'Default Geometry', + depth: 0, + expanded: this.defaultGeometryExpanded, + onToggle: () => { + this.defaultGeometryExpanded = !this.defaultGeometryExpanded; + this.render(); + } + }); + this.container.appendChild(row); + + if (!this.defaultGeometryExpanded) { + return; + } + + const geometryRows = [ + { type: 'plane', key: 'xy', fallbackLabel: 'Top' }, + { type: 'plane', key: 'yz', fallbackLabel: 'Right' }, + { type: 'plane', key: 'xz', fallbackLabel: 'Front' }, + { type: 'origin', label: 'Origin' } + ]; + + for (const entry of geometryRows) { + if (entry.type === 'origin') { + const visible = api.origin.isVisible(); + const selected = !!api.interact?.selectedPoints?.has?.('origin-point'); + this.container.appendChild(this.createRow({ + label: entry.label, + depth: 1, + eyeVisible: visible, + selected, + onSelect: () => { + api.interact.selectPoint('origin-point', { ctrlKey: true, metaKey: false }); + this.render(); + }, + onEye: () => { + api.origin.setVisible(!visible); + this.render(); + } + })); + continue; + } + + const plane = datum.getPlane(entry.key); + if (!plane) continue; + applyPlaneTreeVisibility(plane); + const visible = getPlaneBaseVisible(plane); + const selected = !!api.interact?.selectedPlanes?.has?.(plane); + this.container.appendChild(this.createRow({ + label: plane.getLabel() || entry.fallbackLabel, + depth: 1, + eyeVisible: visible, + selected, + onSelect: () => { + api.interact.selectPlane(plane, { ctrlKey: true, metaKey: false }); + applyPlaneTreeVisibility(plane); + this.render(); + }, + onHoverEnter: () => { + setPlaneTreeHovered(plane, true); + applyPlaneTreeVisibility(plane); + plane.setHovered(true); + }, + onHoverLeave: () => { + plane.setHovered(false); + setPlaneTreeHovered(plane, false); + applyPlaneTreeVisibility(plane); + }, + onEye: () => { + const next = !visible; + plane.setVisible(next); + applyPlaneTreeVisibility(plane); + this.render(); + } + })); + } +} + +function renderFeaturesSection() { + const hover = getHoverContext(); + const faceSelection = getFaceSelectionContext(); + const row = this.createRow({ + label: 'Features', + depth: 0, + expanded: this.featuresExpanded, + onToggle: () => { + this.featuresExpanded = !this.featuresExpanded; + this.render(); + } + }); + this.container.appendChild(row); + + if (!this.featuresExpanded) { + return; + } + + const doc = api.document.current; + const folders = this.getFolders(doc); + const allFeatures = api.features.list(); + const needle = String(this.searchQuery || '').trim().toLowerCase(); + const filtering = needle.length > 0; + const features = filtering + ? allFeatures.filter(feature => String(feature?.name || feature?.type || 'feature').toLowerCase().includes(needle)) + : allFeatures; + const featureIds = new Set(allFeatures.map(f => f?.id).filter(Boolean)); + for (const id of Array.from(this.selectedFeatureIds || [])) { + if (!featureIds.has(id)) { + this.selectedFeatureIds.delete(id); + } + } + const hasOnlyDefaultFolder = folders.length === 1 && folders[0]?.id === 'features'; + const timelineCount = api.document.getTimelineCount(); + const activeEditIndex = getActiveEditIndex(); + const markerCount = this.timelinePointerDrag ? this.timelineDragTargetCount : timelineCount; + const setTimeline = async next => { + const changed = await api.document.setTimelineCount(next); + if (!changed) return; + ensureEditingSketchIsRenderable(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + }; + const timelineCountFromPointer = event => { + const featuresAll = api.features.list(); + if (!featuresAll.length) return 0; + const el = document.elementFromPoint(event.clientX, event.clientY); + const row = el?.closest?.('.tree-item-row'); + if (row?.dataset?.featureIndex !== undefined) { + const index = Number(row.dataset.featureIndex); + if (Number.isFinite(index)) { + const rect = row.getBoundingClientRect(); + const before = event.clientY < (rect.top + rect.height / 2); + return before ? index : index + 1; + } + } + const rows = Array.from(this.container.querySelectorAll('.tree-item-row[data-feature-index]')); + if (!rows.length) return 0; + const firstRect = rows[0].getBoundingClientRect(); + const lastRect = rows[rows.length - 1].getBoundingClientRect(); + if (event.clientY < firstRect.top) return 0; + if (event.clientY > lastRect.bottom) return featuresAll.length; + return this.timelineDragTargetCount ?? timelineCount; + }; + const beginTimelinePointerDrag = event => { + this.timelinePointerDrag = true; + this.timelineDragTargetCount = timelineCountFromPointer(event); + const onMove = moveEvent => { + if (!this.timelinePointerDrag) return; + const next = timelineCountFromPointer(moveEvent); + if (next !== this.timelineDragTargetCount) { + this.timelineDragTargetCount = next; + this.render(); + } + }; + const onUp = async upEvent => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + const finalCount = timelineCountFromPointer(upEvent); + this.timelinePointerDrag = false; + this.timelineDragTargetCount = null; + await setTimeline(finalCount); + }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }; + const dropMove = (targetFeature, before = true) => { + const dragId = this.dragFeatureId || null; + const targetId = targetFeature?.id || null; + if (!dragId || !targetId || dragId === targetId) return; + const list = api.features.list(); + const fromIndex = list.findIndex(f => f?.id === dragId); + const targetIndex = list.findIndex(f => f?.id === targetId); + if (fromIndex < 0 || targetIndex < 0) return; + let toIndex = targetIndex + (before ? 0 : 1); + if (fromIndex < toIndex) { + toIndex -= 1; + } + if (api.features.move(dragId, toIndex)) { + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + }; + + if (hasOnlyDefaultFolder) { + if (!features.length) { + this.container.appendChild(this.createEmptyRow(filtering ? 'No matching features' : 'No features yet', 1)); + return; + } + for (let displayIndex = 0; displayIndex < features.length; displayIndex++) { + const feature = features[displayIndex]; + const index = allFeatures.indexOf(feature); + if (!filtering && markerCount === index) { + this.container.appendChild(this.createTimelineMarkerRow({ + active: true, + onSelect: () => setTimeline(index), + onPointerStart: beginTimelinePointerDrag + })); + } + const label = feature?.name || feature?.type || 'Feature'; + const isSketch = feature?.type === 'sketch'; + const isExtrude = feature?.type === 'extrude'; + const isChamfer = feature?.type === 'chamfer'; + const isBoolean = feature?.type === 'boolean'; + const visible = feature?.visible !== false; + const suppressed = feature?.suppressed === true; + const beyondTimeline = !api.features.isIndexBuilt(index); + const blockedByEditing = activeEditIndex >= 0 && index > activeEditIndex; + this.container.appendChild(this.createItemRow(label, feature, 1, { + featureIndex: index, + selected: this.selectedFeatureIds?.has?.(feature?.id) || faceSelection.selectedFeatureIds.has(feature?.id), + hovered: !!(hover.hoveredFeatureIds.has(feature?.id) || (isSketch && hover.hoveredSketchIds.has(feature?.id))), + eyeVisible: visible, + suppressed, + beyondTimeline, + disabled: blockedByEditing, + onEye: isSketch ? f => { + api.features.setVisible(f.id, f.visible === false); + this.render(); + } : null, + actions: [ + { + label: suppressed ? '▶' : '⏸', + title: suppressed ? 'Unsuppress feature' : 'Suppress feature', + className: suppressed ? 'is-suppressed' : '', + onClick: f => { + api.features.setSuppressed(f.id, f.suppressed !== true); + ensureEditingSketchIsRenderable(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + } + ], + draggable: true, + isTimelineDragging: () => false, + onDragStart: f => { + this.dragFeatureId = f?.id || null; + }, + onDragOver: (_f, event) => { + event.preventDefault(); + }, + onDrop: (f, event, info) => { + event.preventDefault(); + dropMove(f, info?.before !== false); + this.dragFeatureId = null; + }, + onDragEnd: () => { + this.dragFeatureId = null; + }, + onSelect: f => this.onFeatureSelected(f), + onEdit: f => this.onFeatureEdit(f), + onHoverEnter: f => { + if (isSketch) api.sketchRuntime?.setHovered(f.id); + if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.(getSolidIdsForFeature(f?.id)); + }, + onHoverLeave: () => { + if (isSketch) api.sketchRuntime?.setHovered(null); + if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.([]); + } + })); + } + if (!filtering && markerCount === allFeatures.length) { + this.container.appendChild(this.createTimelineMarkerRow({ + active: true, + onSelect: () => setTimeline(allFeatures.length), + onPointerStart: beginTimelinePointerDrag + })); + } + return; + } + + for (let i = 0; i < folders.length; i++) { + const folder = folders[i]; + this.container.appendChild(this.createRow({ + label: folder.name || 'Folder', + depth: 1, + expanded: !folder.collapsed, + onToggle: () => { + folder.collapsed = !folder.collapsed; + api.document.save({ + kind: 'micro', + opType: 'tree.folder.toggle', + undoable: false, + payload: { folder_id: folder.id, collapsed: !!folder.collapsed } + }); + this.render(); + } + })); + + if (folder.collapsed) { + continue; + } + + const items = i === 0 ? features : []; + if (!items.length && i === 0) { + this.container.appendChild(this.createEmptyRow(filtering ? 'No matching features' : 'No features yet', 2)); + } + + for (let localIndex = 0; localIndex < items.length; localIndex++) { + const feature = items[localIndex]; + const index = allFeatures.indexOf(feature); + if (!filtering && i === 0 && markerCount === index) { + this.container.appendChild(this.createTimelineMarkerRow({ + active: true, + onSelect: () => setTimeline(index), + onPointerStart: beginTimelinePointerDrag + })); + } + const label = feature?.name || feature?.type || 'Feature'; + const isSketch = feature?.type === 'sketch'; + const isExtrude = feature?.type === 'extrude'; + const isChamfer = feature?.type === 'chamfer'; + const isBoolean = feature?.type === 'boolean'; + const visible = feature?.visible !== false; + const suppressed = feature?.suppressed === true; + const beyondTimeline = !api.features.isIndexBuilt(index); + const blockedByEditing = activeEditIndex >= 0 && index > activeEditIndex; + this.container.appendChild(this.createItemRow(label, feature, 2, { + featureIndex: index, + selected: this.selectedFeatureIds?.has?.(feature?.id) || faceSelection.selectedFeatureIds.has(feature?.id), + hovered: !!(hover.hoveredFeatureIds.has(feature?.id) || (isSketch && hover.hoveredSketchIds.has(feature?.id))), + eyeVisible: visible, + suppressed, + beyondTimeline, + disabled: blockedByEditing, + onEye: isSketch ? f => { + api.features.setVisible(f.id, f.visible === false); + this.render(); + } : null, + actions: [ + { + label: suppressed ? '▶' : '⏸', + title: suppressed ? 'Unsuppress feature' : 'Suppress feature', + className: suppressed ? 'is-suppressed' : '', + onClick: f => { + api.features.setSuppressed(f.id, f.suppressed !== true); + ensureEditingSketchIsRenderable(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + } + } + ], + draggable: true, + isTimelineDragging: () => false, + onDragStart: f => { + this.dragFeatureId = f?.id || null; + }, + onDragOver: (_f, event) => { + event.preventDefault(); + }, + onDrop: (f, event, info) => { + event.preventDefault(); + dropMove(f, info?.before !== false); + this.dragFeatureId = null; + }, + onDragEnd: () => { + this.dragFeatureId = null; + }, + onSelect: f => this.onFeatureSelected(f), + onEdit: f => this.onFeatureEdit(f), + onHoverEnter: f => { + if (isSketch) api.sketchRuntime?.setHovered(f.id); + if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.(getSolidIdsForFeature(f?.id)); + }, + onHoverLeave: () => { + if (isSketch) api.sketchRuntime?.setHovered(null); + if (isExtrude || isChamfer || isBoolean) api.solids?.setHovered?.([]); + } + })); + } + if (!filtering && i === 0 && items.length && markerCount === allFeatures.length) { + this.container.appendChild(this.createTimelineMarkerRow({ + active: true, + onSelect: () => setTimeline(allFeatures.length), + onPointerStart: beginTimelinePointerDrag + })); + } + } +} + +function renderSolidsSection() { + const hover = getHoverContext(); + const faceSelection = getFaceSelectionContext(); + const row = this.createRow({ + label: 'Solids', + depth: 0, + expanded: this.solidsExpanded, + onToggle: () => { + this.solidsExpanded = !this.solidsExpanded; + this.render(); + } + }); + this.container.appendChild(row); + + if (!this.solidsExpanded) { + return; + } + + const needle = String(this.searchQuery || '').trim().toLowerCase(); + const filtering = needle.length > 0; + const solidsAll = api.solids?.list?.() || []; + const solids = filtering + ? solidsAll.filter(solid => String(solid?.name || 'solid').toLowerCase().includes(needle)) + : solidsAll; + const activeEditIndex = getActiveEditIndex(); + + if (!solids.length) { + this.container.appendChild(this.createEmptyRow(filtering ? 'No matching solids' : 'No solids yet', 1)); + return; + } + + for (const solid of solids) { + const label = solid?.name || 'Solid'; + const visible = solid?.visible !== false; + const sourceFeatureId = solid?.source?.feature_id || null; + const featureIndex = sourceFeatureId + ? api.features.list().findIndex(feature => feature?.id === sourceFeatureId) + : -1; + const blockedByEditing = activeEditIndex >= 0 && featureIndex > activeEditIndex; + this.container.appendChild(this.createItemRow(label, solid, 1, { + selected: this.selectedSolidIds?.has?.(solid?.id) || faceSelection.selectedSolidIds.has(solid?.id), + hovered: hover.hoveredSolidIds.has(solid?.id), + eyeVisible: visible, + disabled: blockedByEditing, + onEye: item => { + const doc = api.document.current; + if (!doc?.generated?.solids) return; + const target = doc.generated.solids.find(s => s?.id === item?.id); + if (!target) return; + target.visible = target.visible === false; + api.document.save({ + kind: 'micro', + opType: 'solid.update', + undoable: false, + payload: { id: target.id, field: 'visible', value: !!target.visible } + }); + api.solids?.syncRuntime?.(); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + onSelect: (item, event) => { + const id = item?.id || null; + if (!id) return; + if (!this.selectedSolidIds) this.selectedSolidIds = new Set(); + const currentFeature = properties.currentFeatureId ? api.features.findById(properties.currentFeatureId) : null; + const editingBoolean = currentFeature?.type === 'boolean' && currentFeature?.id === properties.currentFeatureId; + const editingExtrude = currentFeature?.type === 'extrude' && currentFeature?.id === properties.currentFeatureId; + const extrudeOperation = String(currentFeature?.params?.operation || 'new'); + const editingExtrudeTargets = editingExtrude && (extrudeOperation === 'add' || extrudeOperation === 'subtract'); + const multi = editingBoolean || editingExtrudeTargets || !!(event?.ctrlKey || event?.metaKey); + if (!multi) { + if (this.selectedSolidIds.size === 1 && this.selectedSolidIds.has(id)) { + this.selectedSolidIds.clear(); + } else { + this.selectedSolidIds.clear(); + this.selectedSolidIds.add(id); + } + } else if (this.selectedSolidIds.has(id)) { + this.selectedSolidIds.delete(id); + } else { + this.selectedSolidIds.add(id); + } + if (!editingBoolean && !editingExtrudeTargets) { + this.selectedFeatureIds?.clear?.(); + this.selectedFeatureId = null; + } else { + if (editingBoolean) { + const mode = String(currentFeature?.params?.mode || 'add'); + const role = properties.getBooleanPickRole?.() || 'targets'; + const input = currentFeature?.input || {}; + const targets = Array.isArray(input.targets) ? input.targets.filter(Boolean) : []; + const tools = Array.isArray(input.tools) ? input.tools.filter(Boolean) : []; + let nextTargets = targets.slice(); + let nextTools = tools.slice(); + if (mode === 'subtract') { + if (role === 'tools') { + nextTargets = nextTargets.filter(sid => sid !== id); + if (this.selectedSolidIds.has(id)) { + if (!nextTools.includes(id)) nextTools.push(id); + } else { + nextTools = nextTools.filter(sid => sid !== id); + } + } else { + nextTools = nextTools.filter(sid => sid !== id); + if (this.selectedSolidIds.has(id)) { + if (!nextTargets.includes(id)) nextTargets.push(id); + } else { + nextTargets = nextTargets.filter(sid => sid !== id); + } + } + } else { + nextTargets = Array.from(this.selectedSolidIds); + nextTools = []; + } + const next = mode === 'subtract' + ? Array.from(new Set([...nextTargets, ...nextTools])) + : nextTargets.slice(); + api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + feature.input.targets = nextTargets; + feature.input.tools = nextTools; + }, { + opType: 'feature.update', + payload: { field: 'boolean.inputs', targets: nextTargets, tools: nextTools } + }); + this.selectedSolidIds = new Set(next); + } else if (editingExtrudeTargets) { + const nextTargets = Array.from(this.selectedSolidIds); + api.features.update(currentFeature.id, feature => { + feature.input = feature.input || {}; + feature.input.targets = nextTargets; + }, { + opType: 'feature.update', + payload: { field: 'targets', value: nextTargets } + }); + this.selectedSolidIds = new Set(nextTargets); + } + properties.onChanged?.(); + } + api.solids?.setSelected?.(Array.from(this.selectedSolidIds)); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + onEdit: item => { + const id = item?.id || null; + if (!id) return; + const doc = api.document.current; + if (!doc?.generated?.solids) return; + const target = doc.generated.solids.find(s => s?.id === id); + if (!target) return; + const next = window.prompt('Rename solid', target.name || 'Solid'); + if (next === null) return; + const name = String(next || '').trim(); + if (!name || name === target.name) return; + target.name = name; + api.document.save({ + kind: 'micro', + opType: 'solid.update', + undoable: false, + payload: { id: target.id, field: 'name', value: name } + }); + this.render(); + window.dispatchEvent(new CustomEvent('void-state-change')); + }, + onHoverEnter: item => { + const id = item?.id || null; + if (!id) return; + api.solids?.setHovered?.([id]); + }, + onHoverLeave: () => { + api.solids?.setHovered?.([]); + } + })); + } +} + +function getFolders(doc) { + if (!doc) { + return [{ id: 'features', name: 'Features', collapsed: false }]; + } + if (!doc.tree || !Array.isArray(doc.tree.folders) || doc.tree.folders.length === 0) { + doc.tree = { + folders: [{ id: 'features', name: 'Features', collapsed: false }] + }; + } + return doc.tree.folders; +} + +export { + bindRuntimeChanges, + render, + renderDefaultGeometrySection, + renderFeaturesSection, + renderSolidsSection, + getFolders, + onFeatureSelected, + onFeatureEdit +}; diff --git a/src/void/tree/render.js b/src/void/tree/render.js new file mode 100644 index 00000000..43c327cf --- /dev/null +++ b/src/void/tree/render.js @@ -0,0 +1,322 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function createHeader(text) { + const el = document.createElement('div'); + el.className = 'tree-header'; + el.textContent = text; + return el; +} + +function createSearchHeader({ value = '', onInput, onClear, onFocus, onBlur } = {}) { + const wrap = document.createElement('div'); + wrap.className = 'tree-search'; + + const icon = document.createElement('span'); + icon.className = 'tree-search-icon'; + icon.textContent = '🔍'; + wrap.appendChild(icon); + + const input = document.createElement('input'); + input.className = 'tree-search-input'; + input.type = 'text'; + input.placeholder = 'Search'; + input.value = value || ''; + input.autocomplete = 'off'; + input.spellcheck = false; + input.oninput = event => onInput?.(event); + input.onfocus = event => onFocus?.(event); + input.onblur = event => onBlur?.(event); + wrap.appendChild(input); + + const clear = document.createElement('button'); + clear.className = 'tree-search-clear'; + clear.textContent = '×'; + clear.title = 'Clear search'; + clear.style.visibility = (value && String(value).length) ? 'visible' : 'hidden'; + clear.onclick = event => { + event.stopPropagation(); + input.value = ''; + onClear?.(); + input.focus(); + }; + wrap.appendChild(clear); + + return wrap; +} + +function createDivider() { + const el = document.createElement('div'); + el.className = 'tree-divider'; + return el; +} + +function createRow({ label, depth = 0, expanded, onToggle, eyeVisible, onEye, onSelect, onHoverEnter, onHoverLeave, selected = false, hovered = false }) { + const row = document.createElement('div'); + row.className = 'tree-row'; + if (selected) { + row.classList.add('active'); + } + if (hovered) { + row.classList.add('hovered'); + } + if (onEye && eyeVisible === false) { + row.classList.add('is-off'); + } + row.style.paddingLeft = `${8 + depth * 16}px`; + + const left = document.createElement('div'); + left.className = 'tree-row-left'; + + if (onToggle) { + const twisty = document.createElement('button'); + twisty.className = 'tree-twisty'; + twisty.textContent = expanded ? '▾' : '▸'; + twisty.onclick = event => { + event.stopPropagation(); + onToggle(); + }; + left.appendChild(twisty); + } else { + const spacer = document.createElement('span'); + spacer.className = 'tree-twisty-spacer'; + spacer.textContent = ''; + left.appendChild(spacer); + } + + const text = document.createElement('div'); + text.className = 'tree-row-label'; + text.textContent = label; + left.appendChild(text); + + row.appendChild(left); + + if (onEye) { + const eye = document.createElement('button'); + eye.className = `tree-eye ${eyeVisible ? 'visible' : 'off'}`; + eye.textContent = '👁'; + eye.title = eyeVisible ? 'Hide' : 'Show'; + eye.onclick = event => { + event.stopPropagation(); + onEye(); + }; + row.appendChild(eye); + } + + if (typeof onSelect === 'function') { + row.onclick = event => onSelect(event); + } + if (typeof onHoverEnter === 'function') { + row.onmouseenter = () => onHoverEnter(); + } + if (typeof onHoverLeave === 'function') { + row.onmouseleave = () => onHoverLeave(); + } + + return row; +} + +function createTimelineMarkerRow({ active = false, onSelect, onPointerStart }) { + const row = document.createElement('div'); + row.className = `tree-timeline-marker ${active ? 'active' : ''}`; + row.onclick = () => onSelect?.(); + row.onmousedown = event => { + if (event.button !== 0) return; + onSelect?.(); + onPointerStart?.(event); + event.preventDefault(); + }; + row.title = 'Move history marker'; + + const line = document.createElement('div'); + line.className = 'tree-timeline-line'; + row.appendChild(line); + + return row; +} + +function createItemRow(label, feature, depth = 0, opts = {}) { + const row = document.createElement('div'); + row.className = 'tree-item-row'; + const disabled = !!opts.disabled; + if (opts.selected) { + row.classList.add('active'); + } + if (opts.hovered) { + row.classList.add('hovered'); + } + if (opts.eyeVisible === false) { + row.classList.add('is-off'); + } + if (opts.suppressed) { + row.classList.add('is-suppressed'); + } + if (opts.beyondTimeline) { + row.classList.add('is-future'); + } + if (disabled) { + row.classList.add('is-disabled'); + } + row.style.paddingLeft = `${8 + depth * 16}px`; + if (Number.isFinite(opts.featureIndex)) { + row.dataset.featureIndex = String(opts.featureIndex); + } + if (opts.draggable && !disabled) { + row.draggable = true; + } + + const left = document.createElement('div'); + left.className = 'tree-row-left'; + + const icon = document.createElement('span'); + icon.className = 'tree-item-icon'; + icon.textContent = this.getIcon(feature?.type); + + const text = document.createElement('div'); + text.className = 'tree-row-label'; + text.textContent = label; + + left.appendChild(icon); + left.appendChild(text); + row.appendChild(left); + + const actions = Array.isArray(opts.actions) ? opts.actions : []; + if (actions.length && !disabled) { + const actionWrap = document.createElement('div'); + actionWrap.className = 'tree-item-actions'; + for (const action of actions) { + if (!action || typeof action.onClick !== 'function') continue; + const btn = document.createElement('button'); + btn.className = 'tree-item-action'; + if (action.className) { + btn.classList.add(action.className); + } + btn.textContent = action.label || '•'; + btn.title = action.title || ''; + btn.disabled = !!action.disabled; + btn.onclick = event => { + event.stopPropagation(); + action.onClick(feature); + }; + actionWrap.appendChild(btn); + } + row.appendChild(actionWrap); + } + + if (!disabled) { + row.onclick = event => { + if (typeof opts.onSelect === 'function') { + opts.onSelect(feature, event); + } else { + console.log('Feature selected:', feature); + } + }; + row.ondblclick = () => { + if (typeof opts.onEdit === 'function') { + opts.onEdit(feature); + } + }; + } + if (!disabled && typeof opts.onHoverEnter === 'function') { + row.onmouseenter = () => opts.onHoverEnter(feature); + } + if (!disabled && typeof opts.onHoverLeave === 'function') { + row.onmouseleave = () => opts.onHoverLeave(feature); + } + + if (!disabled && typeof opts.onEye === 'function') { + const eye = document.createElement('button'); + eye.className = `tree-eye ${opts.eyeVisible !== false ? 'visible' : 'off'}`; + eye.textContent = '👁'; + eye.title = opts.eyeVisible !== false ? 'Hide' : 'Show'; + eye.onclick = event => { + event.stopPropagation(); + opts.onEye(feature); + }; + row.appendChild(eye); + } + + if (!disabled && opts.draggable && typeof opts.onDragStart === 'function') { + row.ondragstart = event => { + row.classList.add('is-dragging'); + event.dataTransfer?.setData('text/x-void-feature', String(feature?.id || '')); + event.dataTransfer.effectAllowed = 'move'; + opts.onDragStart(feature, event); + }; + } + if (!disabled && opts.draggable) { + row.ondragend = () => { + row.classList.remove('is-dragging'); + row.classList.remove('drag-over-before'); + row.classList.remove('drag-over-after'); + if (typeof opts.onDragEnd === 'function') { + opts.onDragEnd(feature); + } + }; + } + if (!disabled && opts.draggable && typeof opts.onDragOver === 'function') { + row.ondragover = event => { + event.preventDefault(); + const timelineDrag = !!opts.isTimelineDragging?.(); + const before = (event.offsetY || 0) < (row.clientHeight / 2); + row.classList.toggle('drag-over-before', before); + row.classList.toggle('drag-over-after', !before); + if (timelineDrag && typeof opts.onTimelineDragOver === 'function') { + opts.onTimelineDragOver(feature, event, { before }); + } else { + opts.onDragOver(feature, event, { before }); + } + }; + } + if (!disabled && opts.draggable) { + row.ondragleave = () => { + row.classList.remove('drag-over-before'); + row.classList.remove('drag-over-after'); + }; + } + if (!disabled && opts.draggable && typeof opts.onDrop === 'function') { + row.ondrop = event => { + event.preventDefault(); + const timelineDrag = !!opts.isTimelineDragging?.(); + const before = (event.offsetY || 0) < (row.clientHeight / 2); + row.classList.remove('drag-over-before'); + row.classList.remove('drag-over-after'); + if (timelineDrag && typeof opts.onTimelineDrop === 'function') { + opts.onTimelineDrop(feature, event, { before }); + } else { + opts.onDrop(feature, event, { before }); + } + }; + } + + return row; +} + +function createEmptyRow(label, depth = 0) { + const row = document.createElement('div'); + row.className = 'tree-empty-row'; + row.style.paddingLeft = `${8 + depth * 16}px`; + row.textContent = label; + return row; +} + +function getIcon(type) { + const icons = { + datum: '□', + sketch: '✏', + extrude: '⬆', + revolve: '↻', + boolean: '∪' + }; + return icons[type] || '•'; +} + +export { + createHeader, + createSearchHeader, + createDivider, + createRow, + createTimelineMarkerRow, + createItemRow, + createEmptyRow, + getIcon +}; diff --git a/src/void/viewcube.js b/src/void/viewcube.js new file mode 100644 index 00000000..27a08600 --- /dev/null +++ b/src/void/viewcube.js @@ -0,0 +1,315 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { THREE } from '../ext/three.js'; +import { space } from '../moto/space.js'; +import { VOID_PALETTE } from './palette.js'; + +const { + Group, BoxGeometry, MeshBasicMaterial, Mesh, + EdgesGeometry, LineSegments, LineBasicMaterial, + Scene, PerspectiveCamera, Vector3, Raycaster, Vector2 +} = THREE; + +/** + * ViewCube - Interactive 3D navigation widget + * Renders in top-right corner using separate scene/camera to avoid z-fighting + */ +class ViewCube { + constructor(options = {}) { + this.size = options.size || 60; // Size of cube in pixels + this.padding = options.padding || 20; // Padding from corner + this.cubeSize = options.cubeSize || 1.5; // 3D cube size + + // Create separate scene and camera for viewcube + this.scene = new Scene(); + this.camera = new PerspectiveCamera(45, 1, 0.1, 100); + this.camera.position.set(0, 0, 5); + + // Raycaster for mouse interaction + this.raycaster = new Raycaster(); + this.mouse = new Vector2(); + + // State + this.hoveredFace = null; + this.viewport = { x: 0, y: 0, width: this.size, height: this.size }; // CSS pixels, top-left origin + this.enabled = true; + + // Face colors + this.faceColors = { ...VOID_PALETTE.viewcube.faces }; + + this.hoverColor = VOID_PALETTE.viewcube.hover; + this.edgeColor = VOID_PALETTE.viewcube.edge; + + // Build the cube + this.build(); + + // Setup event listeners + this.setupEvents(); + } + + /** + * Build the viewcube geometry + */ + build() { + this.group = new Group(); + + // Create cube with individual face materials + const geometry = new BoxGeometry(this.cubeSize, this.cubeSize, this.cubeSize); + + // Materials for each face [right, left, top, bottom, front, back] + const materials = [ + new MeshBasicMaterial({ color: this.faceColors.right }), // +X right + new MeshBasicMaterial({ color: this.faceColors.left }), // -X left + new MeshBasicMaterial({ color: this.faceColors.top }), // +Y top + new MeshBasicMaterial({ color: this.faceColors.bottom }), // -Y bottom + new MeshBasicMaterial({ color: this.faceColors.front }), // +Z front + new MeshBasicMaterial({ color: this.faceColors.back }) // -Z back + ]; + + this.cube = new Mesh(geometry, materials); + this.cube.userData.isViewCube = true; + + // Store original colors for hover restore + this.originalColors = materials.map(m => m.color.getHex()); + + // Create edges + const edges = new EdgesGeometry(geometry); + const lineMaterial = new LineBasicMaterial({ + color: this.edgeColor, + linewidth: 2 + }); + this.edges = new LineSegments(edges, lineMaterial); + + this.group.add(this.cube); + this.group.add(this.edges); + this.scene.add(this.group); + + } + + /** + * Setup mouse event listeners + */ + setupEvents() { + const { container } = space.internals(); + + // Mouse move for hover + container.addEventListener('mousemove', (event) => { + if (!this.enabled) return; + this.onMouseMove(event); + }); + + // Mouse click for view change + container.addEventListener('click', (event) => { + if (!this.enabled) return; + this.onClick(event); + }); + } + + /** + * Handle mouse move (hover detection) + */ + onMouseMove(event) { + // Convert mouse to viewcube viewport coordinates + if (!this.isMouseInViewport(event)) { + // Mouse outside viewcube, clear hover + if (this.hoveredFace !== null) { + this.clearHover(); + } + return; + } + + // Get intersection + this.updateMousePosition(event); + this.raycaster.setFromCamera(this.mouse, this.camera); + const intersects = this.raycaster.intersectObject(this.cube); + + if (intersects.length > 0) { + const faceIndex = Math.floor(intersects[0].faceIndex / 2); + + if (this.hoveredFace !== faceIndex) { + this.clearHover(); + this.hoveredFace = faceIndex; + this.cube.material[faceIndex].color.setHex(this.hoverColor); + space.update(); + } + } else if (this.hoveredFace !== null) { + this.clearHover(); + } + } + + /** + * Clear hover state + */ + clearHover() { + if (this.hoveredFace !== null) { + this.cube.material[this.hoveredFace].color.setHex(this.originalColors[this.hoveredFace]); + this.hoveredFace = null; + space.update(); + } + } + + /** + * Handle click (view change) + */ + onClick(event) { + if (!this.isMouseInViewport(event)) return; + + this.updateMousePosition(event); + this.raycaster.setFromCamera(this.mouse, this.camera); + const intersects = this.raycaster.intersectObject(this.cube); + + if (intersects.length > 0) { + const faceIndex = Math.floor(intersects[0].faceIndex / 2); + this.onFaceClick(faceIndex); + } + } + + /** + * Handle face click - change view + */ + onFaceClick(faceIndex) { + // Map face index to view direction + // [right, left, top, bottom, front, back] + const views = ['right', 'left', 'top', 'bottom', 'front', 'back']; + const view = views[faceIndex]; + + // Call space.view preset methods + switch(view) { + case 'front': + space.view.front(); + break; + case 'back': + space.view.back(); + break; + case 'right': + space.view.right(); + break; + case 'left': + space.view.left(); + break; + case 'top': + space.view.top(); + break; + case 'bottom': + space.view.bottom(); + break; + } + } + + /** + * Check if mouse is in viewcube viewport + */ + isMouseInViewport(event) { + const { container } = space.internals(); + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + return x >= this.viewport.x && + x <= this.viewport.x + this.viewport.width && + y >= this.viewport.y && + y <= this.viewport.y + this.viewport.height; + } + + /** + * Update mouse position in viewcube coordinates + */ + updateMousePosition(event) { + const { container } = space.internals(); + const rect = container.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + // Convert to normalized device coordinates for viewcube viewport + this.mouse.x = ((x - this.viewport.x) / this.viewport.width) * 2 - 1; + this.mouse.y = -((y - this.viewport.y) / this.viewport.height) * 2 + 1; + } + + /** + * Update viewcube rotation to match main camera + */ + update() { + if (!this.enabled) return; + + // Get main camera orientation + const mainCamera = space.internals().camera; + + // Copy inverse rotation - viewcube rotates opposite to scene + this.group.quaternion.copy(mainCamera.quaternion); + this.group.quaternion.invert(); + } + + /** + * Render the viewcube + */ + render(renderer) { + if (!this.enabled) return; + + // Update rotation first + this.update(); + + // Save current state + const currentViewport = new Vector4(); + renderer.getViewport(currentViewport); + const currentAutoClear = renderer.autoClear; + + // Calculate viewport position using CSS pixels for hit-testing + const canvas = renderer.domElement; + const cssW = canvas.clientWidth || this.size; + const cssH = canvas.clientHeight || this.size; + const cssX = cssW - this.size - this.padding; + const cssY = this.padding; + this.viewport = { x: cssX, y: cssY, width: this.size, height: this.size }; + + // Convert CSS pixels to renderer drawing-buffer pixels (bottom-left origin) + const dpr = canvas.width / Math.max(1, cssW); + const vpX = Math.round(cssX * dpr); + const vpY = Math.round((cssH - cssY - this.size) * dpr); + const vpS = Math.round(this.size * dpr); + + // Set viewcube viewport + renderer.setViewport(vpX, vpY, vpS, vpS); + renderer.setScissor(vpX, vpY, vpS, vpS); + renderer.setScissorTest(true); + renderer.autoClear = false; + // Ensure the cube is never occluded by main-scene depth. + renderer.clearDepth(); + + // Render viewcube scene + renderer.render(this.scene, this.camera); + + // Restore state + renderer.setViewport(currentViewport); + renderer.setScissorTest(false); + renderer.autoClear = currentAutoClear; + } + + /** + * Set visibility + */ + setEnabled(enabled) { + this.enabled = enabled; + if (!enabled) { + this.clearHover(); + } + } + + /** + * Dispose resources + */ + dispose() { + if (this.cube) { + this.cube.geometry.dispose(); + this.cube.material.forEach(m => m.dispose()); + } + if (this.edges) { + this.edges.geometry.dispose(); + this.edges.material.dispose(); + } + } +} + +// Import Vector4 for viewport save/restore +const { Vector4 } = THREE; + +export { ViewCube }; diff --git a/src/void/worker/solids_worker.js b/src/void/worker/solids_worker.js new file mode 100644 index 00000000..f617a554 --- /dev/null +++ b/src/void/worker/solids_worker.js @@ -0,0 +1,59 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { ensureKernel } from '../solid/kernel.js'; +import { rebuildGeneratedSolidsFromSnapshot } from '../solid/rebuild.js'; + +let kernelReady = false; + +async function ensureReady() { + if (kernelReady) return; + await ensureKernel(); + kernelReady = true; +} + +function serializeMeshCache(meshCache) { + const meshes = []; + const transfer = []; + for (const [id, mesh] of meshCache.entries()) { + if (!id || !mesh?.positions?.length || !mesh?.indices?.length) continue; + const positions = mesh.positions instanceof Float32Array + ? mesh.positions + : new Float32Array(mesh.positions); + const indices = mesh.indices instanceof Uint32Array + ? mesh.indices + : new Uint32Array(mesh.indices); + meshes.push({ id, positions, indices }); + transfer.push(positions.buffer, indices.buffer); + } + return { meshes, transfer }; +} + +self.onmessage = async (event) => { + const msg = event?.data || {}; + const id = msg?.id ?? null; + const type = msg?.type || ''; + if (type !== 'rebuild') { + self.postMessage({ id, ok: false, error: `unknown message type: ${type}` }); + return; + } + try { + await ensureReady(); + const result = await rebuildGeneratedSolidsFromSnapshot(msg.snapshot || {}, { + reason: msg.reason || 'worker' + }); + const { meshes, transfer } = serializeMeshCache(result?.meshCache || new Map()); + self.postMessage({ + id, + ok: true, + solids: result?.solids || [], + meshes + }, transfer); + } catch (error) { + self.postMessage({ + id, + ok: false, + error: error?.message || String(error || 'unknown worker error') + }); + } +}; + diff --git a/web/boot/service.js b/web/boot/service.js index ff7e83e4..b7afd0b3 100644 --- a/web/boot/service.js +++ b/web/boot/service.js @@ -43,7 +43,7 @@ self.addEventListener('message', e => { async function _install(e) { log('install'); if (e.addRoutes) { - for (let pre of ["font","mesh","kiri","lib","wasm"]) { + for (let pre of ["font","mesh","kiri","void","lib","wasm"]) { e.addRoutes({ condition: { urlPattern: new URLPattern({ pathname: `/${pre}/.*` }) }, source: { cacheName: CACHE_VERSION } diff --git a/web/icon/kirimoto.png b/web/icon/kirimoto.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd333158677d0bd6dc417a6e8b2202242505026 GIT binary patch literal 91785 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 Xhk-knbQQ03P-|x$&BR0ss)3f#TvS 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�O%79|u4yJC~{{|E@XdQqE$tMRv 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 z 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 z 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 tRD8ARv=*Sd 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