diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80ef3fb9..f1b848b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,7 +11,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, macos-latest] steps: - name: Checkout code 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-el.js b/app-el.js index d1d349b0..125df65a 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, @@ -61,12 +78,15 @@ function createWindow() { if (url.endsWith('/mesh') || url.endsWith('/mesh/')) { return; } + if (url.endsWith('/void') || url.endsWith('/void/')) { + return; + } event.preventDefault(); shell.openExternal(url); }); webContents.on('did-finish-load', () => { - mainWindow.webContents.executeJavaScript(`{ let x = document.getElementById('app-quit'); if (x) { x.onclick = () => window.close() } }; null;`); + // console.log('did finish load'); }); if (devel) { diff --git a/app.js b/app.js index fb6f1459..a5f78d82 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,7 @@ const mods = {}; const load = []; const api = {}; +let lastTouchTime = {}; let forceUseCache = false; let serviceWorker = true; let crossOrigin = false; @@ -134,10 +135,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 +156,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 +192,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 @@ -208,9 +214,19 @@ function init(mod) { } } - // create alt artifacts with module extensions + // synthesize new main when applicable + createArtifacts(); +} + +// create alt artifacts with module extensions +function createArtifacts() { if (dryrun || !isElectron) { - logger.log('creating artifacts', Object.keys(append)); + if (debug) { + setTimeout(createArtifacts, 1000); + } + if (Object.keys(lastTouchTime).length === 0) { + logger.log('creating artifacts', Object.keys(append)); + } for (let [ key, val ] of Object.entries(append)) { // append mains let src = `${dir}/src/main/${key}.js`; @@ -218,6 +234,14 @@ function init(mod) { logger.log('missing', src); continue; } + let ltt = fs.statSync(src).mtimeMs; + if (lastTouchTime[src] === ltt) { + continue; + } else if (debug) { + logger.log('changed', src); + } + lastTouchTime[src] = ltt; + // console.log({ src, ltt }); fs.mkdirSync(`${dir}/alt/main`, { recursive: true }); let body = fs.readFileSync(src); fs.writeFileSync(`${dir}/alt/main/${key}.js`, body + val); @@ -234,7 +258,7 @@ function init(mod) { } else { logger.log('skipping artifacts'); } -}; +} // either add module assets to path or require(init.js) function loadModule(mod, dir) { @@ -257,7 +281,7 @@ function initModule(mod, file, dir) { logger.log({ module: file, dir }); require_fresh(file)({ // express functions added here show up at "/api/" url root - api: api, + api, adm: { setver(ver) { oversion = ver }, crossOrigin(bool) { crossOrigin = bool } @@ -329,8 +353,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 +395,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', @@ -487,7 +516,7 @@ function ifModifiedDate(req) { function addCorsHeaders(req, res) { res.setHeader('Access-Control-Allow-Credentials', 'true'); - res.setHeader('Access-Control-Allow-Headers', 'X-Moto-Ajax, Content-Type'); + res.setHeader('Access-Control-Allow-Headers', 'X-Api-Key, X-Host, X-Moto-Ajax, Content-Type'); res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] || '*'); if (req.headers['access-control-request-private-network'] === 'true') { res.setHeader('Access-Control-Allow-Private-Network', 'true'); 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 1a55e3e8..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 = [ ]; @@ -29,7 +32,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 +95,7 @@ const rec = { minify: isProd, // false for dev, true for prod platform: 'browser', sourcemap: false, - target: 'es2020', + target: 'es2022', }; async function buildApp() { @@ -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/conf/links.csv b/conf/links.csv index 4ab5141c..145af205 100644 --- a/conf/links.csv +++ b/conf/links.csv @@ -1,17 +1,19 @@ -src/ext/gerber.js,../../node_modules/@tracespace/parser/umd/parser.js -src/ext/manifold.js,../../node_modules/manifold-3d/manifold.js -src/ext/base64.js,../../node_modules/base64-js/base64js.min.js -src/ext/earcut.js,../../node_modules/earcut/src/earcut.js -src/ext/tween.js,../../node_modules/@tweenjs/tween.js/src/Tween.js -src/ext/jszip.js,../../node_modules/jszip/dist/jszip.js -src/wasm/manifold.wasm,../../node_modules/manifold-3d/manifold.wasm -src/kiri/lang-en.js,../../web/kiri/lang/en.js -web/fon2,../node_modules/bootstrap-icons/font/ -web/kiri/lang/pl.js,pl-pl.js -web/kiri/lang/pt-pt.js,pt.js -web/kiri/lang/da-dk.js,da.js -web/kiri/lang/en-us.js,en.js -web/kiri/lang/fr-fr.js,fr.js -web/kiri/lang/de.js,de-de.js -web/kiri/lang/es.js,es-es.js -web/font,../node_modules/@fortawesome/fontawesome-free/ +src//gpu/raster.js,../../node_modules/@gridspace/raster-path/build/raster-path.js +src//gpu/raster-worker.js,../../node_modules/@gridspace/raster-path/build/raster-worker.js +src//ext/gerber.js,../../node_modules/@tracespace/parser/umd/parser.js +src//ext/manifold.js,../../node_modules/manifold-3d/manifold.js +src//ext/base64.js,../../node_modules/base64-js/base64js.min.js +src//ext/earcut.js,../../node_modules/earcut/src/earcut.js +src//ext/tween.js,../../node_modules/@tweenjs/tween.js/src/Tween.js +src//ext/jszip.js,../../node_modules/jszip/dist/jszip.js +src//wasm/manifold.wasm,../../node_modules/manifold-3d/manifold.wasm +src//kiri/app/lang-en.js,../../../web/kiri/lang/en.js +web//fon2,../node_modules/bootstrap-icons/font/ +web//kiri/lang/pl.js,pl-pl.js +web//kiri/lang/pt-pt.js,pt.js +web//kiri/lang/da-dk.js,da.js +web//kiri/lang/en-us.js,en.js +web//kiri/lang/fr-fr.js,fr.js +web//kiri/lang/de.js,de-de.js +web//kiri/lang/es.js,es-es.js +web//font,../node_modules/@fortawesome/fontawesome-free/ 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-palette.md b/docs/future-palette.md new file mode 100644 index 00000000..e0e2ed4a --- /dev/null +++ b/docs/future-palette.md @@ -0,0 +1,49 @@ +# Shared Palette Audit (Kiri / Mesh / Void) + +## Goal + +Define a shared semantic color system in `web/moto/palette.css` and migrate each app incrementally with low risk. + +## Phase 1 Delivered + +- Added shared file: `web/moto/palette.css` +- Added semantic tokens for `light` and `dark` themes: + - `--color-bg`, `--color-surface`, `--color-text`, `--color-border`, `--color-accent`, etc +- Added compatibility aliases (bridge variables) for Mesh/Void existing CSS vars. +- Wired palette stylesheet into: + - `web/kiri/index.html` + - `web/mesh/index.html` + - `web/void/index.html` +- Added root attributes: + - Kiri: `data-app="kiri"` and early `data-theme="light|dark"` set in head script + - Mesh: `data-app="mesh" data-theme="dark"` + - Void: `data-app="void" data-theme="dark"` + +## Naming Recommendation (semantic first) + +- Surfaces: + - `--color-bg`, `--color-bg-elev`, `--color-bg-subtle` + - `--color-surface`, `--color-surface-2` +- Content: + - `--color-text`, `--color-text-muted` + - `--color-border`, `--color-border-strong` +- Interaction: + - `--color-accent`, `--color-accent-hover`, `--color-focus` + - `--color-selection`, `--color-selection-hover` +- Status: + - `--color-success`, `--color-warning`, `--color-danger` + +## Step-wise Migration Plan + +1. Convert top-level containers/menus/panels in each app to semantic tokens only. +2. Convert controls/interactions (hover/focus/selected) to semantic tokens. +3. Convert specialty overlays (grids, badges, debug panes) last. +4. Remove legacy per-app aliases once selectors are migrated. + +## Validation Checklist Per Step + +- Kiri light unchanged +- Kiri dark unchanged +- Mesh dark unchanged +- Void dark unchanged +- Contrast and hover/focus states still readable 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/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 | 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-chamfer-offset-v2.md b/docs/void/plan-chamfer-offset-v2.md new file mode 100644 index 00000000..58812470 --- /dev/null +++ b/docs/void/plan-chamfer-offset-v2.md @@ -0,0 +1,226 @@ +# Void Chamfer V2 Plan (Geometric Offset, Not Boolean Cutters) + +## Goal + +Replace the current chamfer implementation based on cutter solids + boolean difference with a deterministic geometric chamfer pipeline that operates directly on mesh topology and face offsets. + +## Why Change + +1. Current path is boolean-driven (`solid/chamfer.js`) and depends on synthetic cutter prisms. +2. Boolean chamfer is fragile near: + - short edges + - dense/curved topology + - multi-edge corner interactions + - near-coplanar/low-angle neighborhoods +3. Provenance and boundary tracking are harder when chamfer is represented as a subtract operation, rather than explicit edge-face reconstruction. +4. Debugging and deterministic replay are harder with cutter generation and manifold fallback behavior. + +## Current-State Findings + +1. Chamfer currently: + - resolves selected edges + - builds cutter meshes from adjacent triangle normals + - performs boolean difference + - writes resulting body as manifold output + +2. Signals in current code indicate boolean-centric lifecycle: + - `manifold_chamfer_passthrough` + - `manifold_chamfer_ready` + - cutter debug/failure logs + +3. Edge references are already fairly good: + - chamfer refs use canonical boundary/segment identities + - this is strong input for a topology-based rebuild + +## Target Architecture + +Chamfer becomes a topology/geometry transform, not a subtractive solid operation. + +1. Input: + - selected sharp edges (from stable boundary segment refs) + - chamfer distance (and later optional asymmetric distances) + +2. Core operation: + - for each selected edge, offset its two incident face planes by chamfer distance + - intersect offset planes with local wedge to compute chamfer strip geometry + - trim neighboring faces and insert chamfer face(s) + +3. Corner resolution: + - solve multi-edge vertex neighborhoods explicitly + - produce watertight corner patches without global booleans + +4. Output: + - rebuilt manifold mesh + updated provenance/boundary mappings + - explicit chamfer faces with stable IDs (not anonymous boolean remnants) + +## Data Model / Provenance Updates + +1. Extend chamfer result metadata: + - `source_edge_segment_ids[]` + - `generated_face_ids[]` + - `status: geometric_chamfer_ready` + +2. GeometryStore integration: + - chamfer faces emit boundaries/segments directly + - chamfer faces carry source edge lineage + +3. Preserve compatibility: + - existing docs still readable + - optional fallback to legacy boolean chamfer behind feature flag + +## Algorithm Plan + +## Stage A: Topology Extraction + +1. Build edge->incident-face adjacency from input mesh. +2. Identify valid chamfer candidates: + - manifold edges with exactly two incident faces + - non-smooth crease threshold gating + +3. Group selected edges into connected chamfer regions. + +## Stage B: Per-Edge Offset Construction + +1. For each selected edge: + - compute incident face normals + - construct two offset face planes + - compute chamfer line as plane-plane intersection in local neighborhood + +2. Create edge strip endpoints using neighboring trim constraints. + +## Stage C: Face Trimming + Insertion + +1. Trim original incident faces against chamfer boundary lines. +2. Insert chamfer quad/tri strip faces. +3. Maintain winding and local normal consistency. + +## Stage D: Vertex Corner Solver + +1. At each selected vertex: + - collect incoming chamfer strips + - solve intersection polygon in tangent frame + - triangulate corner patch deterministically + +2. Handle edge cases: + - 2-edge corner + - n-edge star corner + - near-parallel incident faces + +## Stage E: Rebuild + Mapping + +1. Rebuild indexed mesh with new vertices/faces. +2. Recompute boundary segments and canonical edge refs. +3. Emit provenance mappings: + - old edge ref -> new chamfer face/segments + - unchanged faces preserve IDs where possible + +## Execution Phases + +## Phase 1: Infrastructure + Feature Flag + +1. Add `chamfer_mode` toggle: + - `legacy_boolean` (default initially) + - `geometric_offset` (new path) +2. Build shared adjacency/topology helpers. + +Exit criteria: + +1. New path can run no-op safely and fall back cleanly. + +## Phase 2: Single-Edge Geometric Chamfer + +1. Implement robust one-edge chamfer on simple prism/cube cases. +2. Add deterministic unit fixtures. + +Exit criteria: + +1. Single selected edge produces expected geometry with no booleans. + +## Phase 3: Multi-Edge Same-Face + Parallel Chains + +1. Handle multiple selected edges on same body. +2. Ensure trim interactions are stable and watertight. + +Exit criteria: + +1. Common user workflows work without mesh cracks. + +## Phase 4: Corner Solver + +1. Implement n-edge corner patches. +2. Add tolerance policy and degeneracy handling. + +Exit criteria: + +1. Complex corners no longer require boolean fallback. + +## Phase 5: Provenance + GeometryStore Wiring + +1. Emit chamfer-derived boundaries/patch IDs with lineage. +2. Update hover/select mapping for chamfer outputs. + +Exit criteria: + +1. Chamfer boundaries are first-class and traceable. + +## Phase 6: Default Cutover + +1. Make geometric mode default. +2. Keep legacy boolean fallback for one release window. +3. Remove legacy path after stability window. + +## Testing Plan + +## Unit + +1. Edge adjacency correctness. +2. Plane offset/intersection math. +3. Corner patch triangulation determinism. +4. Degenerate geometry tolerance behavior. + +## Integration + +1. Cube single-edge chamfer. +2. Multiple connected edges. +3. Concave/convex mixed selections. +4. Timeline edits upstream/downstream with stable refs. +5. Interaction with boolean-added bodies. + +## Regression + +1. No face holes/non-manifold edges after chamfer. +2. No ID churn for unaffected faces. +3. Boundary segment refs remain selectable post-chamfer. + +## Risks and Mitigations + +1. Risk: corner solver complexity. + - Mitigation: staged rollout with strict fixtures before cutover. + +2. Risk: precision instability on small geometry. + - Mitigation: unified epsilon policy + local frame math. + +3. Risk: behavior divergence from existing chamfer expectations. + - Mitigation: side-by-side mode comparison tooling and temp dual-run validator. + +## Implementation Touchpoints + +1. `src/void/solid/chamfer.js` + - split into legacy boolean and new geometric engine. + +2. `src/void/solid/rebuild.js` + - route chamfer feature to mode-specific executor. + +3. `src/void/api/solids.js` + - preserve/refit canonical edge mappings after geometric chamfer. + - expose chamfer lineage for debug overlays. + +4. `src/void/api/geometry_store.js` + - ensure chamfer outputs emit boundary/segment/provenance records consistently. + +## Immediate Next Step + +Implement Phase 1 + Phase 2 in parallel: + +1. Add mode flag and new engine scaffolding. +2. Land deterministic single-edge geometric chamfer on planar solids. diff --git a/docs/void/plan-constraints-v2.md b/docs/void/plan-constraints-v2.md new file mode 100644 index 00000000..2a8c0be7 --- /dev/null +++ b/docs/void/plan-constraints-v2.md @@ -0,0 +1,150 @@ +# Void Constraints V2 Plan (Planegcs-First, Fallback as Safety Net) + +## Goal + +Move sketch solving to a clean planegcs-first architecture, remove dual-solver behavioral drift, and improve drag/tangent stability. + +## Current Findings + +1. Constraint solving is currently dual-mode by default: + - `enforceWithPlanegcs()` runs, then a fallback settle pass is still applied. + - This can reintroduce different motion/priority behavior after planegcs already converged. + +2. Drag interactions frequently force fallback: + - During drag, `useFallback: tangentDriven || !pointDrag` is used in pointer drag paths. + - This bypasses planegcs exactly where stable incremental behavior matters most. + +3. Tangent constraints are not mapped into planegcs in the current mapper: + - `toPlanegcsConstraint()` covers many constraints but not sketch `tangent`. + - Tangency currently depends on fallback heuristics (`constraints_tangent.js`). + +4. The planegcs wrapper already supports temporary constraints: + - Constraint objects with `temporary: true` are supported by the wrapper path. + - This enables proper drag-driving constraints with lower priority solving semantics. + +5. `angle_via_point` is available in the solver bindings: + - Suitable for robust endpoint tangency encoding (angle = 0 at shared endpoint). + - This aligns with FreeCAD guidance for improved stability vs direct tangent formulations in corner cases. + +## Root Cause Summary + +1. Two solvers are actively shaping geometry during interaction. +2. Drag logic has explicit fallback preference in key paths. +3. Tangency is solved outside planegcs, creating inconsistent convergence and corner-case instability. + +## Target Architecture + +1. Planegcs is the primary and default solver for all live interaction and final settle. +2. Fallback solver is retained only as failure recovery. +3. Drag uses temporary constraints in planegcs: + - Temporary point-to-point/point-to-line style guidance constraints to cursor/ghost references. + - No permanent topology mutation from drag constraints. +4. Tangency uses planegcs-native representation: + - Shared-endpoint tangent: `angle_via_point` with angle = 0. + - Non-shared cases: use direct tangent primitives where stable (`tangent_la`, `tangent_aa`, etc.), with endpoint-angle fallback where needed. + +## Phased Execution + +## Phase 1: Instrumentation and Guardrails + +1. Add solver telemetry per enforce call: + - planegcs used, fallback used, solve status, elapsed time. +2. Add debug toggle to display active temporary constraints during drag. +3. Add deterministic logs for tangent constraint path selection. + +Exit Criteria: + +1. We can observe when and why fallback is invoked. + +## Phase 2: Temporary Drag Constraints + +1. Add drag-time temporary constraints (`temporary: true`) in planegcs solve graph. +2. Remove drag-path forced fallback defaults. +3. Keep fallback only if planegcs solve fails or returns non-converged status. + +Exit Criteria: + +1. Drag no longer “snaps back” from dual-pass disagreement. +2. Solver path during normal drag is planegcs-only. + +## Phase 3: Tangent Migration + +1. Implement tangent mapping in `toPlanegcsConstraint()`: + - line-arc, arc-arc, line-circle as available. +2. For shared-endpoint tangent pairs, map to `angle_via_point` (angle=0). +3. Keep old tangent fallback path behind a temporary feature flag for rollback. + +Exit Criteria: + +1. Tangent drag corner cases no longer require tangent-specific fallback aggressiveness. +2. Shared-endpoint tangent cases are stable under repeated edits/drag. + +## Phase 4: Remove Default Dual Settle + +1. Remove unconditional fallback settle after successful planegcs solve. +2. Fallback runs only on explicit planegcs failure paths. +3. Keep compatibility switch (`constraints_v2_force_fallback`) for emergency rollback. + +Exit Criteria: + +1. Single primary solver behavior in normal operation. +2. Fewer constraint jitter/regressions from solver disagreement. + +## Phase 5: Cleanup + +1. Simplify pointer drag enforcement call sites. +2. Remove tangent-specific fallback tuning knobs that become obsolete. +3. Document canonical constraint mapping table and temporary-constraint rules. + +Exit Criteria: + +1. Constraint code paths are materially simpler and easier to reason about. + +## Proposed Code Touchpoints + +1. `src/void/sketch/constraints.js` + - Add temporary constraint plumbing and tangent mapping in `toPlanegcsConstraint()`. + - Remove unconditional fallback settle on success. + +2. `src/void/sketch/pointer.js` + - Replace drag-time fallback preference with planegcs temporary constraints. + +3. `src/void/sketch/constraints_actions.js` + - Ensure apply/edit flows use planegcs-first, fallback-on-failure behavior. + +4. `src/void/sketch/constraints_tangent.js` + - Transition from primary solver role to compatibility fallback only. + +5. `src/void/solver/sketch/gcs_wrapper.js` + - Confirm temporary constraint lifecycle handling and cleanup. + +## Risks and Mitigations + +1. Risk: Regression in legacy sketches tuned around fallback behavior. + - Mitigation: feature flag + staged rollout + telemetry. + +2. Risk: Performance regressions during drag with added temporary constraints. + - Mitigation: limit temporary constraint count to active dragged subset; cap iterations. + +3. Risk: Incorrect tangent mapping for mixed entity types. + - Mitigation: explicit mapping matrix tests per constraint subtype. + +## Validation Plan + +1. Unit tests: + - Tangent mapping (shared endpoint and non-shared). + - Temporary constraint injection/removal lifecycle. + - Planegcs success path without fallback pass. + +2. Interaction tests: + - Drag with dimensions, coincident, perpendicular, and tangent combos. + - Repeated drag/release cycles without geometric drift. + - Circular/grid/polygon pattern interactions under drag. + +3. Regression scenarios: + - Known tangent corner cases. + - Previously flaky dual-solver “snap back” sketches. + +## Recommendation + +Start with Phase 2 (temporary drag constraints + fallback-on-failure only for drag) before full tangent migration. This yields immediate UX improvement and reduces dual-solver interference while keeping rollback safety. 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-face-provenance.md b/docs/void/plan-face-provenance.md new file mode 100644 index 00000000..b211c156 --- /dev/null +++ b/docs/void/plan-face-provenance.md @@ -0,0 +1,234 @@ +# Void Face Provenance Plan (Boundary-First, Split Faces) + +## Goal + +Track which portions of resulting solids come from which sketch extrude regions, including after union/subtract, while keeping storage compact and spline-ready. + +## Execution Status (2026-03-02) + +Completed: + +1. Plan authored and staged into phased implementation. +2. Debug visualization toggles added to preferences and wired to solids runtime: +3. Boundary loop rendering from GeometryStore. +4. Segment rendering from GeometryStore. +5. Segment/surface/region ID labels (overlay text). +6. Fixed world/local debug overlay transform bug: +7. GeometryStore points are world-space; line geometry parented under solids root must convert world -> root local because `space.WORLD` is rotated -90deg on X. +8. Manifold relation passthrough wired through kernel/worker/rebuild (`runIndex`, `runOriginalID`, `faceID`, and source-run solid mapping). +9. GeometryStore now emits provenance-partitioned `surface_patches` from per-face triangle run attribution (not just per-face-loop seeds). +10. Topology now records `patch_to_tris` and `tri_to_patch` during snapshot build for downstream hover/selection cutover. +11. Debug boundary rendering now prefers patch boundaries when present, so visualization aligns with sketch-derived/provenance splits. + +In progress: + +1. Cut over hover/selection resolvers from face-loop heuristics to patch-first entities (`surface_patch_id` canonical path). +2. Improve partition quality from triangle boundary approximation to robust boundary arrangement where needed. + +Next up: + +1. Add explicit `surface_patch_id` in hit/canonical selection entities. +2. Bind extrude-profile hover/select directly to patch/source-region maps. +3. Add regression fixtures for boolean unions/subtracts with mixed curved + planar outputs. + +## Decisions + +1. Primary provenance is `boundary/region/surface-patch`, not raw triangle ownership. +2. Triangle ownership is derived runtime index only (`tri -> surface_patch_id`) and can be rebuilt. +3. Multi-source output faces must be split into multiple bounded surface patches so each patch has one canonical source region. +4. Line/arc support ships first; segment model must support future spline kinds without schema redesign. + +## Scope + +In scope: + +1. Extrude + boolean provenance tracking through rebuild pipeline. +2. Face splitting by source-region boundaries. +3. GeometryStore schema extension for stable patch-level IDs and source refs. +4. Runtime mapping from picks (`face/edge`) to canonical patch and source region. + +Out of scope (initial pass): + +1. Native spline feature authoring. +2. Long-lived persisted triangle provenance tables. +3. Non-planar sketch-on-surface expansion beyond current behavior. + +## Data Model Changes + +Add/extend document `geometry_store` entities: + +1. `segments[]` +2. `kind: line | arc | spline` +3. `geom`: kind-specific payload +4. `sampled_polyline` (optional cache for hit testing/partitioning) + +5. `boundaries[]` +6. Ordered `segment_ids` +7. `closed`, orientation, optional parent/child nesting + +8. `regions[]` +9. `outer_boundary_id` +10. `hole_boundary_ids[]` +11. `source`: canonical source ref (`profile::`) + +12. New `surface_patches[]` +13. `id` +14. `surface_id` (geometric carrier face) +15. `boundary_ids[]` (outer + holes) +16. `source_region_id` (single canonical owner) +17. `source_feature_id` (extrude feature) +18. `solid_id` +19. `status` (`direct`, `boolean-derived`, `rebound`) + +20. New runtime-only `topology.patch_tri_index` +21. Maps mesh triangles to `surface_patch_id` for selection/render acceleration. + +## Kernel Boundary Changes (Manifold) + +Current kernel adapter only round-trips positions/indices; relation metadata is dropped. + +Planned update: + +1. Preserve Manifold mesh relation fields where available (`runOriginalID`, `faceID`, related run metadata). +2. Carry relation metadata through `extrudePolygons()` and `booleanMeshes()`. +3. Emit relation-aware intermediate records to rebuild stage (not directly persisted). + +This enables deterministic attribution from boolean output back to input generated solids/regions before patch splitting. + +## Provenance Build Pipeline + +### Stage A: Sketch Region Capture + +1. Keep current closed-loop profile extraction for line/arc. +2. Emit canonical `region_id = profile::`. +3. Record region boundaries using generic segment schema (`line|arc` now, `spline` later). + +### Stage B: Extrude Seed Patches + +1. Extrude each selected sketch region. +2. Seed cap/side patch candidates with direct source region refs. +3. Preserve manifold relation fields in intermediate mesh record. + +### Stage C: Boolean Attribution + +1. Perform add/subtract/intersect with relation-carrying meshes. +2. Build attribution map from output primitives/runs to source seed patches. +3. Mark ambiguous/mixed carrier surfaces for partitioning. + +### Stage D: Face Partitioning (Split Multi-Source Faces) + +1. For each mixed carrier surface, project contributing source boundaries to surface-local space. +2. Build planar arrangement, split into disjoint bounded cells. +3. Assign each cell a single `source_region_id` by relation majority + geometric tie-break. +4. Emit one `surface_patch` per bounded cell. + +### Stage E: Runtime Topology Index + +1. Build `tri -> surface_patch_id` map from patch partition output. +2. Use map for selection hit resolution and hover highlighting. +3. Rebuild index each solids rebuild; do not persist large triangle maps. + +## Selection/Interaction Integration + +1. Face pick resolves to `surface_patch_id` first, then `source_region_id`. +2. Edge/boundary pick resolves to `boundary_id`/`segment_id` that belongs to a patch. +3. Extrude-profile hover/highlight uses `source_region_id -> surface_patch[]` mapping. +4. Remove fallback heuristics that infer provenance only from coarse `source.profile_keys`. + +## Storage and Performance + +1. Persist compact canonical graph (`segments/boundaries/regions/surface_patches`). +2. Keep triangle-level maps runtime-only to avoid doc bloat and instability across remeshes. +3. Cache partition signatures per carrier surface to avoid full repartition when unchanged. + +## Migration Plan + +### Phase 1: Schema and Adapters + +1. Add `surface_patches` schema and runtime index container. +2. Introduce generic segment schema (`kind + geom`) with current line/arc emitters. +3. Add compatibility normalizer for older docs (missing `surface_patches`). + +### Phase 2: Kernel Metadata Plumbing + +1. Extend solid kernel adapter to preserve manifold relation metadata. +2. Pass relation metadata through worker/main rebuild paths. + +### Phase 3: Patch Builder + +1. Implement mixed-face detection. +2. Implement local-space boundary arrangement and patch emission. +3. Add deterministic patch IDs/signatures. + +### Phase 4: Resolver Cutover + +1. Switch face selection from coarse face groups to `surface_patch` entities. +2. Update properties/tree hover mapping to patch/source-region links. + +### Phase 5: Cleanup + +1. Remove coarse provenance fallbacks once parity is validated. +2. Keep compatibility reader for older docs without `surface_patches`. + +## Validation Plan + +Unit tests: + +1. Region extraction determinism (line/arc). +2. Mixed-face partitioning into disjoint bounded patches. +3. Single-owner assignment per patch. +4. Deterministic patch IDs under stable input. + +Integration tests: + +1. Two extrudes unioned: top face splits by source boundary and highlights per profile. +2. Subtract operation: surviving walls/caps retain correct source region refs. +3. Edit upstream sketch profile: downstream patch mapping updates without manual repair. +4. Rebuild in worker vs main thread yields identical patch/source mapping. + +Regression guardrails: + +1. No persisted triangle tables in doc snapshots. +2. No schema changes required to add spline segment kind later. +3. Selection never reports mixed-source face entities. +4. Any debug/runtime geometry under solids root must explicitly convert GeometryStore world coordinates to root-local coordinates. +5. Boundary debug rendering must use GeometryStore (`boundaries` / `surface_patches`) as source of truth, not reconstructed sketch-plane loops. + +## Implementation Checklist + +Phase 1: + +1. Done: update `src/void/api/geometry_store.js` schema to include `surface_patches` and runtime topology patch map container. +2. Done: extend `buildGeometryStoreSnapshot()` in `src/void/api/solids.js` to emit seeded `surface_patches` per face-loop with source-region candidate fields. +3. In progress: keep current face-key canonical mapping intact while adding optional patch ID fields. + +Phase 2: + +1. Update `src/void/solid/kernel.js` mesh conversion to preserve manifold relation fields: +2. Input pass-through where provided (`runOriginalID`, `runIndex`, `faceID`, etc.). +3. Output pass-through into rebuild intermediate structures. +4. Add worker payload support for relation arrays when present. + +Phase 3: + +1. Implement mixed-source detection in `src/void/solid/rebuild.js`. +2. Add per-surface local partition pass and emit patch boundaries. +3. Assign one canonical `source_region_id` per patch. + +Phase 4: + +1. Extend selection resolver and solids hit mapping to prefer patch IDs over raw face IDs. +2. Add properties/tree hover mapping from extrude profile -> patch IDs. +3. Remove coarse fallback once parity checks pass. + +Phase 5: + +1. Add deterministic integration tests for union/subtract split-face provenance. +2. Remove temporary migration branches and finalize docs. + +## Acceptance Criteria + +1. Every selectable resulting face area maps to exactly one `source_region_id`. +2. Multi-source carrier faces are visibly and topologically split at source boundaries. +3. Extrude profile hover/select maps accurately to resulting solid patches after booleans. +4. GeometryStore stays compact and stable; triangle mapping is derived at runtime. 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/mods/proxy/.debug b/mods/proxy/.debug new file mode 100644 index 00000000..e69de29b diff --git a/mods/proxy/.electron b/mods/proxy/.electron new file mode 100644 index 00000000..e69de29b diff --git a/mods/proxy/init.js b/mods/proxy/init.js new file mode 100644 index 00000000..b8a0639c --- /dev/null +++ b/mods/proxy/init.js @@ -0,0 +1,55 @@ +module.exports = async (server) => { + + const { api, env, handler, path, util } = server; + + server.inject("kiri", "main.js"); + + if (!(env.debug || env.electron)) { + util.log('not a valid context for proxy'); + return; + } + + path.full({ + "/printer/print/start": proxy_post, + "/server/files/upload": proxy_post, + "/api/files/local": proxy_post, + }); +}; + +function proxy_post(req, res, next) { + handler.addCORS(req, res); + if (req.method === 'POST') { + let { url, headers } = req; + let chunks = []; + let host = headers['x-host']; + let apik = headers['x-api-key'] ?? ''; + let cont = headers['content-type'] ?? 'application/binary'; + req + .on('data', data => chunks.push(data) ) + .on('end', () => { + req.app.post = Buffer.concat(chunks); + if (host) { + fetch(host + url, { + method: 'POST', + headers: { + 'Content-Type': cont, + 'X-Api-Key': apik + }, + body: req.app.post + }).then(result => { + if (result.ok) { + res.writeHead(200, 'OK'); + } else { + res.writeHead(500, 'Failed to proxy'); + console.log({ result }); + } + res.end(); + }); + } else { + console.log('drop proxy due to lack of host'); + } + }); + } else { + next(); + } +} diff --git a/mods/proxy/main.js b/mods/proxy/main.js new file mode 100644 index 00000000..77858c9b --- /dev/null +++ b/mods/proxy/main.js @@ -0,0 +1,3 @@ +self.kiri.load(api => { + api.feature.proxy = true; +}, 'Proxy'); 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 e269f823..337edac4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "grid-apps", - "version": "4.6.0", + "version": "4.7.0", "description": "grid.space 3d slicing & modeling tools", "author": "Stewart Allen ", "license": "MIT", @@ -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", @@ -77,7 +78,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", @@ -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/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/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/geo/slicer.js b/src/geo/slicer.js index 4889a8cb..9254ec69 100644 --- a/src/geo/slicer.js +++ b/src/geo/slicer.js @@ -41,6 +41,7 @@ export async function slice(points, options = {}) { zSum = 0.0, // sanity check that points enclose non-zere volume buckets = [], // banded/grouped faces to speed up slice/search overlapMax = options.overlap || 0.75, + bucketMin = options.bucketMin ?? 1, bucketMax = options.bucketMax || 100, onupdate = options.onupdate || function() {}, sliceFn = dval(options.slicer, sliceZ), @@ -138,7 +139,7 @@ export async function slice(points, options = {}) { let zSpan = zMax - zMin; let zSpanAvg = zSum / points.length; let bucketCount = options.bucket !== false ? - Math.min(bucketMax, Math.max(1, Math.floor(zSpan / zSpanAvg))) : 1; + Math.min(bucketMax, Math.max(bucketMin, Math.floor(zSpan / zSpanAvg))) : 1; zScale = 1 / (zMax / bucketCount); @@ -211,7 +212,6 @@ export async function slice(points, options = {}) { let count = 0; let opt = { ...options, zMin, zMax, zIndexes }; let ps = []; - for (let i = 0, l = buckets.length; i < l; i++) { let bucket = buckets[i]; let { points, slices } = bucket; diff --git a/src/kiri/app/api.js b/src/kiri/app/api.js index 9c687c4d..ba954dea 100644 --- a/src/kiri/app/api.js +++ b/src/kiri/app/api.js @@ -40,12 +40,14 @@ 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, 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 @@ -163,6 +165,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 }, @@ -227,6 +230,7 @@ export const api = { onkey(fn) { api.feature.on_key2.push(fn); }, + opfs: OPFS, platform, process: processModule, sdb: dataLocal, @@ -234,6 +238,12 @@ export const api = { settings, show: { alert() { return alerts.show(...arguments) }, + busy(msg) { + if (msg === false || msg === null || msg === 0 || msg === '') { + return visuals.set_progress(0); + } + return visuals.set_progress(-1, typeof msg === 'string' ? msg : undefined); + }, controls() { console.trace('deprecated') }, devices: showDevices, import() { api.ui.import.style.display = '' }, diff --git a/src/kiri/app/conf/defaults.js b/src/kiri/app/conf/defaults.js index 6ccd3143..1efecc0b 100644 --- a/src/kiri/app/conf/defaults.js +++ b/src/kiri/app/conf/defaults.js @@ -341,11 +341,11 @@ export const conf = { sliceSupportAngle: 50, sliceSupportDensity: 0.1, sliceSupportExtra: 0, - sliceSupportGap: 1, + sliceSupportGap: true, sliceSupportNozzle: 0, sliceSupportOffset: 1.0, sliceSupportOutline: true, - sliceSupportSpan: 5, + // sliceSupportSpan: 5, sliceSupportType: "disabled", sliceSupportTree: false, sliceTopLayers: 3, @@ -604,9 +604,11 @@ export const conf = { camToolInit: true, camTraceDogbone: false, camTraceDown: 0, + camTraceIgnore: false, camTraceLines: false, camTraceMerge: false, camTraceOffOver: 0, + camTraceOffZ: 0, camTraceOffset: "none", camTraceOver: 0.5, camTracePlunge: 200, diff --git a/src/kiri/app/conf/dialog.js b/src/kiri/app/conf/dialog.js index 042ceffe..ab716689 100644 --- a/src/kiri/app/conf/dialog.js +++ b/src/kiri/app/conf/dialog.js @@ -82,6 +82,8 @@ function updateProcessList() { load.onclick = (ev) => { api.conf.load(undefined, sk); updateProcessList(); + // update pulldowns + api.devices.refresh(); modal.hide(); } load.appendChild(DOC.createTextNode(sk)); diff --git a/src/kiri/app/conf/manager.js b/src/kiri/app/conf/manager.js index c15f57ec..cdc8f910 100644 --- a/src/kiri/app/conf/manager.js +++ b/src/kiri/app/conf/manager.js @@ -172,9 +172,6 @@ function updateSettings(opt = {}) { same = false; } } - - $('mode-device').innerText = device.deviceName; - $('mode-profile').innerText = `${cproc[mode]}${same ? '' : ' *'}`; } function updateSettingsFromFields(setrec, uirec = api.ui, changes) { @@ -279,6 +276,7 @@ function updateFieldsFromSettings(setrec, uirec = api.ui, opt = {}) { let opt = document.createElement('option'); opt.appendChild(document.createTextNode(el.name)); opt.setAttribute('value', ev); + if (id === '#') opt.setAttribute('disabled', true); uie.appendChild(opt); }); if (chosen) { @@ -526,7 +524,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 +537,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() }; @@ -559,6 +557,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); diff --git a/src/kiri/app/devices.js b/src/kiri/app/devices.js index 77b8f1e7..2714a37b 100644 --- a/src/kiri/app/devices.js +++ b/src/kiri/app/devices.js @@ -301,9 +301,7 @@ function setDeviceCode(code, devicename) { */ function renderDevices(devices) { let selected = api.device.get() || devices[0], - features = api.feature, - devs = setconf.get().devices, - dfilter = typeof(features.device_filter) === 'function' ? features.device_filter : undefined; + devs = setconf.get().devices; for (let local in devs) { if (!(devs.hasOwnProperty(local) && devs[local])) { @@ -367,6 +365,31 @@ function renderDevices(devices) { api.device.export(exp, selected, { event, record }); }; + updateDeviceSelector(devices, api.ui.modeDevice, selected); + updateDeviceSelector(devices, $('dev-list'), selected); + selectDevice(selected); + + // update related settings list + let curr = settings.get(); + let slist = Object.keys(curr.sproc[settings.mode()]); + let cproc = settings.proc().processName; + + h.bind(api.ui.modeProfile, slist.map(profile => { + return h.option({ + _: profile, + selected: profile === cproc ? 1 : undefined, + onclick() { console.log({ select: profile })} + }); + })); + + api.ui.modeProfile.onchange = () => { + api.conf.load(undefined, api.ui.modeProfile.value); + }; +} + +function updateDeviceSelector(devices, selector, selected) { + let features = api.feature; + let dfilter = typeof(features.device_filter) === 'function' ? features.device_filter : undefined; let dedup = {}; let list_cdev = []; let list_mdev = []; @@ -388,7 +411,7 @@ function renderDevices(devices) { } }); - let dev_list = $('dev-list'); + let dev_list = selector; h.bind(dev_list, [ h.option({ _: '-- My Devices --', disabled: true }), ...list_mdev, @@ -401,7 +424,6 @@ function renderDevices(devices) { const seldev = dev_list.options[dev_list.selectedIndex]; selectDevice(seldev.innerText); api.platform.layout(); + updateDeviceList(); } - selectDevice(selected); -} - +} \ No newline at end of file diff --git a/src/kiri/app/export.js b/src/kiri/app/export.js index 1479f36f..b1e5556f 100644 --- a/src/kiri/app/export.js +++ b/src/kiri/app/export.js @@ -247,13 +247,16 @@ function exportGCodeDialog(gcode, sections, info, names) { ajax = new XMLHttpRequest(), host = octo_host.value.toLowerCase(), apik = octo_apik.value, - type = octo_type.value; + type = octo_type.value, + targetLocal = !api.util.isSecure(host), + siteSecure = api.const.SECURE, + proxy = targetLocal && api.feature.proxy ? host : undefined; if (host.indexOf("http") !== 0) { api.show.alert("host missing protocol (http:// or https://)"); return; } - if (api.const.SECURE && !api.util.isSecure(host)) { + if (siteSecure && targetLocal && !proxy) { api.show.alert("host must begin with 'https' on a secure site"); return; } @@ -262,6 +265,11 @@ function exportGCodeDialog(gcode, sections, info, names) { localSet('octo-apik', apik.trim()); localSet('octo-type', type.trim()); + if (proxy) { + console.log('proxying request to', host); + host = 'http://127.0.0.1:5309'; + } + filename = $('print-filename').value + "." + fileext; form.append("file", getBlob(), filename); ajax.onreadystatechange = function() { @@ -297,6 +305,9 @@ function exportGCodeDialog(gcode, sections, info, names) { } else { ajax.open("POST", host + "/api/files/local"); } + if (proxy) { + ajax.setRequestHeader("X-Host", proxy); + } if (apik) { ajax.setRequestHeader("X-Api-Key", apik); } diff --git a/src/kiri/app/image.js b/src/kiri/app/image.js index 97fda375..447860d5 100644 --- a/src/kiri/app/image.js +++ b/src/kiri/app/image.js @@ -22,36 +22,58 @@ function loadImageDialog(image, name, force) { } }); } - const opt = {pre: [ - "
", - "

Image Conversion

", - "

", - " This will create a 3D model from a 2D PNG image. Photos must", - " be blurred to be usable. Values from 0=off to 50=high are suggested.", - " Higher values incur more processing time.", - "

", - "
", - " ", - " ", - " ", - " ", - " ", - " ", - "
blur value invert image
base size invert alpha
border size
", - "
" - ]}; - api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => { - if (ok) { + const rnd = Date.now().toString(36); + const host = $('mod-any'); + host.innerHTML = [ + `
`, + `

Image Conversion

`, + `

`, + ` This will create a 3D model from a 2D PNG image. Photos must`, + ` be blurred to be usable. Values from 0=off to 50=high are suggested.`, + ` Higher values incur more processing time.`, + `

`, + `
`, + ` `, + ` `, + ` `, + ` `, + ` `, + ` `, + `
blur valueinvert image
base sizeinvert alpha
border size
`, + `
`, + ` `, + ` `, + `
`, + `
` + ].join(''); + + const blur = $(`png-blur-${rnd}`); + const base = $(`png-base-${rnd}`); + const border = $(`png-border-${rnd}`); + const invImage = $(`png-inv-${rnd}`); + const invAlpha = $(`alpha-inv-${rnd}`); + const okBtn = $(`img-convert-ok-${rnd}`); + const cancelBtn = $(`img-convert-cancel-${rnd}`); + + okBtn.onclick = () => { + api.modal.hide(); + setTimeout(() => { loadImage(image, { file: name, - blur: parseInt($('png-blur').value) || 0, - base: parseInt($('png-base').value) || 0, - border: parseInt($('png-border').value) || 0, - inv_image: $('png-inv').checked, - inv_alpha: $('alpha-inv').checked + blur: parseInt(blur.value) || 0, + base: parseInt(base.value) || 0, + border: parseInt(border.value) || 0, + inv_image: invImage.checked, + inv_alpha: invAlpha.checked }); - } - }); + },50); + }; + cancelBtn.onclick = () => api.modal.hide(); + blur.onkeypress = (ev) => { + if (ev.key === 'Enter' || ev.charCode === 13) okBtn.click(); + }; + api.modal.show('any'); + setTimeout(() => blur.focus(), 0); } /** @@ -62,10 +84,10 @@ function loadImageDialog(image, name, force) { */ function loadImage(image, opt = {}) { const info = Object.assign({settings: settings.get(), png:image}, opt); - api.client.image2mesh(info, progress => { - api.show.progress(progress, "converting"); + api.client.image2mesh(info, () => { + api.show.busy('converting'); }, vertices => { - api.show.progress(0); + api.show.busy(false); const widget = newWidget().loadVertices(vertices); widget.meta.file = opt.file; platform.add(widget); diff --git a/src/kiri/app/init/bind.js b/src/kiri/app/init/bind.js index 0debc56e..d5d4ba3f 100644 --- a/src/kiri/app/init/bind.js +++ b/src/kiri/app/init/bind.js @@ -105,8 +105,10 @@ export function bind() { range: $('slider-center'), }, - loading: $('progress').style, - progress: $('progbar').style, + loading: $('progress-overlay')?.style, + progressOverlay: $('progress-overlay'), + progress: $('progress-ring'), + progressPct: $('progress-pct'), prostatus: $('progtxt'), selection: $('selection'), diff --git a/src/kiri/app/init/build.js b/src/kiri/app/init/build.js new file mode 100644 index 00000000..005661e7 --- /dev/null +++ b/src/kiri/app/init/build.js @@ -0,0 +1,23 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { menubar } from './menu.js'; +import { api } from '../api.js'; + +const surfaces = { + build(actions = {}) { + const stop = (fn) => (ev) => { + ev?.stopPropagation?.(); + return fn?.(ev); + }; + menubar.build({ + ...actions, + 'view-arrange': stop(() => api.platform.layout()), + 'act-slice': stop(() => api.function.slice()), + 'act-preview': stop(() => api.function.print()), + 'act-animate': stop(() => api.function.animate()), + 'act-export': stop(() => api.function.export()) + }); + } +}; + +export { surfaces }; diff --git a/src/kiri/app/init/input.js b/src/kiri/app/init/input.js index 040e2dce..1cc5a4e5 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) { @@ -142,6 +139,11 @@ function onResize() { } else { ui.modalBox.classList.remove('mh85'); } + if (WIN.innerWidth < 800) { + $('app').classList.add('slideshow'); + } else { + api.prefs.updateDrawer(); + } api.view.update_slider(); } @@ -174,6 +176,7 @@ export function init_input() { event.on('resize', onResize); // configure moto.space + space.view.setFitPadding({ perspective: 0.8 }); space.sky.showGrid(false); space.sky.setColor(controller.dark ? 0 : 0xffffff); space.setAntiAlias(controller.antiAlias); @@ -211,6 +214,10 @@ export function init_input() { // api augmentation with local functions api.device.export = settingsOps.export_device; + let driven = true, + hideable = true, + separator = true; + Object.assign(ui, { tracker: tracker, container: container, @@ -320,6 +327,10 @@ export function init_input() { prefadd: uc.checkpoint($('prefs-add')), + _____: newGroup('Machine Profile', $('all-devpro'), { driven, hideable, separator, group: "devpro" }), + modeDevice: newSelect('machine', {title: 'device', class: "tiny"}, "_"), + modeProfile: newSelect('profile', {title: 'profile', class: "tiny"}, "_"), + /** FDM Settings */ ...menuFDM(), @@ -349,6 +360,8 @@ export function init_input() { // override old style settings two-button menu ui.settingsSave.onclick = () => { settingsOps.settings_save(undefined, ui.settingsName.value); + // update pulldowns + api.devices.refresh(); }; // initialize and expose modal to API diff --git a/src/kiri/app/init/menu.js b/src/kiri/app/init/menu.js new file mode 100644 index 00000000..bbdcf9d8 --- /dev/null +++ b/src/kiri/app/init/menu.js @@ -0,0 +1,267 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { $, h } from '../../../moto/webui.js'; +import { api } from '../api.js'; + +const { div, span, label, input, button, i, hr } = h; + +function icon(cls) { + return i({ class: cls }); +} + +function on(actions, id) { + return actions && typeof actions[id] === 'function' ? { onclick: actions[id] } : {}; +} + +function tr(key, fallback) { + return api.language?.current?.[key] || fallback || key; +} + +function menuItem(actions, { id, lk, xlk, text, title, iconClass, className, children, onclick }) { + const attr = { + ...(id ? { id } : {}), + ...(title ? { title } : {}), + ...(className ? { class: className } : {}), + ...(onclick ? { onclick } : {}), + ...on(actions, id) + }; + const lblAttr = { + ...(lk ? { lk } : {}), + ...(xlk ? { xlk } : {}) + }; + const resolved = lk ? tr(lk, text) : xlk ? tr(xlk, text) : text; + return div(attr, [ + resolved !== undefined ? label({ ...lblAttr, _: resolved }) : undefined, + iconClass ? span([icon(iconClass)]) : undefined, + children + ].filter(v => v !== undefined)); +} + +function dropMenu(actions, side, items) { + return div({ class: `top-menu-drop top-menu-${side}` }, [ + div({ class: 'content' }, items) + ]); +} + +function topMenu(actions, { text, lk, iconClass, side = 'left', right = false, items }) { + const resolved = lk ? tr(lk, text) : text; + return span({ class: right ? 'menu-right' : undefined }, [ + resolved !== undefined ? label({ ...(lk ? { lk } : {}), _: resolved }) : undefined, + iconClass ? icon(iconClass) : undefined, + dropMenu(actions, side, items) + ]); +} + +function rotatePanel(actions) { + return div({ id: 'panel-rotate', class: 'selection-panel hide' }, [ + div({ id: 'panel-rotate-head', class: 'selection-panel-head' }, [ + div({ class: 'selection-panel-head-title' }, [ + i({ class: 'fas fa-rotate-right' }), + label({ _: 'Rotate' }) + ]), + button({ id: 'panel-rotate-close', class: 'selection-panel-close', title: 'close' }, [ + i({ class: 'fas fa-times' }) + ]) + ]), + div({ id: 'ft-rotate', class: 'grid selection-panel-body' }, [ + div({ id: 'rot_x_lt', ...on(actions, 'rot_x_lt') }, icon('fas fa-chevron-left')), + label({ _: 'X' }), + div({ id: 'rot_x_gt', ...on(actions, 'rot_x_gt') }, icon('fas fa-chevron-right')), + input({ id: 'rot_x', class: 'value center', size: '6', value: '90' }), + div({ id: 'rot_y_lt', ...on(actions, 'rot_y_lt') }, icon('fas fa-chevron-left')), + label({ _: 'Y' }), + div({ id: 'rot_y_gt', ...on(actions, 'rot_y_gt') }, icon('fas fa-chevron-right')), + input({ id: 'rot_y', class: 'value center', size: '6', value: '90' }), + div({ id: 'rot_z_lt', ...on(actions, 'rot_z_lt') }, icon('fas fa-chevron-left')), + label({ _: 'Z' }), + div({ id: 'rot_z_gt', ...on(actions, 'rot_z_gt') }, icon('fas fa-chevron-right')), + input({ id: 'rot_z', class: 'value center', size: '6', value: '90' }), + div({ class: 'buttons f-row' }, [ + button({ id: 'unrotate', class: 'grow', lk: 'reset', _: 'reset', ...on(actions, 'unrotate') }) + ]) + ]) + ]); +} + +function scalePanel(actions) { + return div({ id: 'panel-scale', class: 'selection-panel hide' }, [ + div({ id: 'panel-scale-head', class: 'selection-panel-head' }, [ + div({ class: 'selection-panel-head-title' }, [ + i({ class: 'fas fa-expand' }), + label({ _: 'Scale / Size' }) + ]), + button({ id: 'panel-scale-close', class: 'selection-panel-close', title: 'close' }, [ + i({ class: 'fas fa-times' }) + ]) + ]), + div({ id: 'ft-scale', class: 'grid selection-panel-body' }, [ + div([label({ _: 'X' }), input({ id: 'lock_x', type: 'checkbox', _checked: true })]), + div([label({ _: 'Y' }), input({ id: 'lock_y', type: 'checkbox', _checked: true })]), + div([label({ _: 'Z' }), input({ id: 'lock_z', type: 'checkbox', _checked: true })]), + label({ id: 'lab-axis', lk: 'axis', _: 'axis', ...on(actions, 'lab-axis') }), + input({ id: 'size_x', size: '8', class: 'value' }), + input({ id: 'size_y', size: '8', class: 'value' }), + input({ id: 'size_z', size: '8', class: 'value' }), + label({ id: 'lab-size', lk: 'size', _: 'size' }), + input({ id: 'scale_x', size: '8', class: 'value', value: '1' }), + input({ id: 'scale_y', size: '8', class: 'value', value: '1' }), + input({ id: 'scale_z', size: '8', class: 'value', value: '1' }), + label({ id: 'lab-scale', lk: 'scale', _: 'scale', ...on(actions, 'lab-scale') }), + div({ class: 'buttons f-row' }, [ + button({ id: 'scale-reset', class: 'grow j-center', _: 'reset', ...on(actions, 'scale-reset') }) + ]) + ]) + ]); +} + +function content(actions) { + return [ + div({ class: 'menubar-appname el-app-hide', _: 'Kiri:Moto' }), + div({ class: 'menubar-separator el-app-hide' }), + div({ class: 'f-row top-menu grow' }, [ + topMenu(actions, { + text: 'files', lk: 'fe_menu', items: [ + menuItem(actions, { id: 'file-new', lk: 'new', text: 'new', iconClass: 'fas fa-file' }), + hr(), + menuItem(actions, { id: 'file-recent', lk: 'recent', text: 'recent', iconClass: 'fas fa-list' }), + menuItem(actions, { + id: 'file-import', lk: 'import', text: 'import', iconClass: 'fas fa-file-upload', children: + input({ id: 'load-file', type: 'file', name: 'loadme', style: 'display:none', accept: '.km,.kmz,.stl,.obj,.svg,.dxf,.png,.jpg,.jpeg,.gcode,.nc' }) + }), + hr(), + menuItem(actions, { id: 'mesh-export-obj', lk: 'export-obj', text: 'save as OBJ', iconClass: 'fas fa-dice-d20' }), + menuItem(actions, { id: 'mesh-export-stl', lk: 'export-stl', text: 'save as STL', iconClass: 'fas fa-dice-d20' }), + hr(), + menuItem(actions, { id: 'app-export', lk: 'rc_xpws', text: 'export work', iconClass: 'fas fa-download' }), + hr({ class: "app-hide" }), + menuItem(actions, { id: 'app-quit', lk: 'quit', text: 'quit', className: 'hide', onclick() { window.close() } }) + ] + }), + topMenu(actions, { + text: 'edit', lk: 'ed_menu', items: [ + menuItem(actions, { id: 'context-layflat', lk: 'rc_lafl', text: 'face down', iconClass: 'fas fa-angle-double-down' }), + menuItem(actions, { id: 'context-lefty', lk: 'face_left', text: 'face left', iconClass: 'fas fa-angle-double-left' }), + hr(), + menuItem(actions, { id: 'context-mirror', lk: 'rc_mirr', text: 'mirror', iconClass: 'fas fa-arrows-left-right-to-line' }), + menuItem(actions, { id: 'context-duplicate', lk: 'rc_dupl', text: 'duplicate', iconClass: 'fas fa-copy' }), + hr(), + menuItem(actions, { id: 'context-rotate-panel', text: 'rotate', iconClass: 'fas fa-rotate-right' }), + menuItem(actions, { id: 'context-scale-panel', text: 'scale / size', iconClass: 'fas fa-expand' }), + hr(), + menuItem(actions, { id: 'mesh-merge', lk: 'rc_merg', text: 'merge meshes' }), + menuItem(actions, { id: 'mesh-split', lk: 'rc_splt', text: 'isolate meshes' }), + ] + }), + topMenu(actions, { + text: 'view', lk: 'vu_menu', items: [ + menuItem(actions, { id: 'context-setfocus', lk: 'rc_focs', text: 'focal point', iconClass: 'fas fa-eye' }), + hr(), + menuItem(actions, { id: 'view-fit', lk: 'contents', text: 'contents', iconClass: 'fas fa-arrows-to-circle' }), + menuItem(actions, { id: 'view-home', lk: 'home', text: 'home', iconClass: 'fas fa-home' }), + menuItem(actions, { id: 'view-top', lk: 'top', text: 'top', iconClass: 'fas fa-square' }), + hr(), + menuItem(actions, { id: 'app-xpnd', lk: 'fullscreen', text: 'fullscreen', iconClass: 'fas fa-maximize' }) + ] + }), + topMenu(actions, { + text: 'render', lk: 're_menu', items: [ + menuItem(actions, { id: 'render-solid', lk: 'solid', text: 'solid', iconClass: 'fas fa-square' }), + menuItem(actions, { id: 'render-wire', lk: 'wire', text: 'wireframe', iconClass: 'fas fa-border-all' }), + menuItem(actions, { id: 'render-ghost', lk: 'ghost', text: 'transparent', iconClass: 'fas fa-border-none' }), + hr(), + menuItem(actions, { id: 'render-edges', lk: 're_edgs', text: 'toggle edges', iconClass: 'fa-regular fa-square' }) + ] + }), + div({ class: 'f-row top-menu' }, [ + span({ id: 'tool-nozzle' }, [ + label({ lk: 'tool', _: tr('tool', 'tool') }), + div({ id: 'ft-nozzle', class: 'f-col pop' }) + ]) + ]), + div({ class: 'grow' }), + topMenu(actions, { + text: 'info', lk: 'info', side: 'right', right: true, items: [ + menuItem(actions, { id: 'app-help', lk: 'help', text: 'help' }), + menuItem(actions, { id: 'app-don8', lk: 'donate', text: 'donate' }), + ] + }), + topMenu(actions, { + text: 'mode', lk: 'mo_menu', side: 'right', right: true, items: [ + menuItem(actions, { id: 'mode-fdm', text: 'FDM', title: '3D Additive Printing Processes', iconClass: 'fas fa-layer-group' }), + menuItem(actions, { id: 'mode-cam', text: 'CNC', title: 'CNC Mills and Subtractive Processes', iconClass: 'fas fa-bore-hole' }), + menuItem(actions, { id: 'mode-sla', text: 'SLA', title: 'mSLA Resin Printing', iconClass: 'fas fa-cube' }), + hr(), + menuItem(actions, { id: 'mode-laser', text: 'Laser', title: 'Laser Cutting and Engraving', iconClass: 'fas fa-bolt' }), + menuItem(actions, { id: 'mode-wjet', text: 'Water', title: 'WaterJet Cutting', iconClass: 'fas fa-location-pin' }), + menuItem(actions, { id: 'mode-wedm', text: 'Wire', title: 'Wire EDM Cutting', iconClass: 'fas fa-ellipsis-vertical' }), + menuItem(actions, { id: 'mode-drag', text: 'Drag', title: 'Drag Knife Cutting', iconClass: 'fas fa-caret-left' }) + ] + }), + topMenu(actions, { + text: 'setup', lk: 'su_menu', side: 'right', right: true, items: [ + menuItem(actions, { id: 'set-device', lk: 'machines', text: 'machines', iconClass: 'fas fa-cube' }), + menuItem(actions, { id: 'set-profs', lk: 'profs', text: 'profiles', iconClass: 'fas fa-sliders-h' }), + menuItem(actions, { id: 'set-tools', lk: 'tools', text: 'tools', iconClass: 'fas fa-tools' }), + menuItem(actions, { id: 'set-prefs', lk: 'prefs', text: 'prefs', iconClass: 'fa-solid fa-square-check' }), + hr({ class: "el-app-hide" }), + menuItem(actions, { id: 'install', lk: 'install', text: 'install' }), + menuItem(actions, { id: 'uninstall', lk: 'uninstall', text: 'uninstall', className: 'hide' }) + ] + }), + topMenu(actions, { + iconClass: 'fas fa-language', side: 'right', right: true, items: [ + menuItem(actions, { children: label({ id: 'lset-zh', _: '简体中文' }) }), + menuItem(actions, { children: label({ id: 'lset-da', _: 'dansk' }) }), + menuItem(actions, { children: label({ id: 'lset-de', _: 'deutsch' }) }), + menuItem(actions, { children: label({ id: 'lset-en', _: 'english' }) }), + menuItem(actions, { children: label({ id: 'lset-es', _: 'español' }) }), + menuItem(actions, { children: label({ id: 'lset-fr', _: 'français' }) }), + menuItem(actions, { children: label({ id: 'lset-pl', class: 'nocap', _: 'polski' }) }), + menuItem(actions, { children: label({ id: 'lset-pt', _: 'português' }) }) + ] + }) + ]), + rotatePanel(actions), + scalePanel(actions) + ]; +} + +function modeTools(actions) { + const t = (key, fallback) => tr(key, fallback); + return [ + span({ id: 'view-arrange', ...on(actions, 'view-arrange') }, [ + span([icon('fas fa-shapes')]), + span({ lk: 'arrange', _: t('arrange', 'arrange') }) + ]), + span({ id: 'act-slice', ...on(actions, 'act-slice') }, [ + span([icon('fas fa-bars')]), + label({ id: 'label-slice', title: 'generate layer slices', lk: 'slice', _: t('slice', 'slice') }) + ]), + span({ id: 'act-preview', ...on(actions, 'act-preview') }, [ + span([icon('fas fa-layer-group')]), + label({ id: 'label-preview', title: 'show routing and paths', lk: 'preview', _: t('preview', 'preview') }) + ]), + span({ id: 'act-animate', ...on(actions, 'act-animate') }, [ + span([icon('fas fa-film')]), + label({ id: 'label-animate', title: 'render routing on a mesh', lk: 'animate', _: t('animate', 'animate') }) + ]), + span({ id: 'act-export', ...on(actions, 'act-export') }, [ + span([icon('fas fa-file-download')]), + label({ id: 'label-export', title: 'generate gcode', lk: 'export', _: t('export', 'export') }) + ]) + ]; +} + +export const menubar = { + build(actions = {}) { + const menubarNode = $('menubar'); + const modeToolsNode = $('mode-tools'); + if (!menubarNode) { + return; + } + h.bind(menubarNode, content(actions)); + if (modeToolsNode) { + h.bind(modeToolsNode, modeTools(actions)); + } + } +}; diff --git a/src/kiri/app/init/sync.js b/src/kiri/app/init/sync.js index a5ed5915..32b29ab4 100644 --- a/src/kiri/app/init/sync.js +++ b/src/kiri/app/init/sync.js @@ -120,7 +120,9 @@ export async function init_sync() { setup_keybd_nav(); // show topline separator when iframed - try { if (WIN.self !== WIN.top) $('top-sep').style.display = 'flex' } catch (e) { console.log(e) } + if (WIN.self !== WIN.top) { + $('menubar').classList.add('top'); + } // warn users they are running a beta release if (beta && beta > 0 && sdb.kiri_beta != beta) { @@ -143,6 +145,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'; } @@ -215,22 +220,126 @@ function ui_sync() { } function setup_keybd_nav() { + const panelPosKeys = { + 'panel-rotate': 'win.roate', + 'panel-scale': 'win.scale' + }; + + function parsePanelPos(key) { + if (!key) return null; + const raw = api.local.get(key); + if (!raw) return null; + if (typeof raw === 'object' && raw.left !== undefined && raw.top !== undefined) { + return raw; + } + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + if (parsed && parsed.left !== undefined && parsed.top !== undefined) { + return parsed; + } + } catch (e) { + // ignore malformed stored values + } + } + return null; + } + + function placePanel(panel, pos) { + if (!panel || !pos) return; + panel.style.left = `${Math.round(pos.left)}px`; + panel.style.top = `${Math.round(pos.top)}px`; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + } + + function placePanelDefault(panel) { + if (!panel) return; + const rect = panel.getBoundingClientRect(); + const width = rect.width || 260; + const modeToolsRect = $('mode-tools')?.getBoundingClientRect(); + const baseTop = modeToolsRect ? (modeToolsRect.bottom + 10) : 92; + const minPad = 8; + const maxLeft = Math.max(minPad, window.innerWidth - width - minPad); + const left = Math.min(maxLeft, Math.max(minPad, (window.innerWidth - width) / 2)); + placePanel(panel, { left, top: baseTop }); + } + + function showSelectionPanel(pid) { + const panel = $(pid); + if (!panel) return; + panel.classList.remove('hide'); + const key = panelPosKeys[pid]; + const saved = parsePanelPos(key); + if (saved) { + placePanel(panel, saved); + } else { + placePanelDefault(panel); + } + } + + function hideSelectionPanel(pid) { + const panel = $(pid); + if (!panel) return; + panel.classList.add('hide'); + } + + function toggleSelectionPanel(pid) { + const el = $(pid); + if (!el) return; + if (el.classList.contains('hide')) { + showSelectionPanel(pid); + } else { + hideSelectionPanel(pid); + } + } + + function makePanelDraggable(panelId, handleId, storageKey) { + const panel = $(panelId); + const handle = $(handleId); + if (!panel || !handle) return; + let sx = 0, sy = 0, px = 0, py = 0, dragging = false; + handle.onmousedown = (ev) => { + if (ev.button !== 0) return; + dragging = true; + sx = ev.clientX; + sy = ev.clientY; + const rect = panel.getBoundingClientRect(); + px = rect.left; + py = rect.top; + ev.preventDefault(); + ev.stopPropagation(); + }; + document.addEventListener('mousemove', (ev) => { + if (!dragging) return; + const nx = px + (ev.clientX - sx); + const ny = py + (ev.clientY - sy); + panel.style.left = `${Math.round(nx)}px`; + panel.style.top = `${Math.round(ny)}px`; + panel.style.right = 'auto'; + panel.style.bottom = 'auto'; + }); + document.addEventListener('mouseup', () => { + if (dragging && storageKey) { + const rect = panel.getBoundingClientRect(); + api.local.set(storageKey, JSON.stringify({ + left: Math.round(rect.left), + top: Math.round(rect.top) + })); + } + dragging = false; + }); + } + // bind interface action elements ui.acct.help.onclick = (ev) => { ev.stopPropagation(); api.help.show() }; ui.acct.don8.onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') }; - ui.acct.mesh.onclick = (ev) => { ev.stopPropagation(); WIN.location = "/mesh" }; ui.acct.export.onclick = (ev) => { ev.stopPropagation(); settingsOps.export_profile() }; ui.acct.export.title = LANG.acct_xpo; - ui.func.slice.onclick = (ev) => { ev.stopPropagation(); api.function.slice() }; - ui.func.preview.onclick = (ev) => { ev.stopPropagation(); api.function.print() }; - ui.func.animate.onclick = (ev) => { ev.stopPropagation(); api.function.animate() }; - ui.func.export.onclick = (ev) => { ev.stopPropagation(); api.function.export() }; // prevent modal input from propagating to parents ui.modalBox.onclick = (ev) => { ev.stopPropagation() }; $('export-support-a').onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') }; - $('mode-device').onclick = api.show.devices; - $('mode-profile').onclick = settingsOps.settings_load; $('mode-fdm').onclick = () => api.mode.set('FDM'); $('mode-cam').onclick = () => api.mode.set('CAM'); $('mode-sla').onclick = () => api.mode.set('SLA'); @@ -245,13 +354,9 @@ function setup_keybd_nav() { $('file-new').onclick = (ev) => { ev.stopPropagation(); settingsOps.new_workspace() }; $('file-recent').onclick = () => { api.modal.show('files') }; $('file-import').onclick = (ev) => { api.event.import(ev); }; - $('view-arrange').onclick = api.platform.layout; $('view-top').onclick = space.view.top; $('view-home').onclick = space.view.home; - $('view-front').onclick = space.view.front; - $('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 +379,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 +393,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 = () => { @@ -301,15 +409,24 @@ function setup_keybd_nav() { $('mesh-split').onclick = selection.isolateBodies; $('context-duplicate').onclick = selection.duplicate; $('context-mirror').onclick = selection.mirror; + $('context-rotate-panel').onclick = () => toggleSelectionPanel('panel-rotate'); + $('context-scale-panel').onclick = () => toggleSelectionPanel('panel-scale'); + $('panel-rotate-close').onmousedown = (ev) => { ev.stopPropagation(); }; + $('panel-scale-close').onmousedown = (ev) => { ev.stopPropagation(); }; + $('panel-rotate-close').onclick = (ev) => { ev.stopPropagation(); hideSelectionPanel('panel-rotate'); }; + $('panel-scale-close').onclick = (ev) => { ev.stopPropagation(); hideSelectionPanel('panel-scale'); }; $('context-layflat').onclick = view_tools.startLayFlat; $('context-lefty').onclick = view_tools.startLeftAlign; $('context-setfocus').onclick = () => { view_tools.startFocus(ev => api.space.set_focus(undefined, ev.object.point)); }; - $('context-contents').onclick = api.const.SPACE.view.fit; + // $('context-contents').onclick = api.const.SPACE.view.fit; $('view-fit').onclick = api.const.SPACE.view.fit; $('wassup').onmouseover = () => { $('suppopp').classList.remove('hide') }; + makePanelDraggable('panel-rotate', 'panel-rotate-head', 'win.roate'); + makePanelDraggable('panel-scale', 'panel-scale-head', 'win.scale'); + // enable modal hiding $('mod-x').onclick = api.modal.hide; @@ -319,6 +436,13 @@ function setup_keybd_nav() { sdb.gdpr = Date.now(); }; - // add app name hover info - $('app-info').innerText = version; + // 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); + } } diff --git a/src/kiri/app/inputs.js b/src/kiri/app/inputs.js index 2a3a05a1..4ee9d6a8 100644 --- a/src/kiri/app/inputs.js +++ b/src/kiri/app/inputs.js @@ -889,7 +889,7 @@ function newSelect(label, options = {}, source) { } else { row.setAttribute("source", source || "tools"); } - row.setAttribute("class", "var-row"); + row.classList.add('var-row'); row.style.display = hide ? 'none' : ''; if (options.id) ip.setAttribute("id", options.id); if (options.convert) ip.convert = options.convert.bind(ip); diff --git a/src/kiri/app/mode.js b/src/kiri/app/mode.js index 7f992d9d..ecd607fd 100644 --- a/src/kiri/app/mode.js +++ b/src/kiri/app/mode.js @@ -63,6 +63,7 @@ function setMode(mode, lock, then) { // change mode constants current.mode = mode; MODE = MODES[mode]; + document.title = 'Kiri:Moto | ' + mode; // gcode edit area for any non-SLA mode api.uc.setVisible($('gcode-edit'), mode !== 'SLA'); // highlight selected mode menu item diff --git a/src/kiri/app/platform.js b/src/kiri/app/platform.js index a1529fb6..5bd080a4 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'; @@ -171,14 +172,14 @@ function update_size(updateDark = true) { space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.05); space.sky.set({ color: 0, ambient: { intensity: 0.6 } }); - document.body.classList.add('dark'); + document.documentElement.setAttribute('data-theme', 'dark'); } else { space.platform.set({ light: 0.08 }); space.platform.setFont({rulerColor:'#333333'}); space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor); space.platform.opacity(0.2); space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } }); - document.body.classList.remove('dark'); + document.documentElement.setAttribute('data-theme', 'light'); } space.platform.setSize(); } @@ -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); @@ -978,6 +979,7 @@ function load_files(files, group) { isobj = lower.endsWith(".obj"), is3mf = lower.endsWith(".3mf"), issvg = lower.endsWith(".svg"), + isdxf = lower.endsWith(".dxf"), ispng = lower.endsWith(".png"), isjpg = lower.endsWith(".jpg"), iskmz = lower.endsWith(".kmz"), @@ -1066,7 +1068,7 @@ function load_files(files, group) { api.function.parse(data.textDecode('utf-8'), 'gcode'); load_dec(); } else if (issvg) { - loadSVGDialog(opt => { + loadSVGDialog(opt => { group = group || []; let svg = file_load.SVG.parse(data.textDecode('utf-8'), opt); let ind = 0; @@ -1079,6 +1081,19 @@ function load_files(files, group) { } load_dec(); }); + } else if (isdxf) { + loadDXFDialog(opt => { + group = group || []; + let dxf = file_load.DXF.parse(data.textDecode('utf-8'), opt); + let ind = 0; + if (dxf.length === 0) { + api.show.alert(`DXF contains no supported entities`, 10); + } + for (let v of dxf) { + load_verts(group, dxf[ind++], ind ? `${name}-${ind}` : name); + } + load_dec(); + }); } else if (iskmz) api.settings.import_zip(data, true); else if (isset) api.settings.import(data.textDecode('utf-8'), true); @@ -1124,6 +1139,64 @@ function loadSVGDialog(doit) { }); } +/** + * Show dialog to configure DXF import settings. + * Prompts for extrusion depth, arc segment size, and nesting. + * @param {Function} doit - Callback with options: {soup, depth, segmentSize, minSegments} + * @private + */ +function loadDXFDialog(doit) { + const rnd = Date.now().toString(36); + const host = $('mod-any'); + host.innerHTML = [ + `
`, + `

Import DXF

`, + `

`, + ` Extrude a 3D model from a 2D DXF.`, + ` Supports POLYLINE, LWPOLYLINE, LINE, CIRCLE, ARC, and SPLINE entities.`, + `

`, + `
`, + ` `, + ` `, + ` `, + ` `, + ` `, + `
units
z height
arc segment size
minimum arc segments
nest shapes
`, + `
`, + ` `, + ` `, + `
`, + `
` + ].join(''); + + const units = $(`dxf-units-${rnd}`); + const depth = $(`dxf-depth-${rnd}`); + const segmentSize = $(`dxf-seg-${rnd}`); + const minSegments = $(`dxf-min-${rnd}`); + const nest = $(`dxf-nest-${rnd}`); + const okBtn = $(`dxf-convert-ok-${rnd}`); + const cancelBtn = $(`dxf-convert-cancel-${rnd}`); + + okBtn.onclick = () => { + api.modal.hide(); + setTimeout(() => { + doit({ + soup: nest.checked, + depth: Math.max(0.1, parseFloat(depth.value)), + segmentSize: Math.max(0.01, parseFloat(segmentSize.value)), + minSegments: Math.max(3, parseInt(minSegments.value)), + units: units.value + }); + }, 50); + }; + cancelBtn.onclick = () => api.modal.hide(); + depth.onkeypress = (ev) => { + if (ev.key === 'Enter' || ev.charCode === 13) okBtn.click(); + }; + api.modal.show('any'); + setTimeout(() => depth.focus(), 0); +} + /** * Expand platform bed depth to fit widgets (belt mode only). * Finds maximum Y dimension of all widgets and expands bed if needed. diff --git a/src/kiri/app/preferences.js b/src/kiri/app/preferences.js index 8facac53..b505055a 100644 --- a/src/kiri/app/preferences.js +++ b/src/kiri/app/preferences.js @@ -83,7 +83,7 @@ function booleanSave() { control.assembly = ui.assembly.checked; control.autoLayout = ui.autoLayout.checked; control.autoSave = ui.autoSave.checked; - control.dark = ui.dark.checked; + control.dark = api.sdb['kiri-dark'] = ui.dark.checked; control.devel = ui.devel.checked; control.drawer = ui.drawer.checked; control.exportOcto = ui.exportOcto.checked; @@ -109,6 +109,7 @@ function booleanSave() { updateDrawer(); api.event.emit('boolean.update'); space.view.setProjection(control.ortho ? 'orthographic' : 'perspective'); + setDarkLight(control.dark); } function updateDrawer() { @@ -127,3 +128,5 @@ export const preferences = { booleanSave, updateDrawer }; + +api.prefs = preferences; \ No newline at end of file diff --git a/src/kiri/app/selected.js b/src/kiri/app/selected.js index b4421645..e5872451 100644 --- a/src/kiri/app/selected.js +++ b/src/kiri/app/selected.js @@ -6,6 +6,7 @@ import { THREE } from '../../ext/three.js'; import { tool as MeshTool } from '../../mesh/tool.js'; import { encode as objEncode } from '../../load/obj.js'; import { encode as stlEncode } from '../../load/stl.js'; +import { $ } from '../../moto/webui.js'; /** * Array of currently selected widget meshes. diff --git a/src/kiri/app/view-mode.js b/src/kiri/app/view-mode.js index dbb87dc9..a6a0d987 100644 --- a/src/kiri/app/view-mode.js +++ b/src/kiri/app/view-mode.js @@ -42,11 +42,11 @@ function setViewMode(mode) { ['view-arrange','act-slice','act-preview','act-animate'].forEach(el => { $(el).classList.remove('selected') }); - $('render-tools').classList.add('hide'); + // $('render-tools').classList.add('hide'); switch (mode) { case VIEWS.ARRANGE: $('view-arrange').classList.add('selected'); - $('render-tools').classList.remove('hide'); + // $('render-tools').classList.remove('hide'); api.function.clear_progress(); api.client.clear(); STACKS.clear(); diff --git a/src/kiri/app/visuals.js b/src/kiri/app/visuals.js index a5f0af73..8b01604b 100644 --- a/src/kiri/app/visuals.js +++ b/src/kiri/app/visuals.js @@ -315,17 +315,41 @@ function applyVisualState(widget) { * @param {string} [msg] - Optional status message to display */ function setProgress(value = 0, msg) { - value = (value * 100).round(4); - api.ui.progress.width = value+'%'; - if (self.debug) { - // console.log(msg, value.round(2)); - api.ui.prostatus.style.display = 'flex'; - if (msg) { - api.ui.prostatus.innerHTML = msg; - } else { - api.ui.prostatus.innerHTML = ''; - } + const overlay = api.ui.progressOverlay; + const ring = api.ui.progress; + const pct = api.ui.progressPct; + const text = api.ui.prostatus; + + if (!overlay || !ring || !text) { + return; } + + const hasNumeric = typeof value === 'number' && Number.isFinite(value); + const indeterminate = !hasNumeric || (hasNumeric && value < 0); + const isHidden = hasNumeric && value === 0; + + if (isHidden) { + overlay.classList.add('hide'); + ring.classList.remove('indeterminate'); + if (pct) pct.innerText = ''; + text.innerText = ''; + return; + } + + overlay.classList.remove('hide'); + + if (!indeterminate) { + const clamped = Math.max(0, Math.min(1, value)); + ring.classList.remove('indeterminate'); + ring.style.setProperty('--progress-deg', `${(clamped * 360).toFixed(2)}deg`); + if (pct) pct.innerText = `${Math.round(clamped * 100)}%`; + } else { + ring.classList.add('indeterminate'); + ring.style.removeProperty('--progress-deg'); + if (pct) pct.innerText = ''; + } + + text.innerText = msg || ''; } let statsTimer; 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; 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/dev/cam/Makera.Carvera.Air.json b/src/kiri/dev/cam/Makera.Carvera.Air.json new file mode 100644 index 00000000..8474dd18 --- /dev/null +++ b/src/kiri/dev/cam/Makera.Carvera.Air.json @@ -0,0 +1,182 @@ +{ + "mode": "CAM", + "internal": 0, + "bedHeight": 2.5, + "bedWidth": 300, + "bedDepth": 200, + "originCenter": false, + "spindleMax": 13000, + "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}", + "G4 P4000" + ], + "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, diff --git a/src/kiri/mode/cam/app/anim-2d.js b/src/kiri/mode/cam/app/anim-2d.js index 5d3312e5..7614a128 100644 --- a/src/kiri/mode/cam/app/anim-2d.js +++ b/src/kiri/mode/cam/app/anim-2d.js @@ -36,7 +36,7 @@ export function animate_clear(api) { } export function animate(api, delay) { - let alert = api.alerts.show("building animation"); + api.show.busy("building animation"); let settings = api.conf.get(); client.animate_setup(settings, data => { checkMeshCommands(data); @@ -80,7 +80,7 @@ export function animate(api, delay) { button.pause.style.display = 'none'; api.event.emit('animate', 'CAM'); - api.alerts.hide(alert); + api.show.busy(false); space.platform.showGridBelow(false); toggleTrans(0,api.local.getBoolean('cam.anim.trans', true)); toggleModel(0,api.local.getBoolean('cam.anim.model', false)); diff --git a/src/kiri/mode/cam/app/anim-3d.js b/src/kiri/mode/cam/app/anim-3d.js index 8e26a1ac..32bd0e15 100644 --- a/src/kiri/mode/cam/app/anim-3d.js +++ b/src/kiri/mode/cam/app/anim-3d.js @@ -35,7 +35,7 @@ export function animate_clear2(api) { } export function animate2(api, delay) { - let alert = api.alerts.show("building animation"); + api.show.busy("building animation"); let settings = api.conf.get(); dark = settings.controller.dark; manifold = settings.controller.manifold; @@ -92,7 +92,7 @@ export function animate2(api, delay) { button.pause.style.display = 'none'; api.event.emit('animate', 'CAM'); - api.alerts.hide(alert); + api.show.busy(false); }); } diff --git a/src/kiri/mode/cam/app/cl-ops.js b/src/kiri/mode/cam/app/cl-ops.js index 9fe753cf..4dd6ca0f 100644 --- a/src/kiri/mode/cam/app/cl-ops.js +++ b/src/kiri/mode/cam/app/cl-ops.js @@ -485,8 +485,10 @@ export function createPopOps() { rate: 'camTraceSpeed', plunge: 'camTracePlunge', offover: 'camTraceOffOver', + offz: 'camTraceOffZ', dogbone: 'camTraceDogbone', revbone: 'camTraceDogbone', + ignore: 'camTraceIgnore', merge: 'camTraceMerge', ov_topz: 0, ov_botz: 0, @@ -500,9 +502,11 @@ 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 }), - merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }), + 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 }), exp: UC.newExpand("feeds & speeds", { }), @@ -774,6 +778,7 @@ export function createPopOps() { revbones: 'camAreaRevbones', ov_topz: 0, ov_botz: 0, + finish_cut: 0, }).inputs = { mode: UC.newSelect(LANG.mo_menu, { post: opRender }, "opmode"), tr_type: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: isTrace }, "traceoff"), @@ -801,6 +806,7 @@ export function createPopOps() { sr_angle: UC.newInput(LANG.ca_sang_s, { title: LANG.ca_sang_l, convert: toFloat, bound: UC.bound(0, 360), show: isSurfaceLinear }), over: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.001, 100.0), show: () => isClear() || isSurface() }), down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, bound: UC.bound(0, 100.0), units, show: () => isClear() || isTrace() }), + finish_cut: UC.newInput(LANG.ca_fini_s, { title: LANG.ca_fini_l, convert: toFloat, bound: UC.bound(0, 10.0), units, show: () => isClear() }), //todo: needs to check camInnerFirst refine: UC.newInput(LANG.cp_refi_s, { title: LANG.cp_refi_l, convert: toInt, show: isSurface }), sr_alter: UC.newBoolean(LANG.ca_altr_s, undefined, { title: LANG.ca_altr_l, show: isSurfaceLinear }), dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: isTrace }), diff --git a/src/kiri/mode/cam/app/init-menu.js b/src/kiri/mode/cam/app/init-menu.js index 6f59d4bc..9a9ae7d2 100644 --- a/src/kiri/mode/cam/app/init-menu.js +++ b/src/kiri/mode/cam/app/init-menu.js @@ -30,6 +30,7 @@ function zAnchorSave() { api.platform.update_top_z(); } +const hideable = true; const bottom = true; const top = true; @@ -118,7 +119,7 @@ export function menu() { /** Left Side Menu */ - _____: newGroup(LANG.ct_menu, $('cam-tabs'), { modes:CAM, marker:true, driven, separator }), + _____: newGroup(LANG.ct_menu, $('cam-tabs'), { modes:CAM, marker:true, driven, separator, hideable, group:"cam-tabs" }), camTabsWidth: newInput(LANG.ct_wdth_s, {title:LANG.ct_wdth_l, convert:toFloat, bound:bound(0.005,100), units}), camTabsHeight: newInput(LANG.ct_hght_s, {title:LANG.ct_hght_l, convert:toFloat, bound:bound(0.005,100), units}), camTabsDepth: newInput(LANG.ct_dpth_s, {title:LANG.ct_dpth_l, convert:toFloat, bound:bound(0.005,100), units}), @@ -130,7 +131,7 @@ export function menu() { (ui.tabDun = newButton(undefined, onButtonClick, {icon:''})), (ui.tabClr = newButton(undefined, onButtonClick, {icon:''})) ], {class:"ext-buttons f-row"}), - _____: newGroup(LANG.cs_menu, $('cam-stock'), { modes:CAM, driven, separator }), + _____: newGroup(LANG.cs_menu, $('cam-stock'), { modes:CAM, driven, separator, hideable, group:"cam-stock" }), camStockX: newInput(LANG.cs_wdth_s, {title:LANG.cs_wdth_l, convert:toFloat, bound:bound(0,9999), units}), camStockY: newInput(LANG.cs_dpth_s, {title:LANG.cs_dpth_l, convert:toFloat, bound:bound(0,9999), units}), camStockZ: newInput(LANG.cs_hght_s, {title:LANG.cs_hght_l, convert:toFloat, bound:bound(0,9999), units}), @@ -142,7 +143,7 @@ export function menu() { // camStockManual: newRow([ // (ui.stockPlace = newButton('position', onButtonClick, { })), // ], {class:"ext-buttons f-row"}), - _____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator }), + _____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator, hideable, group:"cam-limits" }), camZAnchor: newSelect(LANG.ou_zanc_s, {title: LANG.ou_zanc_l, action:zAnchorSave, show:() => !ui.camStockIndexed.checked}, "zanchor"), camZOffset: newInput(LANG.ou_ztof_s, {title:LANG.ou_ztof_l, convert:toFloat, units}), camZTop: newInput(LANG.ou_ztop_s, {title:LANG.ou_ztop_l, convert:toFloat, units, trigger, selector, top }), @@ -151,7 +152,7 @@ export function menu() { separator: newBlank({ class:"set-sep", driven }), camFastFeed: newInput(LANG.cc_rapd_s, {title:LANG.cc_rapd_l, convert:toFloat, units}), camFastFeedZ: newInput(LANG.cc_rzpd_s, {title:LANG.cc_rzpd_l, convert:toFloat, units}), - _____: newGroup(LANG.ou_menu, $('cam-output'), { modes:CAM, driven, separator, group:"cam-output" }), + _____: newGroup(LANG.ou_menu, $('cam-output'), { modes:CAM, driven, separator, hideable, group:"cam-output" }), camEaseDown: newBoolean(LANG.cr_ease_s, onBooleanClick, {title:LANG.cr_ease_l}), camDepthFirst: newBoolean(LANG.ou_depf_s, onBooleanClick, {title:LANG.ou_depf_l}), camInnerFirst: newBoolean(LANG.ou_inrf_s, onBooleanClick, {title:LANG.ou_inrf_l}), @@ -162,7 +163,7 @@ export function menu() { separator: newBlank({ class:"set-sep", driven }), camEaseAngle: newInput(LANG.ou_eang_s, {title:LANG.ou_eang_l, convert:toFloat, bound:bound(0.1,85), show:() => ui.camEaseDown.checked}), camFullEngage: newInput(LANG.ou_feng_s, {title:LANG.ou_feng_l, convert:toFloat, bound:bound(0.1,1.0)}), - _____: newGroup(LANG.or_menu, $('cam-origin'), { modes:CAM, driven, separator }), + _____: newGroup(LANG.or_menu, $('cam-origin'), { modes:CAM, driven, separator, hideable, group:"cam-origin" }), camOriginTop: newBoolean(LANG.or_topp_s, onBooleanClick, {title:LANG.or_topp_l}), camOriginCenter: newBoolean(LANG.or_cntr_s, onBooleanClick, {title:LANG.or_cntr_l}), separator: newBlank({ class:"set-sep", driven }), @@ -174,7 +175,7 @@ export function menu() { newButton("select", originSelect), newButton("reset", originReset), ], { class: "ext-buttons f-row" }), - _____: newGroup(LANG.op_xprt_s, $('cam-expert'), { group:"cam_expert", modes:CAM, marker: false, driven, separator }), + _____: newGroup(LANG.op_xprt_s, $('cam-expert'), { group:"cam_expert", modes:CAM, marker: false, driven, separator, hideable }), camArcEnabled: newBoolean(LANG.cx_arce_s, onBooleanClick, { title:LANG.cx_arce_l }), camArcTolerance: newInput(LANG.cx_arct_s, {title:LANG.cx_arct_l, convert:toFloat, bound:bound(0,100), units, trigger, show:() => ui.camArcEnabled.checked}), camArcResolution: newInput(LANG.cx_arcr_s, {title:LANG.cx_arcr_l, convert:toFloat, bound:bound(0,180), trigger, show:() => ui.camArcEnabled.checked}), diff --git a/src/kiri/mode/cam/app/init-ui.js b/src/kiri/mode/cam/app/init-ui.js index 2f315981..c284d690 100644 --- a/src/kiri/mode/cam/app/init-ui.js +++ b/src/kiri/mode/cam/app/init-ui.js @@ -339,7 +339,7 @@ export function opRender() { `
`, ``, clock ? '' : - ``, + ``, `
` ]); bind[mark + i] = rec; @@ -421,9 +421,10 @@ export function opRender() { const brect = ev.target.getBoundingClientRect(); const prect = parent.getBoundingClientRect(); const Prect = poprec.div.getBoundingClientRect(); - const tdiff = prect.top - brect.top; - const botoff = innerHeight - (brect.top + Prect.height); - const offpx = -tdiff + (botoff < 0 ? botoff : -Prect.height/3); + const topmv = brect.top - prect.top - Math.min(50, Prect.height/5); + const topnu = prect.top + topmv; + const botof = innerHeight - (topnu + Prect.height); + const offpx = botof < 0 ? topmv + botof : topmv; poprec.div.style.transform = `translateY(${offpx}px)`; poprec.div.onmouseenter = () => { inside = true }; poprec.div.onmouseleave = onLeave; @@ -587,7 +588,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-area.js b/src/kiri/mode/cam/work/op-area.js index 03a4a9fe..7a059813 100644 --- a/src/kiri/mode/cam/work/op-area.js +++ b/src/kiri/mode/cam/work/op-area.js @@ -134,12 +134,15 @@ class OpArea extends CamOp { polys = POLY.union(nupolys, 0.00001, true); } + // filter out invalid polys + polys = polys.filter(p => p && p.length > 2); + // process each area separately let proc = 0; 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, @@ -175,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 }), @@ -188,8 +191,23 @@ class OpArea extends CamOp { let outs = []; let clip = []; let firstOff = -(toolDiam / 2 + (op.leave_xy ?? 0)); - POLY.subtract([ area ], shadow, clip, undefined, undefined, 0); - POLY.offset(clip, [ firstOff, -toolOver ], { + // remove shadow from area + if (op.ignore) { + clip = [ area ]; + } else { + POLY.subtract([ area ], shadow, clip, undefined, undefined, 0); + } + //generate offsets to use + let offsets = [ firstOff ]; + //if we need a finish cut, add it + let finish_cut = op.finish_cut ?? 0; + if (finish_cut != 0) { //todo: this should check for camInnerFirst and warn if it is not true + offsets.push(-finish_cut); + } + //everything else uses the tool stepover + offsets.push(-toolOver); + //actually offset the walls inwards + POLY.offset(clip, offsets, { count: op.walls ? 1 : (op.steps ?? 999), outs, flat: true, z: z - zMov, ...offopt }); // if we see no offsets, re-check the mesh bottom Z then exit @@ -248,14 +266,15 @@ 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(); - 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 @@ -462,7 +481,8 @@ class OpArea extends CamOp { while (areas?.length) { let min = { dist: Infinity, - area: undefined + area: undefined, + point: undefined }; for (let area of areas.filter(p => !p.used)) { @@ -477,16 +497,19 @@ class OpArea extends CamOp { if (find.distance < min.dist) { min.area = area; min.dist = find.distance; + min.point = find.point } } // if we have a next-closest top poly, pocket that if (min.area) { min.area.used = true; + printPoint = min.point; 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/op-contour.js b/src/kiri/mode/cam/work/op-contour.js index e51ce6bd..7bcf7c8f 100644 --- a/src/kiri/mode/cam/work/op-contour.js +++ b/src/kiri/mode/cam/work/op-contour.js @@ -21,7 +21,7 @@ function createFilter(op, origin, axis) { let index = 0; const accept = []; filter = function (slices) { - for (let slice of slices) { + for (let slice of slices.filter(s => s.camLines)) { if (slice_fn && slice_fn(slice, index++)) { accept.push(slice); } else if (box) { 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; 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/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-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/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, diff --git a/src/kiri/mode/cam/work/op-trace.js b/src/kiri/mode/cam/work/op-trace.js index 25ae2065..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, 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, @@ -21,6 +21,7 @@ class OpTrace extends CamOp { down, expand, follow, + ignore, mode: mode === 'clear' ? 'clear' : 'trace', outline, ov_botz, @@ -39,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/src/kiri/mode/cam/work/prepare.js b/src/kiri/mode/cam/work/prepare.js index 04610e6b..288d3b9a 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), @@ -138,6 +138,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { toolType, toolDiam, toolDiamMove, + toolDiamEpsilon, travelBounds, spindle = 0, spindleMax = device.spindleMax, @@ -194,6 +195,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } function setSpindle(speed) { + // console.trace({ setSpindle: speed }); spindle = Math.min(speed, spindleMax); } @@ -211,6 +213,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { toolType = tool.getType(); toolDiam = tool.fluteDiameter(); toolDiamMove = (tool.hasTaper() ? tolerance ?? toolDiam : toolDiam) * 2; + toolDiamEpsilon = toolDiam * 0.01, lastTool = toolID; } feedRate = Math.min(camFastFeed, feed || feedRate || plunge); @@ -387,7 +390,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { /** * when moving between contour endpoints, check if we can * instead route around the bounding area of the contour - * whih we call the coastline. + * which we call the coastline. */ function coastlineMove(point) { let from = toWidgetCoords(printPoint); @@ -395,6 +398,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (!coastline || from.distTo2D(to) < 0.01) { return false; } + let minz = Math.min(from.z, to.z); let start = { dist: 1, poly: 0, pt: from }; let end = { dist: 1, poly: 1, pt: to }; for (let poly of coastline) { @@ -445,7 +449,9 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { } } for (let i=sp, d=0; d < dist; i += dir, d++) { - layerPush(toWorkCoords(points[i % pl]), 1, 0, tool); + let cp = points[i % pl].clone(); + cp.z = Math.max(minz, cp.z); + layerPush(toWorkCoords(cp), 1, 0, tool); } return true; } @@ -567,7 +573,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(); } @@ -590,9 +596,11 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (lastTravelBounds) check.push(...lastTravelBounds); let from = toWidgetCoords(printPoint); let to = toWidgetCoords(point); + let ep = toolDiamEpsilon; for (let poly of check) { - let ints = poly.intersections(from, to); - if (ints.length) { + let ints = poly.intersections(from, to) ?? []; + let far = ints.filter(p => p.distTo2D(to) > ep && p.distTo2D(from) > ep); + if (far.length) { if (debug) console.log({ ints, poly, deltaXY, deltaZ }); upAndOver = "bounds"; break; @@ -636,7 +644,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; } @@ -667,16 +675,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 +700,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 +720,18 @@ export async function prepare_one(widget, settings, print, firstPoint, update) { if (depthFirst) { for (let i=0; i !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.isNested(inside)); + flat = flat.filter(p => p.isInside(inside)); } for (;;) { @@ -736,7 +750,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, outline); + }); + } else { + descend(stack.slice(1), poly, outline); + } } else { return; } @@ -826,20 +846,31 @@ 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 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 }); @@ -950,7 +981,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 }); 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/mode/cam/work/topo3.js b/src/kiri/mode/cam/work/topo3.js index 495c827c..bae90312 100644 --- a/src/kiri/mode/cam/work/topo3.js +++ b/src/kiri/mode/cam/work/topo3.js @@ -51,18 +51,20 @@ export class Topo { stepsY = Math.ceil(boundsY / resolution), widtopo = widget.topo, topoCache = widtopo - && widtopo.resolution === resolution + && widtopo.tolerance === tolerance && widtopo.diameter === toolDiameter ? widtopo : undefined, topo = widget.topo = topoCache || { + axis, data: new Float32Array(new SharedArrayBuffer(stepsX * stepsY * 4)), - stepsX: stepsX, - stepsY: stepsY, - bounds: bounds, + stepsX, + stepsY, + bounds, diameter: toolDiameter, - resolution: resolution, + resolution, + tolerance, profile: toolOffset, - widget: widget, + widget, raster: true, slices: null }, @@ -120,14 +122,15 @@ export class Topo { } let toolData = { positions: toolPos, bounds: toolBounds }; - const vertices = widget.getGeoVertices({ unroll: true, translate: true }); - const wbounds = widget.getBoundingBox(); + let vertices = widget.getGeoVertices({ unroll: true, translate: true }); + let wbounds = widget.getBoundingBox(); if (!inside) { wbounds.expandByVector({ x: toolDiameter/2 + resolution, y: toolDiameter/2 + resolution, z: 0 }); } // swap XY vertices (unswap later after polylines generated) if (contourY) { + vertices = vertices.slice(); for (let i=0; i 0; } } diff --git a/src/kiri/mode/fdm/app/init-menu.js b/src/kiri/mode/fdm/app/init-menu.js index d0dbd6cb..1675037f 100644 --- a/src/kiri/mode/fdm/app/init-menu.js +++ b/src/kiri/mode/fdm/app/init-menu.js @@ -121,12 +121,13 @@ 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 }), + // 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([ (ui.ssmAdd = newButton(undefined, onButtonClick, {icon:''})), 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/src/kiri/mode/fdm/work/export.js b/src/kiri/mode/fdm/work/export.js index 4ba3cd24..05fef2d6 100644 --- a/src/kiri/mode/fdm/work/export.js +++ b/src/kiri/mode/fdm/work/export.js @@ -577,10 +577,12 @@ export function fdm_export(print, online, ondone, ondebug) { lastFanSpeed = fanSpeed; } if (bedTemp !== lastBedTemp) { + // console.log({ bed_temp_change_to: bedTemp, from: lastBedTemp }); append(`M140 S${bedTemp} T0`); lastBedTemp = bedTemp; } if (nozzleTemp !== lastNozzleTemp) { + // console.log({ temp_change_to: nozzleTemp, from: lastNozzleTemp }); if (t0) append(`M104 S${nozzleTemp} T0`); if (t1) append(`M104 S${nozzleTemp} T1`); if (!(t0 || t1)) append(`M104 S${nozzleTemp} T${tool}`); diff --git a/src/kiri/mode/fdm/work/slice.js b/src/kiri/mode/fdm/work/slice.js index 86055429..3756d3f7 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 * @@ -295,6 +318,10 @@ export function sliceOne(settings, widget, onupdate, ondone) { } } + if (isConcurrent) { + minions.setPoints(points); + } + // create Slice objects for specified list of Z heights // zGen() produces the list (or empty for slicer auto-detected) slice(points, { @@ -303,6 +330,7 @@ export function sliceOne(settings, widget, onupdate, ondone) { xray: process.xray, zMin: bounds.min.z, zMax: bounds.max.z - zCut, + bucketMin: minions.concurrent * 5, union: controller.healMesh, indices: process.indices || process.xray, useAssembly, @@ -328,6 +356,9 @@ export function sliceOne(settings, widget, onupdate, ondone) { }) .then(decodeSlices) .then(processSlices) + .then(() => { + minions.setPoints([]); + }) .then(ondone); // z index generator (bottom up) @@ -441,17 +472,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 +494,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); @@ -476,11 +509,32 @@ 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); + 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 }); + } + } + // 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); @@ -496,10 +550,14 @@ export function sliceOne(settings, widget, onupdate, ondone) { } } } + // convert paint points to circles on matching slices 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; @@ -510,31 +568,34 @@ export function sliceOne(settings, widget, onupdate, ondone) { polys.push(newPolygon().centerCircle(point, radius, 10)); } } - slice.shadow = POLY.union(polys, 0, true); - } - } - - // create automatic shadows supports when on manual paint - if (!manual) { - await widget.computeShadowStack(indices, progress => { - trackupdate(progress, 0.05, 0.10, "shadow"); - }, zAngNorm); - - for (let slice of stack) { - slice.shadow = await widget.shadowAt(slice.z, 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; + // convert shadows to trees, when specified + if (process.sliceSupportTree) { + // console.log('TREE OUTPUT'); + } + // 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) { shadow = POLY.offset(shadow, process.sliceSupportExtra); } @@ -542,14 +603,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.up?.clips, - slice.clips, - slice.down?.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; @@ -570,11 +627,47 @@ 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); - 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"); + } + + // 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) { + let clips = [ + slice.clips, + gaps ? slice.up?.clips : undefined, + gaps ? slice.down?.clips : undefined + ].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 +675,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,20 +697,29 @@ 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 + let promises = []; 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; 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"); - 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 +730,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 +741,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 +757,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 +766,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 +793,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 +819,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 +838,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 +893,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 +955,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 +988,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 +995,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) { @@ -1086,6 +1190,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 @@ -1375,19 +1480,36 @@ 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); 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); diff --git a/src/kiri/run/engine.js b/src/kiri/run/engine.js index c8d995cd..512d2b72 100644 --- a/src/kiri/run/engine.js +++ b/src/kiri/run/engine.js @@ -19,13 +19,15 @@ 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 { client.setWorkPath(workURL); client.setPoolPath(poolURL); client.restart(); + client.pool.start(); } catch (error) { console.log({ error }); } @@ -37,6 +39,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) { @@ -49,6 +52,10 @@ class Engine { api.platform.clear(); } + workspace() { + return api.settings.export({ engine: this.settings }); + } + parse(data) { return new Promise((accept, reject) => { try { @@ -63,11 +70,8 @@ class Engine { } setThreading(bool) { - if (bool) { - client.pool.start(); - } else { - client.pool.stop(); - } + console.log('setThreading() deprecated'); + return this; } setListener(listener) { @@ -86,7 +90,15 @@ 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, + }); return this; } @@ -123,17 +135,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; } @@ -158,11 +177,11 @@ 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]); client.rotate(this.settings); + client.slicePre(this.settings, () => {}); client.slice(this.settings, this.widget, msg => { this.listener({ slice: msg }); if (msg.error) { @@ -170,6 +189,7 @@ class Engine { } if (msg.done) { accept(this); + client.slicePost(this.settings, () => {}); } }); }); diff --git a/src/kiri/run/minion.js b/src/kiri/run/minion.js index ab6edb7f..22cf938b 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([]) }); @@ -140,14 +154,11 @@ const funcs = self.minion = { sliceZ(data, seq) { debug('minion.sliceZ', { data, seq }); - let { z, points, options } = data; - let i = 0, p = 0, realp = new Array(points.length / 3); - while (i < points.length) { - realp[p++] = newPoint(points[i++], points[i++], points[i++]).round(3); - } + let { z, options } = data; + let { points } = cache; let state = { zero: [] }; let output = []; - sliceZ(z, realp, { + sliceZ(z, points, { ...options, each(out) { output.push(out) } }).then(() => { @@ -159,9 +170,17 @@ const funcs = self.minion = { }); }, + setPoints(data, seq) { + let { points } = data; + let i = 0, p = 0, realp = new Array(points.length / 3); + while (i < points.length) { + realp[p++] = newPoint(points[i++], points[i++], points[i++]).round(3); + } + cache.points = realp; + }, + putCache(msg) { const { key, data } = msg; - // log({ minion_putCache: key, data }); if (data) { cache[key] = data; } else { diff --git a/src/kiri/run/worker.js b/src/kiri/run/worker.js index fa115dc0..83c6620a 100644 --- a/src/kiri/run/worker.js +++ b/src/kiri/run/worker.js @@ -39,7 +39,7 @@ let drivers = { WJET }, ccvalue = self.navigator ? self.navigator.hardwareConcurrency || 0 : 0, - concurrent = Math.min(4, self.Worker && ccvalue > 3 ? ccvalue - 1 : 0), + concurrent = Math.round(Math.max(4, self.Worker && ccvalue > 3 ? ccvalue * 0.75 : 0)), current = { print: null, snap: null, @@ -101,6 +101,9 @@ function minhandler(msg) { // for concurrent operations const minwork = { + + // core functions + get concurrent() { return concurrent }, @@ -137,6 +140,76 @@ 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); + } + }, + + setPoints(points) { + let i = 0, floatP = new Float32Array(points.length * 3); + for (let p of points) { + floatP[i++] = p.x; + floatP[i++] = p.y; + floatP[i++] = p.z; + } + minwork.broadcast("setPoints", { points: floatP }); + }, + + // 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) { @@ -222,17 +295,9 @@ const minwork = { reject("concurrent slice unavaiable"); } let { each } = options; - // todo use shared array buffer? - let i = 0, floatP = new Float32Array(points.length * 3); - for (let p of points) { - floatP[i++] = p.x; - floatP[i++] = p.y; - floatP[i++] = p.z; - } minwork.queue({ cmd: "sliceZ", z, - points: floatP, options: codec.toCodable(options) }, data => { let recs = codec.decode(data.output); @@ -242,43 +307,9 @@ const minwork = { } } resolve(recs); - }, [ 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"}`); @@ -586,9 +617,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))); diff --git a/src/load/dxf.js b/src/load/dxf.js new file mode 100644 index 00000000..53f21ba8 --- /dev/null +++ b/src/load/dxf.js @@ -0,0 +1,932 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +import { newPolygon } from '../geo/polygon.js'; +import { newPoint } from '../geo/point.js'; +import { polygons } from '../geo/polygons.js'; + +export function parseAsync(text, opt) { + return new Promise((resolve, reject) => { + try { + resolve(parse(text, opt)); + } catch (e) { + reject(e); + } + }); +} + +export function parse(text, opt = { }) { + const justPoly = opt.flat || false; + const fromSoup = opt.soup !== false || justPoly; + const depth = parseFloat(opt.depth || 5); + const segmentSize = parseFloat(opt.segmentSize || 1); // default 1mm segments + const minSegments = parseInt(opt.minSegments || 4); // minimum segments for very small arcs + const objs = []; + const polys = []; + + // Parse DXF file - normalize line endings and split + const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').map(l => l.trim()); + + // Parse header for units (can be overridden by user) + const fileUnits = extractUnits(lines); + // Use file units if "auto" or not specified, otherwise use user's choice + const inputUnits = (!opt.units || opt.units === 'auto') ? fileUnits : opt.units; + const scale = getScaleToMM(inputUnits); // convert to mm (Kiri:Moto's internal unit) + + const entities = extractEntities(lines); + + // Scale all entities to mm BEFORE stitching + scaleEntities(entities, scale); + + // Stitch together open paths that share endpoints (tolerance in mm now) + const tolerance = Math.max(0.001, segmentSize * 0.001); // 0.1% of segment size, min 0.001mm + const stitchedEntities = stitchPaths(entities, tolerance); + + // Convert entities to polygons (no scaling needed, already in mm) + for (let entity of stitchedEntities) { + if (entity.type === 'STITCHED') { + // Convert stitched path parts into a single polyline + const points = []; + for (const part of entity.parts) { + const partPoints = convertEntityToPoints(part, segmentSize, minSegments); + if (partPoints.length > 0) { + if (points.length === 0) { + points.push(...partPoints); + } else { + // Skip first point if it's the same as our last point (avoid duplicates) + points.push(...partPoints.slice(1)); + } + } + } + + if (points.length < 2) continue; + + let poly = newPolygon().addPoints( + points.map(p => newPoint(p.x, p.y, p.z || 0)) + ).clean(); + + if (entity.closed && poly.appearsClosed()) { + poly.points.pop(); + } else if (!entity.closed) { + poly.setOpen(true); + } + + polys.push(poly); + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + if (entity.points.length < 2) { + continue; + } + + let poly = newPolygon().addPoints( + entity.points.map(p => newPoint(p.x, p.y, p.z || 0)) + ).clean(); + + // Check if closed + if (entity.closed && poly.appearsClosed()) { + poly.points.pop(); + } else if (!entity.closed) { + poly.setOpen(true); + } + + polys.push(poly); + } else if (entity.type === 'LINE') { + // Convert line to polyline + let poly = newPolygon().addPoints([ + newPoint(entity.start.x, entity.start.y, entity.start.z || 0), + newPoint(entity.end.x, entity.end.y, entity.end.z || 0) + ]); + poly.setOpen(true); + polys.push(poly); + } else if (entity.type === 'CIRCLE') { + // Convert circle to polygon with points + // Calculate segments based on circumference and desired segment size (already in mm) + const circumference = 2 * Math.PI * entity.radius; + const segments = Math.max(minSegments, Math.ceil(circumference / segmentSize)); + let points = []; + for (let i = 0; i < segments; i++) { + const angle = (i / segments) * Math.PI * 2; + points.push(newPoint( + entity.center.x + Math.cos(angle) * entity.radius, + entity.center.y + Math.sin(angle) * entity.radius, + entity.center.z || 0 + )); + } + let poly = newPolygon().addPoints(points).clean(); + polys.push(poly); + } else if (entity.type === 'ARC') { + // Convert arc to polyline + // DXF arcs always go counterclockwise. Handle angle wrapping. + let startAngle = entity.startAngle; + let endAngle = entity.endAngle; + let angleDiff = endAngle - startAngle; + + // If endAngle < startAngle, arc wraps through 0/360 + if (angleDiff < 0) { + angleDiff += Math.PI * 2; + } + + const arcLength = angleDiff * entity.radius; + const segments = Math.max(minSegments, Math.ceil(arcLength / segmentSize)); + let points = []; + + if (entity.reversed) { + // Sample backwards from end to start + for (let i = 0; i <= segments; i++) { + const angle = endAngle - (i / segments) * angleDiff; + points.push(newPoint( + entity.center.x + Math.cos(angle) * entity.radius, + entity.center.y + Math.sin(angle) * entity.radius, + entity.center.z || 0 + )); + } + } else { + // Sample forward from start to end + for (let i = 0; i <= segments; i++) { + const angle = startAngle + (i / segments) * angleDiff; + points.push(newPoint( + entity.center.x + Math.cos(angle) * entity.radius, + entity.center.y + Math.sin(angle) * entity.radius, + entity.center.z || 0 + )); + } + } + + let poly = newPolygon().addPoints(points); + poly.setOpen(true); + polys.push(poly); + } else if (entity.type === 'SPLINE') { + // Convert NURBS spline to polyline by sampling (already in mm) + if (entity.controlPoints.length < 2) { + continue; + } + + const points = evaluateSpline(entity, segmentSize, minSegments); + if (points.length < 2) { + continue; + } + + let poly = newPolygon().addPoints(points); + if (entity.closed) { + // Remove duplicate end point if closed + if (poly.appearsClosed()) { + poly.points.pop(); + } + } else { + poly.setOpen(true); + } + polys.push(poly); + } + } + + // Nest polygons to identify holes vs outlines + const sub = fromSoup ? polygons.nest(polys) : polys; + const nest = sub.filter(p => { + for (let pc of polys) { + if (pc === p) { + return true; + } else { + return !pc.isEquivalent(p); + } + } + }); + + if (justPoly) { + return nest; + } + + // Extrude polygons to 3D + for (let poly of nest) { + let obj = poly.extrude(depth); + objs.push(obj); + } + + return objs; +} + +function extractEntities(lines) { + const entities = []; + let inEntities = false; + let i = 0; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + // Check if we're in the ENTITIES section + if (code === '0' && value === 'SECTION') { + if (i + 3 < lines.length && lines[i + 2] === '2' && lines[i + 3] === 'ENTITIES') { + inEntities = true; + i += 4; + continue; + } + } + + if (code === '0' && value === 'ENDSEC' && inEntities) { + break; + } + + if (inEntities && code === '0') { + if (value === 'POLYLINE') { + const entity = parsePolyline(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } else if (value === 'LWPOLYLINE') { + const entity = parseLWPolyline(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } else if (value === 'LINE') { + const entity = parseLine(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } else if (value === 'CIRCLE') { + const entity = parseCircle(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } else if (value === 'ARC') { + const entity = parseArc(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } else if (value === 'SPLINE') { + const entity = parseSpline(lines, i); + if (entity) { + entities.push(entity); + i = entity.endIndex; + continue; + } + } + } + + i += 2; + } + + return entities; +} + +function parsePolyline(lines, start) { + let i = start + 2; + let closed = false; + const points = []; + + // Read polyline flags + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '70') { + // Polyline flag: 1 = closed + closed = (parseInt(value) & 1) === 1; + } + + if (code === '0' && value === 'VERTEX') { + const vertex = parseVertex(lines, i); + if (vertex) { + points.push(vertex.point); + i = vertex.endIndex; + continue; + } + } + + if (code === '0' && value === 'SEQEND') { + return { type: 'POLYLINE', points, closed, endIndex: i + 2 }; + } + + i += 2; + } + + return null; +} + +function parseVertex(lines, start) { + let i = start + 2; + const point = { x: 0, y: 0, z: 0 }; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '10') point.x = parseFloat(value); + if (code === '20') point.y = parseFloat(value); + if (code === '30') point.z = parseFloat(value); + + if (code === '0') { + return { point, endIndex: i }; + } + + i += 2; + } + + return { point, endIndex: i }; +} + +function parseLWPolyline(lines, start) { + let i = start + 2; + let closed = false; + const points = []; + let currentPoint = null; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '70') { + closed = (parseInt(value) & 1) === 1; + } + + if (code === '10') { + if (currentPoint) { + points.push(currentPoint); + } + currentPoint = { x: parseFloat(value), y: 0, z: 0 }; + } + + if (code === '20' && currentPoint) { + currentPoint.y = parseFloat(value); + } + + if (code === '0') { + if (currentPoint) { + points.push(currentPoint); + } + return { type: 'LWPOLYLINE', points, closed, endIndex: i }; + } + + i += 2; + } + + if (currentPoint) { + points.push(currentPoint); + } + + return { type: 'LWPOLYLINE', points, closed, endIndex: i }; +} + +function parseLine(lines, start) { + let i = start + 2; + const start_point = { x: 0, y: 0, z: 0 }; + const end_point = { x: 0, y: 0, z: 0 }; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '10') start_point.x = parseFloat(value); + if (code === '20') start_point.y = parseFloat(value); + if (code === '30') start_point.z = parseFloat(value); + if (code === '11') end_point.x = parseFloat(value); + if (code === '21') end_point.y = parseFloat(value); + if (code === '31') end_point.z = parseFloat(value); + + if (code === '0') { + return { type: 'LINE', start: start_point, end: end_point, endIndex: i }; + } + + i += 2; + } + + return { type: 'LINE', start: start_point, end: end_point, endIndex: i }; +} + +function parseCircle(lines, start) { + let i = start + 2; + const center = { x: 0, y: 0, z: 0 }; + let radius = 0; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '10') center.x = parseFloat(value); + if (code === '20') center.y = parseFloat(value); + if (code === '30') center.z = parseFloat(value); + if (code === '40') radius = parseFloat(value); + + if (code === '0') { + return { type: 'CIRCLE', center, radius, endIndex: i }; + } + + i += 2; + } + + return { type: 'CIRCLE', center, radius, endIndex: i }; +} + +function parseArc(lines, start) { + let i = start + 2; + const center = { x: 0, y: 0, z: 0 }; + let radius = 0; + let startAngle = 0; + let endAngle = 0; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '10') center.x = parseFloat(value); + if (code === '20') center.y = parseFloat(value); + if (code === '30') center.z = parseFloat(value); + if (code === '40') radius = parseFloat(value); + if (code === '50') startAngle = parseFloat(value) * Math.PI / 180; // Convert to radians + if (code === '51') endAngle = parseFloat(value) * Math.PI / 180; // Convert to radians + + if (code === '0') { + return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i }; + } + + i += 2; + } + + return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i }; +} + +function parseSpline(lines, start) { + let i = start + 2; + let degree = 3; // default cubic + let closed = false; + const controlPoints = []; + const knots = []; + let numKnots = 0; + let numControlPoints = 0; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '70') { + // Spline flag: bit 0 (1) = closed + closed = (parseInt(value) & 1) === 1; + } + if (code === '71') degree = parseInt(value); + if (code === '72') numKnots = parseInt(value); + if (code === '73') numControlPoints = parseInt(value); + if (code === '40') { + // Knot value + knots.push(parseFloat(value)); + } + if (code === '10') { + // Control point X - start new point + controlPoints.push({ x: parseFloat(value), y: 0, z: 0 }); + } + if (code === '20' && controlPoints.length > 0) { + // Control point Y + controlPoints[controlPoints.length - 1].y = parseFloat(value); + } + if (code === '30' && controlPoints.length > 0) { + // Control point Z + controlPoints[controlPoints.length - 1].z = parseFloat(value); + } + + if (code === '0') { + return { type: 'SPLINE', degree, closed, knots, controlPoints, endIndex: i }; + } + + i += 2; + } + + return { type: 'SPLINE', degree, closed, knots, controlPoints, endIndex: i }; +} + +// Evaluate NURBS B-spline curve to generate sample points (entities already scaled to mm) +function evaluateSpline(entity, segmentSize, minSegments) { + const { degree, controlPoints, knots, closed } = entity; + + if (controlPoints.length < degree + 1 || knots.length === 0) { + // Degenerate spline, just return control points + return controlPoints.map(p => newPoint(p.x, p.y, p.z)); + } + + // Estimate curve length by summing control point distances (rough approximation) + let estimatedLength = 0; + for (let i = 1; i < controlPoints.length; i++) { + const dx = controlPoints[i].x - controlPoints[i-1].x; + const dy = controlPoints[i].y - controlPoints[i-1].y; + estimatedLength += Math.sqrt(dx * dx + dy * dy); + } + + // Calculate number of samples + const numSamples = Math.max(minSegments, Math.ceil(estimatedLength / segmentSize)); + const points = []; + + // Find parameter range (first and last non-repeated knot values) + const knotStart = knots[degree]; + const knotEnd = knots[knots.length - degree - 1]; + + if (knotStart >= knotEnd) { + // Invalid knot vector, return control points + return controlPoints.map(p => newPoint(p.x, p.y, p.z)); + } + + // Sample the curve + for (let i = 0; i <= numSamples; i++) { + const t = knotStart + (i / numSamples) * (knotEnd - knotStart); + const point = evaluateNURBS(t, degree, controlPoints, knots); + points.push(newPoint(point.x, point.y, point.z)); + } + + return points; +} + +// Evaluate a single point on a NURBS curve using De Boor's algorithm +function evaluateNURBS(t, degree, controlPoints, knots) { + const n = controlPoints.length - 1; + + // Clamp t to valid range + t = Math.max(knots[degree], Math.min(knots[n + 1], t)); + + // Find knot span (which segment t falls into) + let span = degree; + while (span <= n && knots[span + 1] <= t) { + span++; + } + if (span > n) span = n; + + // Compute basis functions using Cox-de Boor recursion + const N = []; + for (let i = 0; i <= n; i++) { + N[i] = []; + } + + // Initialize degree 0 basis functions + for (let i = 0; i <= n; i++) { + if (t >= knots[i] && t < knots[i + 1]) { + N[i][0] = 1.0; + } else { + N[i][0] = 0.0; + } + } + // Special case for last knot + if (t === knots[n + 1]) { + N[n][0] = 1.0; + } + + // Compute higher degree basis functions + for (let k = 1; k <= degree; k++) { + for (let i = 0; i <= n; i++) { + let c1 = 0, c2 = 0; + + if (N[i][k - 1] !== 0) { + if (knots[i + k] !== knots[i]) { + c1 = ((t - knots[i]) / (knots[i + k] - knots[i])) * N[i][k - 1]; + } + } + + if (i + 1 <= n && N[i + 1][k - 1] !== 0) { + if (knots[i + k + 1] !== knots[i + 1]) { + c2 = ((knots[i + k + 1] - t) / (knots[i + k + 1] - knots[i + 1])) * N[i + 1][k - 1]; + } + } + + N[i][k] = c1 + c2; + } + } + + // Compute curve point as weighted sum of control points + let x = 0, y = 0, z = 0; + for (let i = 0; i <= n; i++) { + const weight = N[i][degree] || 0; + x += controlPoints[i].x * weight; + y += controlPoints[i].y * weight; + z += controlPoints[i].z * weight; + } + + return { x, y, z }; +} + +// Scale all entity coordinates to millimeters +function scaleEntities(entities, scale) { + if (scale === 1) return; // no scaling needed + + for (let entity of entities) { + if (entity.type === 'LINE') { + entity.start.x *= scale; + entity.start.y *= scale; + entity.start.z = (entity.start.z || 0) * scale; + entity.end.x *= scale; + entity.end.y *= scale; + entity.end.z = (entity.end.z || 0) * scale; + } else if (entity.type === 'CIRCLE') { + entity.center.x *= scale; + entity.center.y *= scale; + entity.center.z = (entity.center.z || 0) * scale; + entity.radius *= scale; + } else if (entity.type === 'ARC') { + entity.center.x *= scale; + entity.center.y *= scale; + entity.center.z = (entity.center.z || 0) * scale; + entity.radius *= scale; + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + for (let point of entity.points) { + point.x *= scale; + point.y *= scale; + point.z = (point.z || 0) * scale; + } + } else if (entity.type === 'SPLINE') { + for (let point of entity.controlPoints) { + point.x *= scale; + point.y *= scale; + point.z = (point.z || 0) * scale; + } + } + } +} + +// Extract units from DXF header +function extractUnits(lines) { + let i = 0; + let inHeader = false; + + while (i < lines.length - 1) { + const code = lines[i]; + const value = lines[i + 1]; + + if (code === '0' && value === 'SECTION') { + if (i + 3 < lines.length && lines[i + 2] === '2' && lines[i + 3] === 'HEADER') { + inHeader = true; + i += 4; + continue; + } + } + + if (code === '0' && value === 'ENDSEC' && inHeader) { + break; + } + + if (inHeader && code === '9' && value === '$INSUNITS') { + // Next line should be 70, followed by the unit code + if (i + 3 < lines.length && lines[i + 2] === '70') { + const unitCode = parseInt(lines[i + 3]); + // DXF INSUNITS codes: 0=unitless, 1=inches, 2=feet, 4=mm, 5=cm, 6=meters + switch (unitCode) { + case 1: return 'inch'; + case 2: return 'foot'; + case 4: return 'mm'; + case 5: return 'cm'; + case 6: return 'meter'; + default: return 'mm'; // default to mm for unitless + } + } + } + + i += 2; + } + + return 'mm'; // default to millimeters +} + +// Get scale factor to convert from input units to millimeters +function getScaleToMM(inputUnits) { + // Scale factors to convert to mm (Kiri:Moto's internal unit) + const toMM = { + 'mm': 1, + 'cm': 10, + 'meter': 1000, + 'inch': 25.4, + 'foot': 304.8 + }; + + return toMM[inputUnits] || 1; +} + +// Stitch together open paths that share endpoints +function stitchPaths(entities, tolerance = 0.01) { + const stitched = []; + const used = new Set(); + + // Helper to check if two points are within tolerance + const pointsMatch = (p1, p2) => { + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + const dz = (p1.z || 0) - (p2.z || 0); + return Math.sqrt(dx * dx + dy * dy + dz * dz) < tolerance; + }; + + // Helper to get endpoints of an entity + const getEndpoints = (entity) => { + if (entity.type === 'LINE') { + return { start: entity.start, end: entity.end }; + } else if (entity.type === 'ARC') { + // Calculate actual arc endpoints + const startX = entity.center.x + Math.cos(entity.startAngle) * entity.radius; + const startY = entity.center.y + Math.sin(entity.startAngle) * entity.radius; + const endX = entity.center.x + Math.cos(entity.endAngle) * entity.radius; + const endY = entity.center.y + Math.sin(entity.endAngle) * entity.radius; + const start = { x: startX, y: startY, z: entity.center.z || 0 }; + const end = { x: endX, y: endY, z: entity.center.z || 0 }; + // If arc is reversed, swap the endpoints + if (entity.reversed) { + return { start: end, end: start }; + } + return { start, end }; + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + if (entity.closed || entity.points.length < 2) return null; + return { + start: entity.points[0], + end: entity.points[entity.points.length - 1] + }; + } + return null; + }; + + // Helper to convert entity to points + const entityToPoints = (entity) => { + if (entity.type === 'LINE') { + return [entity.start, entity.end]; + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + return [...entity.points]; + } + // For ARC and other types, return null (will be converted later in main loop) + return null; + }; + + // First, identify stitchable entities (LINE, ARC, open POLYLINE) + const stitchable = []; + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (entity.type === 'LINE' || entity.type === 'ARC' || + ((entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') && !entity.closed)) { + stitchable.push({ entity, index: i }); + } + } + + // Try to stitch paths together + for (let i = 0; i < stitchable.length; i++) { + if (used.has(i)) continue; + + const { entity, index } = stitchable[i]; + const endpoints = getEndpoints(entity); + if (!endpoints) { + stitched.push(entity); + used.add(i); + continue; + } + + // Start a new path + const path = [entity]; + used.add(i); + let currentEnd = endpoints.end; + let currentStart = endpoints.start; + let foundMatch = true; + + // Keep extending the path + while (foundMatch) { + foundMatch = false; + + for (let j = 0; j < stitchable.length; j++) { + if (used.has(j)) continue; + + const nextEndpoints = getEndpoints(stitchable[j].entity); + if (!nextEndpoints) continue; + + // Check if this entity connects to current end + if (pointsMatch(currentEnd, nextEndpoints.start)) { + path.push(stitchable[j].entity); + currentEnd = nextEndpoints.end; + used.add(j); + foundMatch = true; + break; + } else if (pointsMatch(currentEnd, nextEndpoints.end)) { + // Need to reverse this entity + const reversed = reverseEntity(stitchable[j].entity); + path.push(reversed); + // After reversing, the start becomes the new end + const reversedEndpoints = getEndpoints(reversed); + currentEnd = reversedEndpoints.end; + used.add(j); + foundMatch = true; + break; + } + // Check if this entity connects to current start (prepend) + else if (pointsMatch(currentStart, nextEndpoints.end)) { + path.unshift(stitchable[j].entity); + currentStart = nextEndpoints.start; + used.add(j); + foundMatch = true; + break; + } else if (pointsMatch(currentStart, nextEndpoints.start)) { + // Need to reverse and prepend + const reversed = reverseEntity(stitchable[j].entity); + path.unshift(reversed); + // After reversing, the end becomes the new start + const reversedEndpoints = getEndpoints(reversed); + currentStart = reversedEndpoints.start; + used.add(j); + foundMatch = true; + break; + } + } + } + + // Convert path to a single stitched entity + if (path.length === 1) { + stitched.push(path[0]); + } else { + // Combine into stitched polyline - mark for later conversion + const stitchedEntity = { + type: 'STITCHED', + parts: path, + closed: pointsMatch(currentStart, currentEnd) + }; + stitched.push(stitchedEntity); + } + } + + // Add non-stitchable entities (CIRCLE, SPLINE, closed POLYLINE) + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (entity.type === 'CIRCLE' || entity.type === 'SPLINE' || + ((entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') && entity.closed)) { + stitched.push(entity); + } + } + + return stitched; +} + +// Reverse an entity's direction +function reverseEntity(entity) { + if (entity.type === 'LINE') { + return { + type: 'LINE', + start: entity.end, + end: entity.start + }; + } else if (entity.type === 'ARC') { + // Mark the arc as reversed so it gets sampled in reverse + return { + type: 'ARC', + center: entity.center, + radius: entity.radius, + startAngle: entity.startAngle, + endAngle: entity.endAngle, + reversed: true + }; + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + return { + type: entity.type, + points: [...entity.points].reverse(), + closed: entity.closed + }; + } + return entity; +} + +// Convert entity to array of points (entities already scaled to mm) +function convertEntityToPoints(entity, segmentSize, minSegments) { + if (entity.type === 'LINE') { + return [entity.start, entity.end]; + } else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') { + return [...entity.points]; + } else if (entity.type === 'ARC') { + // DXF arcs always go counterclockwise. Handle angle wrapping. + let startAngle = entity.startAngle; + let endAngle = entity.endAngle; + let angleDiff = endAngle - startAngle; + + // If endAngle < startAngle, arc wraps through 0/360 + if (angleDiff < 0) { + angleDiff += Math.PI * 2; + } + + const arcLength = angleDiff * entity.radius; + const segments = Math.max(minSegments, Math.ceil(arcLength / segmentSize)); + const points = []; + + if (entity.reversed) { + // Sample backwards from end to start + for (let i = 0; i <= segments; i++) { + const angle = endAngle - (i / segments) * angleDiff; + points.push({ + x: entity.center.x + Math.cos(angle) * entity.radius, + y: entity.center.y + Math.sin(angle) * entity.radius, + z: entity.center.z || 0 + }); + } + } else { + // Sample forward from start to end + for (let i = 0; i <= segments; i++) { + const angle = startAngle + (i / segments) * angleDiff; + points.push({ + x: entity.center.x + Math.cos(angle) * entity.radius, + y: entity.center.y + Math.sin(angle) * entity.radius, + z: entity.center.z || 0 + }); + } + } + + return points; + } + return []; +} diff --git a/src/load/file.js b/src/load/file.js index 88962c27..82978395 100644 --- a/src/load/file.js +++ b/src/load/file.js @@ -6,6 +6,7 @@ import { STL } from './stl.js'; import * as OBJ from './obj.js'; import * as TMF from './3mf.js'; import * as SVG from './svg.js'; +import * as DXF from './dxf.js'; import * as GBR from './gbr.js'; import { load as pngLoad } from './png.js'; @@ -36,6 +37,11 @@ const types = { resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } })); }, + dxf(data, file, resolve, reject, opt = {}) { + let out = DXF.parse(data, opt); + resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } })); + }, + png(data, file, resolve, reject, opt = {}) { pngLoad.PNG.parse(data, { ...opt, @@ -100,6 +106,6 @@ function load_file(file, opt) { }); } -Object.assign(load_file, { SVG, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG }); +Object.assign(load_file, { SVG, DXF, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG }); export { types, as_buffer, load_data, load_file, load_file as load }; 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/load/svg.js b/src/load/svg.js index 8024b6f8..f3519afa 100644 --- a/src/load/svg.js +++ b/src/load/svg.js @@ -44,7 +44,7 @@ export function parse(text, opt = { }) { if (points.length < 3) { continue; } - let poly = newPolygon().addPoints(points.map(p => newPoint(p.x, -p.y, 0))); + let poly = newPolygon().addPoints(points.map(p => newPoint(p.x, -p.y, 0))).clean(); if (poly.appearsClosed()) poly.points.pop(); if (type === 'polyline') poly.setOpen(true); poly._svg = { width, miter }; diff --git a/src/load/url.js b/src/load/url.js index 3cb8d711..d4f5ed82 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, DXF } = load_file; const CDH = 'Content-Disposition'; export function load_url(url, options = {}) { @@ -14,7 +12,7 @@ export function load_url(url, options = {}) { let xhr = new XMLHttpRequest(); let file = options.file || options.filename || (((url.split('?')[0]).split('#')[0]).split('/')).pop(); let ext = file.split('.').pop().toLowerCase(); - let deftype = ext === "obj" || ext === 'svg' ? "text" : "arraybuffer"; + let deftype = ext === "obj" || ext === 'svg' || ext === 'dxf' ? "text" : "arraybuffer"; let datatype = options.datatype || deftype; let formdata = options.formdata; @@ -57,6 +55,9 @@ export function load_url(url, options = {}) { case "svg": resolve(SVG.parse(data).map(m => { return {mesh: m.toFloat32(), file} })); break; + case "dxf": + resolve(DXF.parse(data).map(m => { return {mesh: m.toFloat32(), file} })); + break; default: reject(`unknown file type: "${ext}" from ${url}`); break; diff --git a/src/main/kiri.js b/src/main/kiri.js index 8e360c8d..8691ecda 100644 --- a/src/main/kiri.js +++ b/src/main/kiri.js @@ -10,6 +10,7 @@ import { api } from '../kiri/app/api.js'; import { init_lang } from '../kiri/app/init/lang.js'; import { init_input } from '../kiri/app/init/input.js'; import { init_sync } from '../kiri/app/init/sync.js'; +import { surfaces } from '../kiri/app/init/build.js'; let traceload = location.search.indexOf('traceload') > 0; let load = []; @@ -36,6 +37,7 @@ async function checkReady() { { api.client.start(); await init_lang(); + surfaces.build(); await init_input(); await init_sync(); } @@ -44,11 +46,11 @@ async function checkReady() { } load = undefined; api.event.emit('load-done', stats); + api.event.emit('resize'); if (api.electron) { $('install').classList.add('hide'); $('app-quit').classList.remove('hide'); - $('app-name-text').innerText = "More Info"; - $('top-sep').style.display = 'flex'; + [...document.getElementsByClassName('el-app-hide')].forEach(el => el.classList.add('hide')); } else if (bootctrl) { $('install').classList.add('hide'); $('uninstall').classList.remove('hide'); @@ -57,6 +59,7 @@ async function checkReady() { location.reload(); } } else { + [...document.getElementsByClassName('app-hide')].forEach(el => el.classList.add('hide')); $('install').onclick = () => { location.replace('/boot'); } diff --git a/src/main/mesh.js b/src/main/mesh.js index f0cbf138..6f08309d 100644 --- a/src/main/mesh.js +++ b/src/main/mesh.js @@ -17,10 +17,12 @@ import { edges as meshEdges } from '../mesh/edges.js'; import { open as dataOpen } from '../data/index.js'; import { load as fileLoad } from '../load/file.js'; import { THREE } from '../ext/three.js'; +import { createDocumentManager } from '../mesh/document.js'; const version = '1.5.7'; const call = broker.send; -const dbindex = [ "admin", "space" ]; +const dbindex = [ "admin", "documents", "versions" ]; +const DOC_META_KEY = '__doc'; const { Quaternion } = THREE; @@ -28,10 +30,67 @@ function log() { return api.log.emit(...arguments); } +function boot_status(message = 'loading...') { + const curtain = $('curtain'); + if (!curtain) return; + curtain.textContent = String(message || 'loading...'); +} + +function boot_start(message = 'loading...') { + $('app')?.classList?.add('booting'); + boot_status(message); + $d('curtain', 'flex'); +} + +function boot_done() { + $('app')?.classList?.remove('booting'); + $d('curtain', 'none'); +} + +function get_doc_meta(meta = metaCache) { + if (!meta || typeof meta !== 'object') return {}; + return meta[DOC_META_KEY] || {}; +} + +function capture_camera_state() { + return { + place: space.view.save(), + focus: space.view.getFocus() + }; +} + +function apply_camera_state(camera) { + if (!camera) return; + if (camera.place) { + space.view.load(camera.place); + } + if (camera.focus) { + space.view.setFocus(camera.focus); + } +} + +function save_camera_to_document() { + const dmeta = get_doc_meta(metaCache); + metaCache[DOC_META_KEY] = { + ...dmeta, + camera: capture_camera_state() + }; + store_meta(); +} + +let cameraSaveTimer = null; +function schedule_camera_save(delay = 120) { + clearTimeout(cameraSaveTimer); + cameraSaveTimer = setTimeout(() => { + cameraSaveTimer = null; + save_camera_to_document(); + }, delay); +} + // set below. called once the DOM readyState = complete // this is the main() entrypoint called after all dependents load function init() { - let stores = dataOpen('mesh', { stores: dbindex, version: 4 }).init(), + let stores = dataOpen('mesh', { stores: dbindex, version: 5 }).init(), dark = false, ortho = false, zoomrev = true, @@ -39,11 +98,21 @@ function init() { platform = space.platform, db = api.db = { admin: stores.promise('admin'), - space: stores.promise('space') + documents: stores.promise('documents'), + versions: stores.promise('versions') }; + const docman = api.document = createDocumentManager({ + admin: db.admin, + documents: db.documents, + versions: db.versions, + maxRevisions: 200 + }); + db.space = docman.spaceStore; + // initialize the API (to avoid circular dependencies) api.init(); + boot_start('initializing mesh:tool'); // mark init time and use count db.admin.put("init", Date.now()); @@ -70,26 +139,23 @@ function init() { colorX: 0xff7777, colorY: 0x7777ff }, }); platform.onMove(() => { - // save last location and focus - db.admin.put('camera', { - place: space.view.save(), - focus: space.view.getFocus() - }); + // save camera per-document + save_camera_to_document(); }, 100); space.view.setZoom(zoomrev, zoomspd); + // trigger ui building + call.ui_build(); + + // trigger space event binding + call.space_init({ space: space, platform }); + // reload stored space when worker is ready motoClient.on('ready', restore_space); // start worker motoClient.start('../lib/mesh/work.js?' + version); - // trigger space event binding - call.space_init({ space: space, platform }); - - // trigger ui building - call.ui_build(); - // hide url params let wlp = window.location.pathname; let mio = wlp.indexOf('/mesh/'); @@ -102,32 +168,27 @@ function init() { self.electron = navigator.userAgent.includes('Electron'); } -// restore space layout and view from previous session -async function restore_space() { - const db_admin = api.db.admin; +function clear_workspace() { + api.selection.clear(); + for (let sk of api.sketch.list().slice()) { + sk.remove(); + } + for (let grp of api.group.list().slice()) { + grp.remove(); + } +} + +async function restore_workspace_from_state(cached = {}, mcache = {}) { const db_space = api.db.space; - // let mcache = {}; - await db_admin.get("camera") - .then(saved => { - if (saved) { - space.view.load(saved.place); - space.view.setFocus(saved.focus); - } - }); - const mcache = await db_admin.get("meta") || {}; let count = 0; - await db_space.iterate({ map: true }).then(cached => { + await Promise.resolve(cached).then(cached => { const keys = []; const claimed = []; for (let [id, data] of Object.entries(cached)) { - // console.log({ id, data }); keys.push(id); if (count++ === 0) { log(`restoring workspace`); } - // restore object based on type - // group arrays load models they contain - // sketches are loaded by type since they're not grouped if (Array.isArray(data)) { claimed.push(id); let models = data @@ -135,11 +196,11 @@ async function restore_space() { claimed.push(id); return { id, md: cached[id] } }) - .filter(r => r.md) // filter cache misses - .map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id])) + .filter(r => r.md) + .map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id])); if (models.length) { log(`restored ${models.length} model(s)`); - api.group.new(models, id).applyMeta(mcache[id]) + api.group.new(models, id).applyMeta(mcache[id]); } else { log(`removed empty group ${id}`); db_space.remove(id); @@ -155,22 +216,17 @@ async function restore_space() { if (keys.length) { log(`removing ${keys.length} unclaimed meshes`); } - // clear out meshes left in the space db along with their meta-data for (let id of keys) { db_space.remove(id); delete mcache[id]; } - // restore global cache only after objects are restored - // otherwise their setup will corrupt the cache for other restores metaCache = mcache; - store_meta(); + api.document.setMeta(metaCache); }).then(() => { - // restore preferences after models are restored return api.prefs.load().then(() => { let { map } = api.prefs; let { space, mode } = map; api.grid(space.grid); - // restore selected state let selist = space.select || []; let smodel = api.model.list().filter(m => selist.contains(m.id)); let sgroup = api.group.list().filter(m => selist.contains(m.id)); @@ -179,14 +235,38 @@ async function restore_space() { let tgroup = api.group.list().filter(m => tolist.contains(m.id)); let sklist = api.sketch.list().filter(s => selist.contains(s.id)); api.selection.set([...smodel, ...sgroup, ...sklist], [...tmodel, ...tgroup]); - // restore edit mode api.mode[mode](); - // restore dark mode - set_darkmode(map.space.dark); + set_darkmode(); }); - }).finally(() => { + }); +} + +// restore space layout and view from previous session +async function restore_space() { + const db_admin = api.db.admin; + const db_space = api.db.space; + const docman = api.document; + boot_status('loading document'); + const currentDoc = await docman.restoreOrCreate(); + const mcache = docman.getMeta() || {}; + const oldCamera = await db_admin.get("camera"); + const docCamera = get_doc_meta(mcache).camera || oldCamera || null; + boot_status('restoring workspace'); + const cached = await db_space.iterate({ map: true }) || {}; + docman.pause(); + try { + await restore_workspace_from_state(cached, mcache); + } finally { + docman.resume(); + } + boot_status('restoring view'); + apply_camera_state(docCamera); + space.update(); + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + boot_status('finalizing'); + Promise.resolve().finally(() => { // hide loading curtain - $d('curtain','none'); + boot_done(); // restore handles visibility handles.setEnabled(api.prefs.map.space.bounds ?? false); // restore script if was showing @@ -196,15 +276,103 @@ async function restore_space() { if (api.prefs.map.info.welcome !== false) { api.welcome(version); } + api.file.set_doc_name(currentDoc?.name || 'Untitled'); broker.publish("app_ready"); }); } +async function document_new(opt = {}) { + const docman = api.document; + await docman.flush(); + await docman.commit('document.autosave', 'document.autosave'); + docman.pause(); + try { + clear_workspace(); + space.view.home(); + metaCache = { + [DOC_META_KEY]: { + camera: capture_camera_state() + } + }; + await docman.create(opt.name || 'Untitled'); + docman.setMeta(metaCache); + } finally { + docman.resume(); + } + await docman.commit('document.new', 'document.new'); + api.file.set_doc_name(docman.current?.name || 'Untitled'); +} + +async function document_open(opt = {}) { + const docman = api.document; + const id = String(opt?.id || ''); + if (!id) return; + await docman.flush(); + await docman.open(id, { autosave: opt.autosave !== false }); + const cached = docman.getSpace() || {}; + const mcache = docman.getMeta() || {}; + docman.pause(); + try { + clear_workspace(); + await restore_workspace_from_state(cached, mcache); + } finally { + docman.resume(); + } + apply_camera_state(get_doc_meta(mcache).camera); + api.file.set_doc_name(docman.current?.name || 'Untitled'); +} + // add space event bindings function space_init(data) { let platcolor = 0x00ff00; let { space, platform } = data; let { selection } = api; + + function selection_or_visible_entities() { + const selected = api.selection.list(true); + if (selected?.length) return selected; + return [ + ...api.group.list().filter(g => g.visible()), + ...api.sketch.list().filter(s => s.visible()) + ]; + } + + function fit_visible() { + const entities = selection_or_visible_entities(); + const objects = entities.map(e => e?.object).filter(o => o); + return space.view.fit(undefined, { + padding: 1, + visibleOnly: true, + objects: objects.length ? objects : undefined + }); + } + + function focus_visible() { + const entities = selection_or_visible_entities(); + if (entities.length) { + return api.focus(entities); + } + return api.focus([ + ...api.group.list(), + ...api.sketch.list() + ]); + } + + function norm_code(evt) { + if (evt?.code) return evt.code; + const key = evt?.key; + if (!key) return ''; + if (key === ' ') return 'Space'; + if (key === 'Spacebar') return 'Space'; + if (key === 'Escape') return 'Escape'; + if (key.length === 1) { + const up = key.toUpperCase(); + if (up >= 'A' && up <= 'Z') return `Key${up}`; + if (up >= '0' && up <= '9') return `Digit${up}`; + } + return key; + } + // add file drop handler space.event.addHandlers(self, [ 'drop', (evt) => { @@ -222,6 +390,10 @@ function space_init(data) { 'dragleave', evt => { platform.set({ opacity: 0, color: platcolor }); }, + // camera interactions (orbit/pan/dolly) are not guaranteed to trigger platform.onMove + 'wheel', () => schedule_camera_save(), + 'mouseup', () => schedule_camera_save(), + 'touchend', () => schedule_camera_save(), 'keypress', evt => { if (api.modal.showing) { return; @@ -229,7 +401,8 @@ function space_init(data) { if (evt.key === '?') { return api.welcome(version); } - let { shiftKey, metaKey, ctrlKey, code, target } = evt; + let { shiftKey, metaKey, ctrlKey, target } = evt; + let code = norm_code(evt); if (target.nodeName === 'TEXTAREA') { api.script.changed(); return; @@ -250,7 +423,11 @@ function space_init(data) { case 'KeyB': return selection.boundsBox({toggle:true}); case 'KeyC': - return selection.centerXY().focus(); + if (shiftKey) { + return selection.floor(); + } else { + return selection.centerXY().focus(); + } case 'KeyD': return shiftKey && api.tool.duplicate(); case 'KeyE': @@ -259,14 +436,14 @@ function space_init(data) { return api.sketch.extrude(); } return; - case 'KeyF': - return shiftKey ? selection.focus() : selection.floor().focus(); case 'KeyG': return shiftKey ? (api.mode.is([ api.modes.sketch ]) ? api.sketch.arrange.group() : api.tool.regroup()) : api.grid(); case 'KeyH': - return shiftKey ? selection.hide() : space.view.home(); + if (shiftKey) return selection.hide(); + schedule_camera_save(180); + return space.view.home(); case 'KeyI': return api.file.import(); case 'KeyL': @@ -283,7 +460,9 @@ function space_init(data) { if (!api.mode.is([ api.modes.object ])) return; return shiftKey ? selection.visible({toggle:true}) : meshSplit.start(); case 'KeyT': - return shiftKey ? api.tool.triangulate() : space.view.top(); + if (shiftKey) return api.tool.triangulate(); + schedule_camera_save(180); + return space.view.top(); case 'KeyU': return shiftKey && api.tool.union(); case 'KeyV': @@ -295,7 +474,9 @@ function space_init(data) { } }, 'keydown', evt => { - let { shiftKey, metaKey, ctrlKey, code, target } = evt; + let { shiftKey, metaKey, ctrlKey, target } = evt; + let code = norm_code(evt); + const key = evt?.key; if (target.nodeName === 'TEXTAREA') { if (code === 'Tab') { estop(evt); @@ -320,14 +501,36 @@ function space_init(data) { delete keyOnce[code]; return once(evt); } - let rv = (Math.PI / 12); if (api.modal.showing) { if (code === 'Escape') { api.modal.cancel(); } return; } - let rot, floor = api.prefs.map.space.floor !== false; + const isFit = code === 'KeyF' || + key === 'f' || + key === 'F'; + if (isFit && !(metaKey || ctrlKey)) { + estop(evt); + const rv = shiftKey ? focus_visible() : fit_visible(); + schedule_camera_save(220); + return rv; + } + const isSpace = code === 'Space' || + code === 'Spacebar' || + key === ' ' || + key === 'Spacebar'; + if (isSpace) { + if (selection.clear()) { + meshEdges.clear(); + meshSplit.end(); + } + estop(evt); + return; + } + let rv = (Math.PI / 12); + let rot; + let floor = api.prefs.map.space.floor !== false; switch (code) { case 'KeyA': estop(evt); @@ -353,7 +556,9 @@ function space_init(data) { if (metaKey || ctrlKey) { return shiftKey ? api.history.redo() : api.history.undo(); } else { - return space.view.reset(); + space.view.reset(); + schedule_camera_save(220); + return; } case 'Escape': if (selection.clear()) { @@ -664,7 +869,7 @@ function key_once_cancel(code) { } function store_meta() { - api.db.admin.put("meta", metaCache); + api.document?.setMeta?.(metaCache); } function update_meta(id, data) { @@ -694,28 +899,19 @@ function object_destroy(id) { function set_darkmode(dark) { let { prefs, model } = api; let { sky, platform } = space; - prefs.map.space.dark = dark; - if (dark) { - materials.wireframe.color.set(0xaaaaaa); - materials.wireline.color.set(0xaaaaaa); - $('app').classList.add('dark'); - } else { - materials.wireframe.color.set(0,0,0); - materials.wireline.color.set(0,0,0); - $('app').classList.remove('dark'); - } + dark = true; + prefs.map.space.dark = true; + materials.wireframe.color.set(0xaaaaaa); + materials.wireline.color.set(0xaaaaaa); sky.set({ - color: dark ? 0 : 0xffffff, - ambient: { intensity: dark ? 0.55 : 1.1 } + color: 0, + ambient: { intensity: 0.55 } }); platform.set({ - light: dark ? 0.08 : 0.08, - grid: dark ? { + light: 0.08, + grid: { colorMajor: 0x666666, colorMinor: 0x333333, - } : { - colorMajor: 0xcccccc, - colorMinor: 0xeeeeee, }, }); api.updateFog(); @@ -741,11 +937,8 @@ function set_normals_length(length) { function set_normals_color(color) { let { prefs, model } = api; let { map } = prefs; - if (map.space.dark) { - map.normals.color_dark = color || 0; - } else { - map.normals.color_lite = color || 0; - } + map.normals.color_dark = color || 0; + map.normals.color_lite = color || 0; prefs.save(); // Update existing normals for (let m of model.list()) { @@ -809,7 +1002,9 @@ broker.listeners({ set_surface_radius, set_wireframe_opacity, set_wireframe_fog, - set_snap_value + set_snap_value, + document_new, + document_open }); init(); @@ -837,5 +1032,7 @@ export { set_surface_radius, set_wireframe_opacity, set_wireframe_fog, - set_snap_value + set_snap_value, + document_new, + document_open }; diff --git a/src/main/void.js b/src/main/void.js new file mode 100644 index 00000000..2f9f7366 --- /dev/null +++ b/src/main/void.js @@ -0,0 +1,329 @@ +/** 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(); + }; + + const onResize = () => { + applyWidth(left.getBoundingClientRect().width); + } + + window.addEventListener('resize', onResize); +} + +// 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(); + + const { THREE } = window; + + // Show overlay + overlay.show(); + + // Add origin + 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(); + + // update canvas based on left panel size + space.event.onResize(); + + // 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/mesh/api.js b/src/mesh/api.js index de17b7ac..d885e27b 100644 --- a/src/mesh/api.js +++ b/src/mesh/api.js @@ -972,6 +972,93 @@ let add = { }; let file = { + set_doc_name(name = 'Untitled') { + const label = String(name || 'Untitled').trim() || 'Untitled'; + document.title = `${label} | Mesh:Tool`; + const el = $('top-doc-name'); + if (el) { + el.textContent = label; + el.title = `click to rename (${label})`; + } + }, + + async new() { + await api.document?.flush?.(); + await call.document_new({ name: 'Untitled' }); + api.file.set_doc_name('Untitled'); + }, + + async open() { + const docs = await (api.document?.list?.() || Promise.resolve([])); + const rows = docs.map(doc => h.div({ class: "doc-open-row", onclick: async function() { + api.modal.hide(); + await call.document_open({ id: doc.id }); + api.file.set_doc_name(doc.name || 'Untitled'); + } }, [ + h.div({ class: "doc-open-name", _: `${doc.name || 'Untitled'}` }), + h.button({ _: "rename", onclick(evt) { + evt?.stopPropagation?.(); + api.modal.hide(); + setTimeout(() => api.file.rename(doc), 0); + } }), + h.button({ class: "doc-open-del", _: "×", title: "delete", onclick: async (evt) => { + evt?.stopPropagation?.(); + await api.file.delete(doc); + } }) + ])); + api.modal.dialog({ + title: "open document", + body: [ h.div({ class: "doc-open-list" }, [ + ...rows, + rows.length ? undefined : h.div({ class: "doc-open-empty", _: "no documents" }), + h.hr(), + h.button({ class: "doc-open-new", _: "new", onclick() { + api.modal.hide(); + api.file.new(); + } }) + ].filter(v => v)) ] + }); + }, + + async rename(doc = api.document?.current) { + const current = doc || api.document?.current; + if (!current?.id) return; + if (api.modal?.showing) { + api.modal.hide(); + await Promise.resolve(); + } + + let onclick = onkeydown = (ev) => { + if (!tempedit || (ev.code && ev.code !== 'Enter')) { + return; + } + api.document.rename(current.id, tempedit.value).then(rec => { + if (rec?.id && api.document?.current?.id === rec.id) { + api.file.set_doc_name(rec.name || 'Untitled'); + } + }).finally(() => api.modal.hide()); + }; + + let { tempedit } = api.modal.show(`rename document`, h.div({ class: "rename"}, [ + h.input({ id: "tempedit", value: current.name || 'Untitled', onkeydown }), + h.button({ _: 'ok', onclick }) + ])); + tempedit.setSelectionRange(0,1000); + tempedit.focus(); + }, + + async delete(doc = api.document?.current) { + const current = doc || api.document?.current; + if (!current?.id) return; + const result = await api.document?.delete?.(current.id); + if (result?.switched && result?.current?.id) { + await call.document_open({ id: result.current.id, autosave: false }); + api.file.set_doc_name(result.current.name || 'Untitled'); + } + api.modal.hide(); + api.file.open(); + }, + import() { // binding created in mesh.build $('import').click(); @@ -1348,7 +1435,7 @@ const mode = { $(`mode-${key}`).classList.remove('selected'); } $(`mode-${mode}`).classList.add('selected'); - $('mode-label').innerText = mode; + $('top-mode-label').innerText = mode; api.mode.check(); meshEdges?.end(); if (mode === 'sketch') { @@ -1572,10 +1659,30 @@ const api = { history: { undo() { - history.undo(); + if (api.document?.undo) { + api.document.undo().then(changed => { + if (changed) { + call.document_open({ id: api.document.current?.id, autosave: false }); + } else { + history.undo(); + } + }); + } else { + history.undo(); + } }, redo() { - history.redo(); + if (api.document?.redo) { + api.document.redo().then(changed => { + if (changed) { + call.document_open({ id: api.document.current?.id, autosave: false }); + } else { + history.redo(); + } + }); + } else { + history.redo(); + } } }, @@ -1701,6 +1808,8 @@ const api = { sketch, + space: motoSpace, + tool, isDebug: self.debug === true diff --git a/src/mesh/build.js b/src/mesh/build.js index 5413222a..2c1ab8a8 100644 --- a/src/mesh/build.js +++ b/src/mesh/build.js @@ -19,7 +19,10 @@ let deg = Math.PI / 180; let und = undefined; broker.listeners({ - ui_build + ui_build, + app_ready() { + api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled'); + } }); let spin_timer; @@ -240,14 +243,10 @@ api.welcome = function(version = "unknown") { api.settings = function() { const { prefs } = api; const { surface, normals, space, sketch, wireframe } = prefs.map; - const { dark } = space; + const dark = true; const set1 = div([ - label('dark mode'), - input({ type: "checkbox", - onchange: ev => call.set_darkmode(ev.target.checked), - [ dark ? 'checked' : 'unchecked' ] : 1 - }), + label({ class: "header", _: 'auto'}), label('auto floor'), input({ type: "checkbox", onchange: ev => prefs.save( space.floor = !space.floor ), @@ -391,20 +390,27 @@ function ui_build() { // top left drop menus bind($('top-left'), [ + div({ _: 'Mesh:Tool', class: "title" }), + div({ class: "menubar-separator" }), div({ class: "menu" }, [ div('File'), div({ class: "menu-items" }, [ input({ - id: "import", type: "file", class: ["hide"], multiple: true, accept:".stl,.obj", + id: "import", type: "file", class: ["hide"], multiple: true, accept:".stl,.obj,.svg,.png", onchange(evt) { broker.send.load_files(evt.target.files) } }), + menu_item('New', file.new), + menu_item('Open', file.open), + hr(), menu_item('Import', file.import, 'I'), menu_item('Export', file.export, 'X'), hr(), menu_item('Slicer', api.kirimoto), menu_item('Script', api.script.toggle), hr(), - menu_item('Close', window.close), + menu_item('Preferences', api.settings, 'Q'), + hr(), + menu_item('Close', () => window.close() || api.kirimoto()), ]) ]), div({ class: "menu sketch-on" }, [ @@ -467,7 +473,6 @@ function ui_build() { menu_item('Face', mode.face, '5', 'mode-face'), menu_item('Edge', mode.edge, '6', 'mode-edge'), ]), - div({ id: "mode-label" }) ]), div({ class: "menu sketch-on" }, [ div('Items'), @@ -510,7 +515,7 @@ function ui_build() { div({ class: "menu sketch-off" }, [ div('Faces'), div({ class: "menu-items" }, [ - menu_item('Flip Normals', tool.invert, ['bi-shift','I']), + menu_item('Flip Normals', tool.invert, ['bi-shift','N']), menu_item('Triangulate', tool.triangulate, ['bi-shift','T']), menu_item('To Sketch', tool.toSketch), hr(), @@ -535,6 +540,7 @@ function ui_build() { ]) ]), div({ class: "menu" }, [ + // div({ class: "fas fa-question" }), div('Help'), div({ class: "menu-items" }, [ menu_item('About', () => { api.welcome(version) }), @@ -546,15 +552,15 @@ function ui_build() { menu_item('Versions', api.version), ]) ]), + div({ class: "menubar-separator" }), + div({ id: "top-mode-label" }), ]); // add help buttons bind($('top-right'), [ - div({ id: "top-settings", onclick: api.settings }, [ - div({ class: "fas fa-gear" }), - div('Settings') - ]), + div({ id: "top-doc-name", onclick: () => api.file.rename(), _: 'Untitled' }), ]); + api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled'); // modal dialog and page blocker bind($('modal_page'), [ @@ -600,32 +606,55 @@ function ui_build() { return div({ onclick: fn, class: "tool" }, [ bicon(icon), div([ label(help) ]) ]); } + function toolbar_separator() { + return div({ class: "toolbar-separator" }); + } + // bind sketch chiclets bind(sketchtools, div([ tool_item('bi-plus', 'New Sketch', add.sketch), + toolbar_separator(), tool_item('bi-circle', 'Add Circle', api.add.circle), + toolbar_separator(), tool_item('bi-square', 'Add Rectangle', api.add.rectangle), + toolbar_separator(), tool_item('bi-symmetry-vertical', 'Flip Horizontal', api.sketch.arrange.fliph), + toolbar_separator(), tool_item('bi-symmetry-horizontal', 'Flip Vertical', api.sketch.arrange.flipv), + toolbar_separator(), tool_item('bi-arrow-clockwise', 'Rotate', api.sketch.arrange.rotate), + toolbar_separator(), tool_item('bi-union', 'Union', sketch.boolean.union), + toolbar_separator(), tool_item('bi-intersect', 'Intersect', sketch.boolean.intersect), + toolbar_separator(), tool_item('bi-exclude', 'Difference', sketch.boolean.difference), + toolbar_separator(), tool_item('bi-pip', 'Nest', sketch.boolean.nest), + toolbar_separator(), tool_item('bi-layers', 'Flatten', sketch.boolean.flatten), + toolbar_separator(), tool_item('bi-cookie', 'Even Odd', sketch.boolean.evenodd), + toolbar_separator(), tool_item('bi-arrow-bar-up', 'Extrude', () => sketch.extrude()), ])); // bind object chiclets bind(objecttools, div([ tool_item('bi-pencil', 'New Sketch', add.sketch), + toolbar_separator(), tool_item('bi-box', 'New Cube', add.cube), + toolbar_separator(), tool_item('bi-database', 'New Cylinder', add.cylinder), + toolbar_separator(), tool_item('bi-gear', 'New Gear', add.gear), + toolbar_separator(), tool_item('bi-union', 'Union', tool.union), + toolbar_separator(), tool_item('bi-subtract', 'Subtract', tool.subtract), + toolbar_separator(), tool_item('bi-intersect', 'Intersect', tool.intersect), + toolbar_separator(), tool_item('bi-exclude', 'Difference', tool.difference), ])); diff --git a/src/mesh/document.js b/src/mesh/document.js new file mode 100644 index 00000000..6772f6dd --- /dev/null +++ b/src/mesh/document.js @@ -0,0 +1,316 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +function uid() { + 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 clone(data) { + if (data === undefined || data === null) return data; + if (typeof structuredClone === 'function') { + return structuredClone(data); + } + return JSON.parse(JSON.stringify(data)); +} + +function title(name = '') { + const clean = String(name || '').trim(); + return clean || 'Untitled'; +} + +function revKey(docId, revId) { + return `${docId}:${revId}`; +} + +export function createDocumentManager({ admin, documents, versions, maxRevisions = 200 }) { + const state = { + admin, + documents, + versions, + maxRevisions, + doc: null, + space: {}, + meta: {}, + pauseDepth: 0, + commitTimer: null, + commitDelay: 350 + }; + + function paused() { + return state.pauseDepth > 0; + } + + async function saveDoc() { + if (!state.doc) return; + await state.documents.put(state.doc.id, clone(state.doc)); + await state.admin.put('current_document_id', state.doc.id); + } + + async function loadSnapshot(doc, revId = null) { + if (!doc) { + state.space = {}; + state.meta = {}; + return null; + } + const rid = revId || doc.cursor_rev || doc.head_rev || null; + if (!rid) { + state.space = {}; + state.meta = {}; + return null; + } + const rec = await state.versions.get(revKey(doc.id, rid)); + const snap = rec?.snapshot || {}; + state.space = clone(snap.space || {}); + state.meta = clone(snap.meta || {}); + return rec || null; + } + + async function maybePruneRevisions() { + const order = Array.isArray(state.doc?.rev_order) ? state.doc.rev_order : []; + const over = order.length - state.maxRevisions; + if (over <= 0) return; + const purge = order.splice(0, over); + for (const rid of purge) { + await state.versions.remove(revKey(state.doc.id, rid)); + } + if (state.doc.cursor_rev && !order.includes(state.doc.cursor_rev)) { + state.doc.cursor_rev = order[0] || null; + } + if (state.doc.head_rev && !order.includes(state.doc.head_rev)) { + state.doc.head_rev = order[order.length - 1] || null; + } + } + + async function commit(op_type = 'autosave', label = 'autosave') { + if (!state.doc || paused()) return null; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order.slice() : []; + const cursor = state.doc.cursor_rev || null; + const cursorIndex = cursor ? order.indexOf(cursor) : -1; + if (cursorIndex >= 0 && cursorIndex < order.length - 1) { + const remove = order.slice(cursorIndex + 1); + for (const rid of remove) { + await state.versions.remove(revKey(state.doc.id, rid)); + } + order.length = cursorIndex + 1; + } + const parent = order.length ? order[order.length - 1] : null; + const rid = uid(); + await state.versions.put(revKey(state.doc.id, rid), { + doc_id: state.doc.id, + rev_id: rid, + parent_rev: parent, + created_at: Date.now(), + op_type, + label, + snapshot: { + space: clone(state.space), + meta: clone(state.meta) + } + }); + order.push(rid); + state.doc.rev_order = order; + state.doc.head_rev = rid; + state.doc.cursor_rev = rid; + state.doc.updated_at = Date.now(); + await maybePruneRevisions(); + await saveDoc(); + return rid; + } + + function scheduleCommit(op_type = 'autosave', label = 'autosave') { + if (!state.doc || paused()) return; + clearTimeout(state.commitTimer); + state.commitTimer = setTimeout(() => { + state.commitTimer = null; + commit(op_type, label).catch(error => console.trace(error)); + }, state.commitDelay); + } + + async function createDoc(name = 'Untitled') { + const id = uid(); + const now = Date.now(); + state.doc = { + id, + name: title(name), + created_at: now, + updated_at: now, + head_rev: null, + cursor_rev: null, + rev_order: [] + }; + state.space = {}; + state.meta = {}; + await commit('document.create', 'document.create'); + return clone(state.doc); + } + + async function restoreOrCreate() { + let docId = await state.admin.get('current_document_id'); + let doc = docId ? await state.documents.get(docId) : null; + if (!doc) { + const listed = await state.documents.iterate({ map: true }) || {}; + const docs = Object.values(listed); + if (docs.length) { + docs.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + doc = docs[0]; + } + } + if (!doc) { + return createDoc('Untitled'); + } + state.doc = clone(doc); + await loadSnapshot(state.doc); + await saveDoc(); + return clone(state.doc); + } + + async function open(docId, { autosave = true } = {}) { + if (autosave) { + await flush(); + await commit('document.autosave', 'document.autosave'); + } + const next = await state.documents.get(docId); + if (!next) throw new Error(`document ${docId} missing`); + state.doc = clone(next); + await loadSnapshot(state.doc); + await saveDoc(); + return clone(state.doc); + } + + async function rename(docId, name) { + const doc = await state.documents.get(docId); + if (!doc) return null; + doc.name = title(name); + doc.updated_at = Date.now(); + await state.documents.put(doc.id, doc); + if (state.doc?.id === doc.id) { + state.doc = clone(doc); + await state.admin.put('current_document_id', doc.id); + } + return clone(doc); + } + + async function list() { + const map = await state.documents.iterate({ map: true }) || {}; + return Object.values(map) + .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + } + + async function remove(docId) { + const id = String(docId || ''); + if (!id) return { deleted: false, switched: false, current: clone(state.doc) }; + await flush(); + const doc = await state.documents.get(id); + if (!doc) return { deleted: false, switched: false, current: clone(state.doc) }; + const revs = Array.isArray(doc.rev_order) ? doc.rev_order : []; + for (const rid of revs) { + await state.versions.remove(revKey(id, rid)); + } + await state.documents.remove(id); + if (state.doc?.id !== id) { + return { deleted: true, switched: false, current: clone(state.doc) }; + } + const map = await state.documents.iterate({ map: true }) || {}; + const docs = Object.values(map).sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0)); + if (!docs.length) { + await createDoc('Untitled'); + return { deleted: true, switched: true, current: clone(state.doc) }; + } + state.doc = clone(docs[0]); + await loadSnapshot(state.doc); + await saveDoc(); + return { deleted: true, switched: true, current: clone(state.doc) }; + } + + async function undo() { + await flush(); + if (!state.doc) return false; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : []; + if (order.length < 2) return false; + const idx = order.indexOf(state.doc.cursor_rev); + if (idx <= 0) return false; + state.doc.cursor_rev = order[idx - 1]; + state.doc.updated_at = Date.now(); + await loadSnapshot(state.doc, state.doc.cursor_rev); + await saveDoc(); + return true; + } + + async function redo() { + await flush(); + if (!state.doc) return false; + const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : []; + const idx = order.indexOf(state.doc.cursor_rev); + if (idx < 0 || idx >= order.length - 1) return false; + state.doc.cursor_rev = order[idx + 1]; + state.doc.updated_at = Date.now(); + await loadSnapshot(state.doc, state.doc.cursor_rev); + await saveDoc(); + return true; + } + + async function flush() { + if (state.commitTimer) { + clearTimeout(state.commitTimer); + state.commitTimer = null; + await commit('autosave.flush', 'autosave.flush'); + } + } + + const spaceStore = { + async put(key, value) { + state.space[String(key)] = clone(value); + scheduleCommit('space.put', `space.put:${key}`); + return value; + }, + async remove(key) { + delete state.space[String(key)]; + scheduleCommit('space.remove', `space.remove:${key}`); + return true; + }, + async get(key) { + return clone(state.space[String(key)]); + }, + async iterate(opt = {}) { + if (opt?.map) { + return clone(state.space); + } + return Object.entries(state.space || {}); + } + }; + + return { + spaceStore, + pause() { + state.pauseDepth++; + }, + resume() { + state.pauseDepth = Math.max(0, state.pauseDepth - 1); + }, + setMeta(meta = {}) { + state.meta = clone(meta || {}); + scheduleCommit('meta.set', 'meta.set'); + }, + getMeta() { + return clone(state.meta || {}); + }, + getSpace() { + return clone(state.space || {}); + }, + get current() { + return clone(state.doc); + }, + restoreOrCreate, + create: createDoc, + open, + rename, + delete: remove, + list, + commit, + flush, + undo, + redo + }; +} 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); } diff --git a/src/moto/license.js b/src/moto/license.js index d307e578..e4944e0c 100644 --- a/src/moto/license.js +++ b/src/moto/license.js @@ -3,9 +3,9 @@ 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.7.0" }; -export const beta = 4600; +export const beta = 0; export const license = terms; export const version = terms.VERSION; diff --git a/src/moto/opfs.js b/src/moto/opfs.js new file mode 100644 index 00000000..e107ca48 --- /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 +}; diff --git a/src/moto/orbit.js b/src/moto/orbit.js index 4e7204e8..a060f7b6 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(), @@ -227,15 +235,33 @@ class Orbit extends EventDispatcher { this.setPosition = function(set) { thetaSet = firstValue([set.left, set.theta, thetaSet]); phiSet = firstValue([set.up, set.phi, phiSet]); - if (set.panX !== undefined) this.target.x = set.panX; - if (set.panY !== undefined) this.target.y = set.panY; - if (set.panZ !== undefined) this.target.z = set.panZ; + let target = this.target; + let position = this.object.position; + if (set.posX !== undefined) position.x = set.posX; + if (set.posY !== undefined) position.y = set.posY; + if (set.posZ !== undefined) position.z = set.posZ; + if (set.panX !== undefined) target.x = set.panX; + if (set.panY !== undefined) target.y = set.panY; + if (set.panZ !== undefined) target.z = set.panZ; if (set.scale !== undefined) scale = set.scale; + else scale = 1; + this.update(); }; - this.getPosition = function(scaled) { + this.getPosition = function({ scaled } = { scaled: false }) { let t = this.target, - pos = { left:theta, up:phi, panX:t.x, panY:t.y, panZ:t.z, scale:scaled ? scaleSave : 1 }; + p = this.object.position, + pos = { + left: theta, + up: phi, + panX: t.x, + panY: t.y, + panZ: t.z, + posX: p.x, + posY: p.y, + posZ: p.z, + scale: scaled ? scaleSave : undefined + }; return pos; }; @@ -325,11 +351,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); @@ -362,6 +390,15 @@ class Orbit extends EventDispatcher { if (scope.enabled === false) return; event.preventDefault(); + // keep wheel as dolly, but treat middle-button drag like right-button drag + // in default orbit bindings. + const touchSynthesized = Boolean(event?.sourceCapabilities?.firesTouchEvents); + if (!touchSynthesized + && event.button === MOUSE.MIDDLE + && scope.mouseButtons.ZOOM === MOUSE.MIDDLE + && scope.mouseButtons.PAN === MOUSE.RIGHT) { + state = STATE.PAN; + } else { switch (event.button) { case scope.mouseButtons.ORBIT: state = event.metaKey ? STATE.PAN : STATE.ROTATE; @@ -373,6 +410,7 @@ class Orbit extends EventDispatcher { state = STATE.PAN; break; } + } switch (state) { case STATE.ROTATE: @@ -467,14 +505,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) @@ -681,7 +719,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..0fcda855 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,29 +1553,56 @@ 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}) }, - 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) }, - setZoom: (r,v) => { viewControl.setZoom(r,v) }, - fit: (then, opts = {}) => { + 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,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; + const targetObjects = Array.isArray(opts.objects) ? opts.objects.filter(Boolean) : null; - // Recursively expand box for all visible objects with geometry - WORLD.traverse(obj => { - if (obj.visible && obj.geometry) { + if (targetObjects && targetObjects.length) { + // Fit only the supplied objects (selection-driven fit in app code) + for (const obj of targetObjects) { + if (!obj) continue; + if (visibleOnly && !isEffectivelyVisible(obj)) continue; box.expandByObject(obj); hasObjects = true; } - }); + } else { + // Recursively expand box for all visible objects with geometry + WORLD.traverse(obj => { + if (!obj.geometry) return; + if (visibleOnly && !isEffectivelyVisible(obj)) return; + if (obj.visible) { + box.expandByObject(obj); + hasObjects = true; + } + }); + } // If no objects, fall back to platform bounds if (!hasObjects) { @@ -1277,10 +1624,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 +1640,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({ scaled: 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 +1815,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 +1850,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 +1957,7 @@ let Space = { }, internals() { - return { renderer, camera, platform }; + return { renderer, camera, platform, container, raycaster }; }, isOrtho() { @@ -1498,7 +2003,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 +2024,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 +2082,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 +2155,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 +2179,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..76dc8a54 --- /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 = 0.8; +const VOID_PAN_SPEED_ORTHO = 2.0; +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..6affb70c --- /dev/null +++ b/src/void/api/document.js @@ -0,0 +1,618 @@ +/** 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); + getApi().solids?.onDocumentHydrated?.('document.new'); + 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 = []; + api.solids?.onDocumentHydrated?.('document.open'); + 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?.onDocumentHydrated?.('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..3211e763 --- /dev/null +++ b/src/void/api/geometry_store.js @@ -0,0 +1,109 @@ +/** 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: [], + surface_patches: [], + topology: { + surface_to_segments: {}, + segment_to_surfaces: {}, + patch_to_tris: {}, + tri_to_patch: {} + }, + 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.surface_patches = Array.isArray(out.surface_patches) ? out.surface_patches : []; + out.topology = out.topology && typeof out.topology === 'object' + ? out.topology + : base.topology; + out.topology.patch_to_tris = out.topology.patch_to_tris && typeof out.topology.patch_to_tris === 'object' + ? out.topology.patch_to_tris + : {}; + out.topology.tri_to_patch = out.topology.tri_to_patch && typeof out.topology.tri_to_patch === 'object' + ? out.topology.tri_to_patch + : {}; + out.meta = out.meta && typeof out.meta === 'object' + ? out.meta + : 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.surface_patches = Array.isArray(snapshot.surface_patches) ? snapshot.surface_patches : []; + 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..4f757827 --- /dev/null +++ b/src/void/api/solids.js @@ -0,0 +1,3523 @@ +/** 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 normalizeProfileLoops(loops) { + if (!Array.isArray(loops) || !loops.length) return null; + const out = loops + .filter(loop => Array.isArray(loop) && loop.length >= 3) + .map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) }))); + return out.length ? out : null; + } + + function profileLoopsFromTarget(profileTarget = {}) { + return normalizeProfileLoops(profileTarget?.loops); + } + + 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 = profileLoopsFromTarget(profileTarget) || 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; + const mesh = { positions, indices }; + const optionalUint = ['mergeFromVert', 'mergeToVert', 'runIndex', 'runOriginalID', 'faceID']; + for (const key of optionalUint) { + if (!rec?.[key]?.length) continue; + mesh[key] = rec[key] instanceof Uint32Array ? rec[key] : new Uint32Array(rec[key]); + } + const optionalFloat = ['halfedgeTangent', 'runTransform']; + for (const key of optionalFloat) { + if (!rec?.[key]?.length) continue; + mesh[key] = rec[key] instanceof Float32Array ? rec[key] : new Float32Array(rec[key]); + } + if (rec?.run_source_solid_ids && typeof rec.run_source_solid_ids === 'object') { + mesh.run_source_solid_ids = rec.run_source_solid_ids; + } + if (Array.isArray(rec?.source_solid_ids)) { + mesh.source_solid_ids = rec.source_solid_ids.map(id => String(id || '')).filter(Boolean); + } + map.set(id, mesh); + } + 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 colorFromId(id, sat = 70, light = 58) { + const raw = String(id || ''); + let hash = 0; + for (let i = 0; i < raw.length; i++) { + hash = ((hash << 5) - hash + raw.charCodeAt(i)) | 0; + } + const hue = Math.abs(hash % 360); + const color = new THREE.Color(); + color.setHSL(hue / 360, sat / 100, light / 100); + return color; + } + + function vec3FromRecord(rec) { + return new THREE.Vector3( + Number(rec?.x || 0), + Number(rec?.y || 0), + Number(rec?.z || 0) + ); + } + + function distancePointToSegmentSquared(p, a, b) { + 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, + tris: tris.slice(), + 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}`; + } + + function normalizeRegionIds(ids = [], fallback = []) { + const next = new Set(); + for (const id of ids || []) { + const raw = String(id || '').trim(); + if (raw) next.add(raw); + } + if (!next.size) { + for (const id of fallback || []) { + const raw = String(id || '').trim(); + if (raw) next.add(raw); + } + } + if (!next.size) next.add('region:unknown'); + return Array.from(next).sort(); + } + + function regionKey(ids = []) { + return normalizeRegionIds(ids).join('|'); + } + + function runResolver(meshData = null) { + const runIndex = meshData?.runIndex; + const runOriginalID = meshData?.runOriginalID; + if (!runIndex?.length || runIndex.length < 2 || !runOriginalID?.length) { + return null; + } + return (triIndex) => { + const tri = Number(triIndex); + if (!Number.isFinite(tri) || tri < 0) return null; + let lo = 0; + let hi = runIndex.length - 2; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const a = Number(runIndex[mid]); + const b = Number(runIndex[mid + 1]); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + if (tri < a) { + hi = mid - 1; + } else if (tri >= b) { + lo = mid + 1; + } else { + return Number(runOriginalID[mid]); + } + } + return null; + }; + } + + function facePatchesFromTriProvenance({ + faceMeta = null, + mesh = null, + meshData = null, + sourceProfileKeys = [], + solidsById = new Map() + } = {}) { + const geometry = faceMeta?.geometry || null; + const indexAttr = geometry?.getIndex?.() || null; + const posAttr = geometry?.getAttribute?.('position') || null; + const triIndex = indexAttr?.array || null; + const triCount = Math.floor(Number(triIndex?.length || 0) / 3); + if (!posAttr || !triCount) return []; + + const faceTris = Array.isArray(faceMeta?.tris) ? faceMeta.tris : []; + const resolveRun = runResolver(meshData); + const runSourceSolidIds = meshData?.run_source_solid_ids || {}; + const fallbackSolidIds = Array.isArray(meshData?.source_solid_ids) ? meshData.source_solid_ids : []; + + const triRegionIds = new Array(triCount); + const triRegionKeys = new Array(triCount); + + for (let ti = 0; ti < triCount; ti++) { + const globalTri = Number(faceTris[ti]); + const sourceSolidIds = new Set(); + const runOriginal = resolveRun ? resolveRun(globalTri) : null; + if (Number.isFinite(runOriginal)) { + const runSources = runSourceSolidIds[String(runOriginal)]; + if (Array.isArray(runSources) && runSources.length) { + for (const sid of runSources) { + const id = String(sid || '').trim(); + if (id) sourceSolidIds.add(id); + } + } + } + if (!sourceSolidIds.size) { + for (const sid of fallbackSolidIds) { + const id = String(sid || '').trim(); + if (id) sourceSolidIds.add(id); + } + } + const fromSolids = []; + for (const sid of sourceSolidIds) { + const solid = solidsById.get(String(sid || '')); + const keys = Array.isArray(solid?.source?.profile_keys) ? solid.source.profile_keys : []; + for (const key of keys) { + const kid = String(key || '').trim(); + if (kid) fromSolids.push(kid); + } + } + const ids = normalizeRegionIds(fromSolids, sourceProfileKeys); + triRegionIds[ti] = ids; + triRegionKeys[ti] = regionKey(ids); + } + + const localToWorld = new Map(); + const worldVertex = (vi) => { + const key = Number(vi); + if (localToWorld.has(key)) return localToWorld.get(key); + const p = new THREE.Vector3().fromBufferAttribute(posAttr, key); + const out = mesh?.matrixWorld ? p.applyMatrix4(mesh.matrixWorld) : p; + localToWorld.set(key, out); + return out; + }; + + const geomEdgeKey = (a, b) => { + const ka = quantPointKey(a); + const kb = quantPointKey(b); + return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`; + }; + + const triNeighbors = Array.from({ length: triCount }, () => []); + const firstByEdge = new Map(); + for (let ti = 0; ti < triCount; ti++) { + const ia = Number(triIndex[ti * 3]); + const ib = Number(triIndex[ti * 3 + 1]); + const ic = Number(triIndex[ti * 3 + 2]); + const a = worldVertex(ia); + const b = worldVertex(ib); + const c = worldVertex(ic); + const edges = [[a, b], [b, c], [c, a]]; + for (const [v0, v1] of edges) { + const key = geomEdgeKey(v0, v1); + if (!firstByEdge.has(key)) { + firstByEdge.set(key, ti); + } else { + const other = firstByEdge.get(key); + if (Number.isFinite(other) && other !== ti) { + triNeighbors[ti].push(other); + triNeighbors[other].push(ti); + } + } + } + } + + const visited = new Uint8Array(triCount); + const groups = []; + for (let seed = 0; seed < triCount; seed++) { + if (visited[seed]) continue; + const key = triRegionKeys[seed]; + const queue = [seed]; + const localTris = []; + visited[seed] = 1; + while (queue.length) { + const cur = queue.pop(); + localTris.push(cur); + const nbs = triNeighbors[cur] || []; + for (const nb of nbs) { + if (visited[nb]) continue; + if (triRegionKeys[nb] !== key) continue; + visited[nb] = 1; + queue.push(nb); + } + } + groups.push({ + key, + source_region_ids: triRegionIds[seed].slice(), + local_tris: localTris + }); + } + + const out = []; + for (const group of groups) { + const boundaryEdges = new Map(); + const triIdsGlobal = []; + for (const localTri of group.local_tris) { + const i0 = Number(triIndex[localTri * 3]); + const i1 = Number(triIndex[localTri * 3 + 1]); + const i2 = Number(triIndex[localTri * 3 + 2]); + const v0 = worldVertex(i0); + const v1 = worldVertex(i1); + const v2 = worldVertex(i2); + const globalTri = Number(faceTris[localTri]); + if (Number.isFinite(globalTri)) triIdsGlobal.push(globalTri); + const edges = [[v0, v1], [v1, v2], [v2, v0]]; + for (const [a, b] of edges) { + const ek = geomEdgeKey(a, b); + const rec = boundaryEdges.get(ek); + if (!rec) { + boundaryEdges.set(ek, { a: a.clone(), b: b.clone(), count: 1 }); + } else { + rec.count++; + } + } + } + const loopSegments = []; + for (const rec of boundaryEdges.values()) { + if (Number(rec?.count) !== 1) continue; + const a = rec?.a; + const b = rec?.b; + if (!a || !b || a.distanceToSquared?.(b) <= 1e-16) continue; + loopSegments.push({ a, b }); + } + const loops = buildBoundaryLoopsFromSegments(loopSegments); + if (!loops.length) continue; + out.push({ + key: group.key, + source_region_ids: group.source_region_ids.slice(), + tri_ids: triIdsGlobal, + loops + }); + } + return out; + } + + 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 + }, + _debugPrefs: { + showBoundaries: false, + showSegments: false, + showSegmentLabels: false, + showSurfaceLabels: false, + showRegionLabels: false, + showPatchLabels: false + }, + _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, + _debugGroup: null, + _debugLabelIds: new Set(), + + 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._debugGroup = new THREE.Group(); + this._debugGroup.name = 'void-solids-debug-overlays'; + this._root.add(this._frozenEdgeOverlays); + this._root.add(this._debugGroup); + world?.add?.(this._root); + }, + + onDocumentHydrated(reason = 'document.hydrate', options = {}) { + clearTimeout(this._rebuildTimer); + this._pendingReason = null; + // Invalidate any in-flight rebuild response from prior document state. + this._rebuildSeq++; + this._meshCache = new Map(); + this._selectedIds.clear(); + this._hoveredIds.clear(); + this._selectedFaceKeys.clear(); + this._hoveredFaceKey = null; + this._selectedEdgeKeys.clear(); + this._hoveredEdgeKey = null; + this._frozenChamferEdges = null; + this.syncRuntime(); + if (options?.rebuild !== false) { + this.scheduleRebuild(reason, 0); + } + }, + + getSketchDerivedBoundarySegmentsForSolid(solid) { + const api = getApi(); + if (!solid || String(solid?.source?.feature_type || '') !== 'extrude') return []; + const depth = Math.max(0.0001, Math.abs(Number(solid?.extrude?.depth ?? 0))); + if (!Number.isFinite(depth) || depth <= 0) return []; + const symmetric = solid?.extrude?.symmetric === true; + const direction = solid?.extrude?.direction === 'reverse' ? 'reverse' : 'normal'; + const localZShift = symmetric ? (-depth / 2) : (direction === 'reverse' ? -depth : 0); + + const keys = Array.isArray(solid?.source?.profile_keys) && solid.source.profile_keys.length + ? solid.source.profile_keys + : (solid?.source?.profile?.region_id ? [solid.source.profile.region_id] : []); + if (!keys.length) return []; + + const segments = []; + for (const key of keys) { + const ref = resolveProfileTargetRef({ region_id: String(key || '') }); + if (!ref?.sketchId || !ref?.profileId) continue; + const sketch = api.features?.findById?.(ref.sketchId); + const basis = frameToBasis(sketch?.plane || null); + if (!basis) continue; + const loops = profileLoopsFromRuntime(api, { region_id: String(key || '') }) || []; + for (const loop of loops) { + if (!Array.isArray(loop) || loop.length < 3) continue; + const points = loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) })); + for (let i = 0; i < points.length; i++) { + const a2 = points[i]; + const b2 = points[(i + 1) % points.length]; + const aStart = basis.origin.clone() + .addScaledVector(basis.xAxis, a2.x) + .addScaledVector(basis.yAxis, a2.y) + .addScaledVector(basis.normal, localZShift); + const bStart = basis.origin.clone() + .addScaledVector(basis.xAxis, b2.x) + .addScaledVector(basis.yAxis, b2.y) + .addScaledVector(basis.normal, localZShift); + const aEnd = aStart.clone().addScaledVector(basis.normal, depth); + const bEnd = bStart.clone().addScaledVector(basis.normal, depth); + segments.push({ a: aStart, b: bStart }); + segments.push({ a: aEnd, b: bEnd }); + } + } + } + return segments; + }, + + getSketchDerivedBoundarySegments() { + const out = []; + for (const solid of this.list() || []) { + const segs = this.getSketchDerivedBoundarySegmentsForSolid(solid); + if (segs?.length) out.push(...segs); + } + return out; + }, + + clearDebugOverlays() { + if (this._debugGroup) { + while (this._debugGroup.children.length) { + const child = this._debugGroup.children[0]; + child.geometry?.dispose?.(); + child.material?.dispose?.(); + this._debugGroup.remove(child); + } + } + const api = getApi(); + for (const id of this._debugLabelIds) { + api.overlay?.remove?.(id); + } + this._debugLabelIds.clear(); + }, + + syncDebugOverlays(snapshot = null) { + this.clearDebugOverlays(); + const prefs = this._debugPrefs || {}; + const enabled = !!( + prefs.showBoundaries + || prefs.showSegments + || prefs.showSegmentLabels + || prefs.showSurfaceLabels + || prefs.showRegionLabels + || prefs.showPatchLabels + ); + if (!enabled || !this._debugGroup) return; + + const api = getApi(); + const store = snapshot || api?.document?.current?.geometry_store || null; + const boundaries = Array.isArray(store?.boundaries) ? store.boundaries : []; + const segments = Array.isArray(store?.segments) ? store.segments : []; + const surfaces = Array.isArray(store?.surfaces) ? store.surfaces : []; + const regions = Array.isArray(store?.regions) ? store.regions : []; + const patches = Array.isArray(store?.surface_patches) ? store.surface_patches : []; + if (!boundaries.length && !segments.length && !surfaces.length && !regions.length) return; + + const segById = new Map(segments.map(seg => [String(seg?.id || ''), seg])); + const boundaryById = new Map(boundaries.map(boundary => [String(boundary?.id || ''), boundary])); + const surfaceById = new Map(surfaces.map(surface => [String(surface?.id || ''), surface])); + const patchById = new Map(patches.map(patch => [String(patch?.id || ''), patch])); + this._root?.updateMatrixWorld?.(true); + + // IMPORTANT: GeometryStore coordinates are scene/world-space. + // The solids runtime is attached under `space.WORLD`, which is rotated + // by -90deg on X in `space.js`. Any 3D debug geometry parented under + // this root MUST convert world -> root local first, or it will appear + // rotated/misaligned. Keep labels in world space (overlay expects world). + const toRootLocal = (worldVec3) => this._root?.worldToLocal?.(worldVec3.clone()) || worldVec3.clone(); + + const addLabel = (id, text, pos3d, color = '#ffd166') => { + if (!api.overlay || !pos3d || !text) return; + const labelId = `solid-debug:${id}`; + const opts = { + pos3d, + text, + color, + fontSize: 11, + className: 'overlay-text' + }; + if (api.overlay.elements?.has?.(labelId)) { + api.overlay.update(labelId, opts); + } else { + api.overlay.add(labelId, 'text', opts); + } + this._debugLabelIds.add(labelId); + }; + + const hoveredFaceKey = String(this._hoveredFaceKey || ''); + const hoveredSurfaceId = hoveredFaceKey + ? (this._geomSurfaceIdByFaceKey.get(hoveredFaceKey) || `surface:${hoveredFaceKey}`) + : null; + + const hoveredEdgeKey = String(this._hoveredEdgeKey || ''); + let hoveredSegmentId = null; + let hoveredBoundaryId = null; + if (hoveredEdgeKey) { + hoveredSegmentId = this._geomSegmentIdByEdgeKey.get(hoveredEdgeKey) || null; + hoveredBoundaryId = this._geomBoundaryIdByLoopKey.get(hoveredEdgeKey) || null; + if (!hoveredSegmentId && !hoveredBoundaryId) { + const edge = this.getEdgeByKey(hoveredEdgeKey); + if (edge?.solidId && Number.isFinite(edge?.faceId) && Number.isFinite(edge?.index)) { + const basisKey = `faceedge:${edge.solidId}:${edge.faceId}:${edge.index}`; + hoveredSegmentId = this._geomSegmentIdByEdgeKey.get(basisKey) || null; + } + } + if (!hoveredBoundaryId && hoveredSegmentId) { + hoveredBoundaryId = String(segById.get(hoveredSegmentId)?.boundary_id || '') || null; + } + } + + if (prefs.showBoundaries) { + const preferredBoundaryIds = new Set(); + for (const patch of patches) { + const ids = Array.isArray(patch?.boundary_ids) ? patch.boundary_ids : []; + for (const id of ids) { + const bid = String(id || '').trim(); + if (bid) preferredBoundaryIds.add(bid); + } + } + let toDraw = preferredBoundaryIds.size + ? boundaries.filter(boundary => preferredBoundaryIds.has(String(boundary?.id || ''))) + : boundaries; + if (!toDraw.length && boundaries.length) { + toDraw = boundaries; + } + for (const boundary of toDraw) { + const segmentIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : []; + if (!segmentIds.length) continue; + const positions = []; + for (const segmentId of segmentIds) { + const seg = segById.get(String(segmentId || '')); + if (!seg?.a || !seg?.b) continue; + const a = toRootLocal(vec3FromRecord(seg.a)); + const b = toRootLocal(vec3FromRecord(seg.b)); + positions.push(a.x, a.y, a.z, b.x, b.y, b.z); + } + if (!positions.length) continue; + const color = colorFromId(boundary?.id, 62, 56); + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + const mat = new THREE.LineBasicMaterial({ + color, + transparent: true, + opacity: 0.9, + depthTest: false, + depthWrite: false + }); + const lines = new THREE.LineSegments(geom, mat); + lines.renderOrder = 95; + this._debugGroup.add(lines); + } + } + + if (prefs.showSegments) { + for (const seg of segments) { + if (!seg?.a || !seg?.b) continue; + const a = toRootLocal(vec3FromRecord(seg.a)); + const b = toRootLocal(vec3FromRecord(seg.b)); + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.Float32BufferAttribute([ + a.x, a.y, a.z, + b.x, b.y, b.z + ], 3)); + const mat = new THREE.LineBasicMaterial({ + color: 0x66d9ef, + transparent: true, + opacity: 0.95, + depthTest: false, + depthWrite: false + }); + const line = new THREE.LineSegments(geom, mat); + line.renderOrder = 96; + this._debugGroup.add(line); + } + } + + if (prefs.showSegmentLabels) { + const shown = []; + if (hoveredBoundaryId) { + const boundary = boundaryById.get(String(hoveredBoundaryId || '')); + const ids = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : []; + for (const sid of ids) { + const seg = segById.get(String(sid || '')); + if (seg) shown.push(seg); + } + } else if (hoveredSegmentId) { + const seg = segById.get(String(hoveredSegmentId || '')); + if (seg) shown.push(seg); + } + for (const seg of shown) { + const mid = seg?.mid || null; + if (!mid) continue; + const text = String(seg?.id || 'segment'); + addLabel(`segment:${text}`, text, vec3FromRecord(mid), '#9ad9ff'); + } + } + + if (prefs.showSurfaceLabels) { + if (hoveredSurfaceId) { + const surface = surfaceById.get(String(hoveredSurfaceId || '')); + const center = surface?.center || null; + if (center) { + const text = String(surface?.id || 'surface'); + addLabel(`surface:${text}`, text, vec3FromRecord(center), '#ffcf7a'); + } + } + } + + if (prefs.showRegionLabels) { + for (const region of regions) { + if (hoveredSurfaceId && String(region?.surface_id || '') !== String(hoveredSurfaceId)) { + continue; + } + const boundaryIds = Array.isArray(region?.boundary_ids) ? region.boundary_ids : []; + const boundary = boundaryById.get(String(boundaryIds[0] || '')); + const segIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : []; + let anchor = null; + if (segIds.length) { + const sum = new THREE.Vector3(); + let count = 0; + for (const sid of segIds) { + const seg = segById.get(String(sid || '')); + if (!seg?.mid) continue; + sum.add(vec3FromRecord(seg.mid)); + count++; + } + if (count) { + anchor = sum.multiplyScalar(1 / count); + } + } + if (!anchor && region?.surface_id) { + const surface = surfaceById.get(String(region.surface_id)); + if (surface?.center) anchor = vec3FromRecord(surface.center); + } + if (!anchor) continue; + const text = String(region?.id || 'region'); + addLabel(`region:${text}`, text, anchor, '#ff9dc2'); + } + } + + if (prefs.showPatchLabels) { + const shown = []; + if (hoveredSurfaceId) { + for (const patch of patches) { + if (String(patch?.surface_id || '') === String(hoveredSurfaceId)) { + shown.push(patch); + } + } + } else if (hoveredSurfaceId === null && hoveredEdgeKey) { + // edge hover only path fallback: show matching patch by boundary id + if (hoveredBoundaryId) { + for (const patch of patches) { + const bids = Array.isArray(patch?.boundary_ids) ? patch.boundary_ids : []; + if (bids.includes(hoveredBoundaryId)) shown.push(patch); + } + } + } + for (const patch of shown) { + const regionId = String(patch?.source_region_id || ''); + const boundaryIds = Array.isArray(patch?.boundary_ids) ? patch.boundary_ids : []; + const boundary = boundaryById.get(String(boundaryIds[0] || '')); + const segIds = Array.isArray(boundary?.segment_ids) ? boundary.segment_ids : []; + let anchor = null; + if (segIds.length) { + const sum = new THREE.Vector3(); + let count = 0; + for (const sid of segIds) { + const seg = segById.get(String(sid || '')); + if (!seg?.mid) continue; + sum.add(vec3FromRecord(seg.mid)); + count++; + } + if (count) anchor = sum.multiplyScalar(1 / count); + } + if (!anchor && patch?.surface_id) { + const surface = surfaceById.get(String(patch.surface_id)); + if (surface?.center) anchor = vec3FromRecord(surface.center); + } + if (!anchor) continue; + const text = regionId + ? `${String(patch?.id || 'patch')} -> ${regionId}` + : String(patch?.id || 'patch'); + addLabel(`patch:${patch?.id || text}`, text, anchor, '#a6f57a'); + } + } + + api.overlay?.updateAll?.(); + }, + + list() { + const api = getApi(); + return Array.isArray(api.document.current?.generated?.solids) + ? api.document.current.generated.solids + : []; + }, + + buildGeometryStoreSnapshot() { + this._root?.updateMatrixWorld?.(true); + 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 surface_patches = []; + const pointIdByKey = new Map(); + const topology = { + surface_to_segments: {}, + segment_to_surfaces: {}, + patch_to_tris: {}, + tri_to_patch: {} + }; + const solidsById = new Map((this.list() || []).map(item => [String(item?.id || ''), item])); + + 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()) { + const solid = solidsById.get(String(solidId)) || null; + const sourceProfileKeys = Array.isArray(solid?.source?.profile_keys) + ? solid.source.profile_keys.map(key => String(key || '')).filter(Boolean) + : []; + const primarySourceRegion = sourceProfileKeys.length === 1 ? sourceProfileKeys[0] : null; + if (!view?.faceGroups?.size) continue; + for (const [faceId, meta] of view.faceGroups.entries()) { + const faceKey = `${solidId}:${faceId}`; + const surfaceId = `surface:${faceKey}`; + const loops = this.getFaceBoundaryLoops(faceKey) || []; + const mesh = view?.mesh || null; + const normalLocal = meta?.normal || new THREE.Vector3(0, 0, 1); + const centerLocal = meta?.center || new THREE.Vector3(); + // GeometryStore should store scene/world-space coordinates. + // Face loops already do this via `getFaceBoundaryLoops()`. + // Keep surface center/normal in the same space for consistency. + const normal = normalLocal?.clone?.() && mesh?.matrixWorld + ? normalLocal.clone().transformDirection(mesh.matrixWorld).normalize() + : normalLocal; + const center = centerLocal?.clone?.() && mesh?.matrixWorld + ? centerLocal.clone().applyMatrix4(mesh.matrixWorld) + : centerLocal; + surfaces.push({ + 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 meshData = this._meshCache?.get?.(String(solidId)) || null; + const facePatches = facePatchesFromTriProvenance({ + faceMeta: meta, + mesh, + meshData, + sourceProfileKeys, + solidsById + }); + const faceSourceRegionSet = new Set(); + for (const patch of facePatches) { + for (const id of patch?.source_region_ids || []) { + const rid = String(id || '').trim(); + if (rid) faceSourceRegionSet.add(rid); + } + } + if (!faceSourceRegionSet.size) { + for (const rid of normalizeRegionIds(sourceProfileKeys)) { + faceSourceRegionSet.add(rid); + } + } + const faceSourceRegionIds = Array.from(faceSourceRegionSet); + const facePrimarySourceRegion = faceSourceRegionIds.length === 1 + ? faceSourceRegionIds[0] + : (primarySourceRegion || null); + + 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 + } + }); + } + + let emittedPatchCount = 0; + if (facePatches.length) { + for (let pi = 0; pi < facePatches.length; pi++) { + const patch = facePatches[pi]; + const patchId = `surface-patch:${faceKey}:${pi}`; + const patchBoundaryIds = []; + const sourceRegionIds = normalizeRegionIds(patch?.source_region_ids, faceSourceRegionIds); + const patchPrimarySourceRegion = sourceRegionIds.length === 1 + ? sourceRegionIds[0] + : facePrimarySourceRegion; + const patchLoops = Array.isArray(patch?.loops) ? patch.loops : []; + for (let pli = 0; pli < patchLoops.length; pli++) { + const loop = patchLoops[pli]; + const points = Array.isArray(loop?.points) ? loop.points : []; + if (points.length < 2) continue; + const boundaryId = `boundary:patch:${faceKey}:${pi}:${pli}`; + const boundarySegmentIds = []; + const closed = !!loop?.closed; + const stepCount = closed ? points.length : (points.length - 1); + for (let si = 0; si < stepCount; si++) { + const a = points[si]; + const b = points[(si + 1) % points.length]; + if (!a || !b || a.distanceToSquared?.(b) <= 1e-16) continue; + const segmentId = `segment:patch:${faceKey}:${pi}:${pli}:${si}`; + const aId = getPointId(a, 'patch-boundary-vertex'); + const bId = getPointId(b, 'patch-boundary-vertex'); + const mid = a.clone().add(b).multiplyScalar(0.5); + const midId = getPointId(mid, 'patch-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: 'surface-patch-boundary', + patch_id: patchId, + face_key: faceKey + } + }); + boundarySegmentIds.push(segmentId); + surfaceSegmentIds.push(segmentId); + topology.segment_to_surfaces[segmentId] = [surfaceId]; + } + if (!boundarySegmentIds.length) continue; + boundaries.push({ + id: boundaryId, + surface_id: surfaceId, + segment_ids: boundarySegmentIds, + closed, + source: { + type: 'surface-patch-loop', + patch_id: patchId, + face_key: faceKey, + loop_index: pli + } + }); + patchBoundaryIds.push(boundaryId); + } + if (!patchBoundaryIds.length) continue; + surface_patches.push({ + id: patchId, + surface_id: surfaceId, + boundary_ids: patchBoundaryIds, + source_region_id: patchPrimarySourceRegion, + source_region_ids: sourceRegionIds, + solid_id: solidId, + face_id: faceId, + status: facePatches.length > 1 ? 'partitioned' : 'single-source', + source: { + type: 'tri-provenance', + face_key: faceKey, + feature_id: solid?.source?.feature_id || null + } + }); + emittedPatchCount++; + const patchTriIds = Array.isArray(patch?.tri_ids) ? patch.tri_ids : []; + topology.patch_to_tris[patchId] = patchTriIds.slice(); + for (const triId of patchTriIds) { + if (!Number.isFinite(Number(triId))) continue; + topology.tri_to_patch[`${solidId}:${Number(triId)}`] = patchId; + } + regions.push({ + id: `region:patch:${faceKey}:${pi}`, + surface_id: surfaceId, + boundary_ids: patchBoundaryIds.slice(), + source: { + type: 'surface-patch-region', + patch_id: patchId, + face_key: faceKey + } + }); + } + } + if (!emittedPatchCount) { + for (let li = 0; li < loops.length; li++) { + const boundaryId = `boundary:${faceKey}:${li}`; + const patchId = `surface-patch:${faceKey}:${li}`; + surface_patches.push({ + id: patchId, + surface_id: surfaceId, + boundary_ids: [boundaryId], + source_region_id: facePrimarySourceRegion, + source_region_ids: faceSourceRegionIds.slice(), + solid_id: solidId, + face_id: faceId, + status: 'seed', + source: { + type: 'solid-face-loop', + face_key: faceKey, + loop_index: li, + feature_id: solid?.source?.feature_id || null + } + }); + } + } + topology.surface_to_segments[surfaceId] = surfaceSegmentIds; + } + } + return { + surfaces, + boundaries, + segments, + points, + regions, + surface_patches, + 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; + // Derive visible hard edges from indexed manifold topology, not the + // creased render geometry, to avoid triangle/split seam artifacts. + const edgesGeom = new THREE.EdgesGeometry(built.indexed, 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.indexed, 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); + this.syncDebugOverlays(snapshot); + } else { + this.syncDebugOverlays(null); + } + }, + + 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 = {}, options = {}) { + if (source?.type !== 'solid-edge') return null; + const allowGlobalFallback = options?.allowGlobalFallback !== false; + 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); + } + if (allowGlobalFallback) { + 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 = {}, options = {}) { + 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, options); + 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(); + if (this._debugPrefs?.showSurfaceLabels || this._debugPrefs?.showRegionLabels) { + this.syncDebugOverlays(); + } + }, + + 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(); + if (this._debugPrefs?.showSegmentLabels) { + this.syncDebugOverlays(); + } + }, + + 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(); + }, + + getDebugPreferences() { + return { ...(this._debugPrefs || {}) }; + }, + + setDebugPreferences(next = {}) { + const curr = this._debugPrefs || {}; + const merged = { + showBoundaries: next.showBoundaries !== undefined ? next.showBoundaries === true : !!curr.showBoundaries, + showSegments: next.showSegments !== undefined ? next.showSegments === true : !!curr.showSegments, + showSegmentLabels: next.showSegmentLabels !== undefined ? next.showSegmentLabels === true : !!curr.showSegmentLabels, + showSurfaceLabels: next.showSurfaceLabels !== undefined ? next.showSurfaceLabels === true : !!curr.showSurfaceLabels, + showRegionLabels: next.showRegionLabels !== undefined ? next.showRegionLabels === true : !!curr.showRegionLabels, + showPatchLabels: next.showPatchLabels !== undefined ? next.showPatchLabels === true : !!curr.showPatchLabels + }; + this._debugPrefs = merged; + this.syncDebugOverlays(); + return this.getDebugPreferences(); + }, + + getSketchTargetForFaceKey(key) { + 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) + ) + : (meta?.xAxis?.clone?.() || 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, options = {}) { + const sourceType = String(source?.type || ''); + if (sourceType !== 'solid-face' && sourceType !== 'face') return null; + const allowGlobalFallback = options?.allowGlobalFallback !== false; + 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 && allowGlobalFallback) { + 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(options = {}) { + const api = getApi(); + const features = api.features.list() || []; + const eligible = options?.eligibleSketchIds instanceof Set ? options.eligibleSketchIds : null; + let changed = false; + for (const feature of features) { + if (feature?.type !== 'sketch') continue; + if (eligible && !eligible.has(String(feature?.id || ''))) 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) { + // Auto-refresh path must not drift to unrelated/newer solids when + // the original source solid/face no longer exists. + resolved = this.resolveSketchFrameForSource(source, feature.plane || null, { + allowGlobalFallback: false + }); + } + 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(); + const allFeatures = api.features.list() || []; + const featureIndexById = new Map(allFeatures.map((f, i) => [String(f?.id || ''), i])); + const eligibleSketchIds = new Set(); + const activeEditFeatureId = String(api.document?.getAtomicEditFeatureId?.() || ''); + const activeEditFeature = activeEditFeatureId + ? (allFeatures.find(f => String(f?.id || '') === activeEditFeatureId) || null) + : null; + const activeSketchEditId = activeEditFeature?.type === 'sketch' + ? String(activeEditFeature.id || '') + : ''; + const getFeatureIndex = (fid) => { + const key = String(fid || ''); + if (!key) return -1; + const idx = featureIndexById.get(key); + return Number.isFinite(idx) ? Number(idx) : -1; + }; + const hasFutureSourceDependency = (sketchFeature) => { + const sketchIndex = getFeatureIndex(sketchFeature?.id); + if (sketchIndex < 0) return false; + const sourceFeatureIds = new Set(); + const targetSource = sketchFeature?.target?.source || null; + const targetSourceFeatureId = String(targetSource?.solid_feature_id || ''); + if (targetSourceFeatureId) sourceFeatureIds.add(targetSourceFeatureId); + const entities = Array.isArray(sketchFeature?.entities) ? sketchFeature.entities : []; + for (const entity of entities) { + if (!entity?.derived) continue; + const src = entity?.source || null; + const srcFeatureId = String(src?.solid_feature_id || ''); + if (srcFeatureId) sourceFeatureIds.add(srcFeatureId); + } + for (const srcId of sourceFeatureIds) { + const srcIndex = getFeatureIndex(srcId); + if (srcIndex > sketchIndex) return true; + } + return false; + }; + // Guardrail: do not auto-mutate historical sketches during solids + // rebuilds triggered by other features. Only the actively edited + // sketch may auto-refresh derived refs / face attachments. + if (activeSketchEditId) { + const feature = allFeatures.find(f => String(f?.id || '') === activeSketchEditId) || null; + if (feature?.type === 'sketch' && !hasFutureSourceDependency(feature)) { + eligibleSketchIds.add(activeSketchEditId); + } + } + let derivedChanged = false; + for (const feature of allFeatures) { + if (feature?.type !== 'sketch') continue; + if (!eligibleSketchIds.has(String(feature?.id || ''))) continue; + if (api.interact?.refreshDerivedSketchGeometry?.(feature)) { + derivedChanged = true; + } + } + const rebound = this.refreshSketchFaceAttachments({ eligibleSketchIds }); + 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..886080d3 --- /dev/null +++ b/src/void/interact.js @@ -0,0 +1,372 @@ +/** 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(); + if (this.isSketchEditing()) { + this.handleSketchHover(null, []); + } + }); + + 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..faa23335 --- /dev/null +++ b/src/void/sketch/create.js @@ -0,0 +1,2136 @@ +/** 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, { + allowGlobalFallback: false + }) || 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, { + allowGlobalFallback: false + }); + 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..7bafbf46 --- /dev/null +++ b/src/void/sketch/pointer.js @@ -0,0 +1,1225 @@ +/** 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); + 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..c014d8dd --- /dev/null +++ b/src/void/sketch/tools.js @@ -0,0 +1,568 @@ +/** 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) { + window.alert('Circular pattern requires exactly one anchor point selected.'); + 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) { + window.alert('Grid pattern requires exactly one anchor point selected.'); + return false; + } + + // If line/arc sources are already selected, apply immediately. + const entities = Array.isArray(feature?.entities) ? feature.entities : []; + const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity])); + const sourceIds = Array.from(this.selectedSketchEntities || []) + .filter(id => typeof id === 'string' && id && id !== centerPointId && !id.startsWith('arc-center:')) + .filter(id => { + const ent = byId.get(id); + return ent?.type === 'line' || ent?.type === 'arc'; + }); + if (sourceIds.length) { + const applied = this.gridPatternSelectedSketchGeometry?.({ + centerRef: centerPointId, + sourceIds, + keepResultSelected: false + }); + if (applied) return true; + } + + 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..159b672f --- /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); + if (false) 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..a046f814 --- /dev/null +++ b/src/void/solid/kernel.js @@ -0,0 +1,195 @@ +/** 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); + const rec = { + numProp: 3, + vertProperties: props, + triVerts: Uint32Array.from(indices) + }; + if (meshData?.mergeFromVert?.length) rec.mergeFromVert = Uint32Array.from(meshData.mergeFromVert); + if (meshData?.mergeToVert?.length) rec.mergeToVert = Uint32Array.from(meshData.mergeToVert); + if (meshData?.runIndex?.length) rec.runIndex = Uint32Array.from(meshData.runIndex); + if (meshData?.runOriginalID?.length) rec.runOriginalID = Uint32Array.from(meshData.runOriginalID); + if (meshData?.faceID?.length) rec.faceID = Uint32Array.from(meshData.faceID); + if (meshData?.halfedgeTangent?.length) rec.halfedgeTangent = Float32Array.from(meshData.halfedgeTangent); + if (meshData?.runTransform?.length) rec.runTransform = Float32Array.from(meshData.runTransform); + return new inst.Mesh(rec); +} + +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); + } + const out = { + positions, + indices: Uint32Array.from(triVerts) + }; + if (mesh?.mergeFromVert?.length) out.mergeFromVert = Uint32Array.from(mesh.mergeFromVert); + if (mesh?.mergeToVert?.length) out.mergeToVert = Uint32Array.from(mesh.mergeToVert); + if (mesh?.runIndex?.length) out.runIndex = Uint32Array.from(mesh.runIndex); + if (mesh?.runOriginalID?.length) out.runOriginalID = Uint32Array.from(mesh.runOriginalID); + if (mesh?.faceID?.length) out.faceID = Uint32Array.from(mesh.faceID); + if (mesh?.halfedgeTangent?.length) out.halfedgeTangent = Float32Array.from(mesh.halfedgeTangent); + if (mesh?.runTransform?.length) out.runTransform = Float32Array.from(mesh.runTransform); + return out; +} + +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 = []; + const runSourceSolidIdsByOriginal = {}; + let result = null; + try { + const toManifold = meshData => { + const kernelMesh = toKernelMesh(inst, meshData); + const manifold = kernelMesh ? new inst.Manifold(kernelMesh) : null; + if (!manifold) return null; + try { + const infoMesh = manifold.getMesh?.(); + const runOriginalID = Array.isArray(infoMesh?.runOriginalID) + ? infoMesh.runOriginalID + : (infoMesh?.runOriginalID ? Array.from(infoMesh.runOriginalID) : []); + const sourceIds = Array.isArray(meshData?.source_solid_ids) + ? meshData.source_solid_ids.map(id => String(id || '')).filter(Boolean) + : []; + const first = Number(runOriginalID?.[0]); + if (Number.isFinite(first) && sourceIds.length) { + runSourceSolidIdsByOriginal[String(first)] = sourceIds; + } + } catch {} + return manifold; + }; + 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?.(); + if (!mesh) return null; + const outMesh = fromKernelMesh(mesh); + if (Object.keys(runSourceSolidIdsByOriginal).length) { + outMesh.run_source_solid_ids = runSourceSolidIdsByOriginal; + } + return { mesh: outMesh }; + } 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..f0df1e1d --- /dev/null +++ b/src/void/solid/rebuild.js @@ -0,0 +1,602 @@ +/** 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 normalizeProfileLoops(loops) { + if (!Array.isArray(loops) || !loops.length) return null; + const out = loops + .filter(loop => Array.isArray(loop) && loop.length >= 3) + .map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) }))); + return out.length ? out : null; +} + +function profileLoopsFromTarget(profileTarget = {}) { + return normalizeProfileLoops(profileTarget?.loops); +} + +function profileLoopsFromSnapshot(snapshot, profileTarget) { + const direct = profileLoopsFromTarget(profileTarget); + if (direct?.length) return direct; + const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget); + if (!key || !sketchId || !profileId) return null; + const map = snapshot?.profileLoops || {}; + return normalizeProfileLoops(map[key]); +} + +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'; + // Symmetric is a single extrusion whose local span is shifted to + // [-depth/2, +depth/2], not two opposing extrusions/union. + 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) { + meshWorld.source_solid_ids = [id]; + 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 withSourceIds = (mesh, sid) => { + if (!mesh) return null; + const source = Array.isArray(mesh.source_solid_ids) && mesh.source_solid_ids.length + ? mesh.source_solid_ids + : [sid]; + return { ...mesh, source_solid_ids: source.map(id => String(id || '')).filter(Boolean) }; + }; + const toolMeshes = createdSolids + .map(s => withSourceIds(meshCache.get(s.id), s.id)) + .filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + const targetMeshes = targetSolids + .map(s => withSourceIds(meshCache.get(s.id), 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 consumedSourceSolidIds = Array.from(consumed); + 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' + }; + merge.mesh.source_solid_ids = consumedSourceSolidIds; + 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 withSourceIds = (mesh, sid) => { + if (!mesh) return null; + const source = Array.isArray(mesh.source_solid_ids) && mesh.source_solid_ids.length + ? mesh.source_solid_ids + : [sid]; + return { ...mesh, source_solid_ids: source.map(id => String(id || '')).filter(Boolean) }; + }; + const targetMeshes = targetSolids + .map(s => withSourceIds(meshCache.get(s.id), s.id)) + .filter(mesh => mesh?.positions?.length && mesh?.indices?.length); + const toolMeshes = toolSolids + .map(s => withSourceIds(meshCache.get(s.id), 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' + }; + result.mesh.source_solid_ids = selectedIds.slice(); + 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 = profileLoopsFromTarget(profileTarget) || 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..001f679c --- /dev/null +++ b/src/void/toolbar.js @@ -0,0 +1,1551 @@ +/** 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; + const target = { + region_id: `profile:${sketchId}:${profileId}` + }; + const rec = api.sketchRuntime?.getRecord?.(sketchId); + const view = rec?.entityViews?.get?.(profileId); + const rawLoops = view?.object?.userData?.sketchProfileLoops + || (view?.object?.userData?.sketchProfileLoop ? [view.object.userData.sketchProfileLoop] : null) + || view?.entity?.loops + || (view?.entity?.loop ? [view.entity.loop] : null); + const loops = Array.isArray(rawLoops) + ? rawLoops + .filter(loop => Array.isArray(loop) && loop.length >= 3) + .map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) }))) + : []; + if (loops.length) { + target.loops = loops; + } + out.push(target); + } + 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, + debugShowBoundaries: false, + debugShowSegments: false, + debugShowSegmentLabels: false, + debugShowSurfaceLabels: false, + debugShowRegionLabels: false, + debugShowPatchLabels: false + }; + }, + + normalizePreferences(raw = {}) { + const d = this.getDefaultPreferences(); + const toBool = value => value === true || value === 'true' || value === 1 || value === '1'; + 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), + debugShowBoundaries: toBool(raw.debugShowBoundaries ?? d.debugShowBoundaries), + debugShowSegments: toBool(raw.debugShowSegments ?? d.debugShowSegments), + debugShowSegmentLabels: toBool(raw.debugShowSegmentLabels ?? d.debugShowSegmentLabels), + debugShowSurfaceLabels: toBool(raw.debugShowSurfaceLabels ?? d.debugShowSurfaceLabels), + debugShowRegionLabels: toBool(raw.debugShowRegionLabels ?? d.debugShowRegionLabels), + debugShowPatchLabels: toBool(raw.debugShowPatchLabels ?? d.debugShowPatchLabels) + }; + }, + + 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.solids?.setDebugPreferences?.({ + showBoundaries: prefs.debugShowBoundaries, + showSegments: prefs.debugShowSegments, + showSegmentLabels: prefs.debugShowSegmentLabels, + showSurfaceLabels: prefs.debugShowSurfaceLabels, + showRegionLabels: prefs.debugShowRegionLabels, + showPatchLabels: prefs.debugShowPatchLabels + }); + 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); + if (inputs.debugShowBoundaries) inputs.debugShowBoundaries.checked = !!prefs.debugShowBoundaries; + if (inputs.debugShowSegments) inputs.debugShowSegments.checked = !!prefs.debugShowSegments; + if (inputs.debugShowSegmentLabels) inputs.debugShowSegmentLabels.checked = !!prefs.debugShowSegmentLabels; + if (inputs.debugShowSurfaceLabels) inputs.debugShowSurfaceLabels.checked = !!prefs.debugShowSurfaceLabels; + if (inputs.debugShowRegionLabels) inputs.debugShowRegionLabels.checked = !!prefs.debugShowRegionLabels; + if (inputs.debugShowPatchLabels) inputs.debugShowPatchLabels.checked = !!prefs.debugShowPatchLabels; + }, + + buildPreferencesDialog() { + 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; + }; + const makeCheckboxRow = (label, key, help = '') => { + const row = document.createElement('div'); + row.className = 'doc-dialog-row prefs-row'; + const name = document.createElement('div'); + name.className = 'doc-dialog-name'; + name.textContent = label; + if (help) { + name.title = help; + row.title = help; + } + const input = document.createElement('input'); + input.type = 'checkbox'; + input.className = 'prefs-input'; + if (help) { + input.title = help; + } + row.appendChild(name); + row.appendChild(input); + list.appendChild(row); + this.preferencesInputs = this.preferencesInputs || {}; + this.preferencesInputs[key] = input; + }; + + makeNumberRow( + '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.' + ); + makeCheckboxRow( + 'Debug: Show Boundaries', + 'debugShowBoundaries', + 'Render boundary loops from GeometryStore in 3D to verify boundary extraction and provenance partitioning.' + ); + makeCheckboxRow( + 'Debug: Show Segments', + 'debugShowSegments', + 'Render every boundary segment from GeometryStore in cyan for dense topology inspection.' + ); + makeCheckboxRow( + 'Debug: Segment Labels', + 'debugShowSegmentLabels', + 'Draw 2D overlay labels for segment IDs at segment midpoints.' + ); + makeCheckboxRow( + 'Debug: Surface Labels', + 'debugShowSurfaceLabels', + 'Draw 2D overlay labels for canonical surface IDs at surface centers.' + ); + makeCheckboxRow( + 'Debug: Region Labels', + 'debugShowRegionLabels', + 'Draw 2D overlay labels for canonical region IDs using boundary centroid anchors.' + ); + makeCheckboxRow( + 'Debug: Patch Labels', + 'debugShowPatchLabels', + 'Draw hover-scoped labels for seeded surface patch IDs and mapped source region IDs.' + ); + const actions = document.createElement('div'); + 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), + debugShowBoundaries: !!this.preferencesInputs?.debugShowBoundaries?.checked, + debugShowSegments: !!this.preferencesInputs?.debugShowSegments?.checked, + debugShowSegmentLabels: !!this.preferencesInputs?.debugShowSegmentLabels?.checked, + debugShowSurfaceLabels: !!this.preferencesInputs?.debugShowSurfaceLabels?.checked, + debugShowRegionLabels: !!this.preferencesInputs?.debugShowRegionLabels?.checked, + debugShowPatchLabels: !!this.preferencesInputs?.debugShowPatchLabels?.checked + }, { persist: true, updateFields: true }); + }); + 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..4d9b976c --- /dev/null +++ b/src/void/worker/solids_worker.js @@ -0,0 +1,78 @@ +/** 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); + const optionalUint = ['mergeFromVert', 'mergeToVert', 'runIndex', 'runOriginalID', 'faceID']; + for (const key of optionalUint) { + if (!mesh?.[key]?.length) continue; + const arr = mesh[key] instanceof Uint32Array ? mesh[key] : new Uint32Array(mesh[key]); + meshes[meshes.length - 1][key] = arr; + transfer.push(arr.buffer); + } + const optionalFloat = ['halfedgeTangent', 'runTransform']; + for (const key of optionalFloat) { + if (!mesh?.[key]?.length) continue; + const arr = mesh[key] instanceof Float32Array ? mesh[key] : new Float32Array(mesh[key]); + meshes[meshes.length - 1][key] = arr; + transfer.push(arr.buffer); + } + if (mesh?.run_source_solid_ids && typeof mesh.run_source_solid_ids === 'object') { + meshes[meshes.length - 1].run_source_solid_ids = mesh.run_source_solid_ids; + } + if (Array.isArray(mesh?.source_solid_ids)) { + meshes[meshes.length - 1].source_solid_ids = mesh.source_solid_ids; + } + } + 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/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..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 } @@ -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' : diff --git a/web/icon/kirimoto.png b/web/icon/kirimoto.png new file mode 100644 index 00000000..dcd33315 Binary files /dev/null and b/web/icon/kirimoto.png differ diff --git a/web/icon/kirimoto2.png b/web/icon/kirimoto2.png new file mode 100644 index 00000000..f6aeb134 Binary files /dev/null and b/web/icon/kirimoto2.png differ diff --git a/web/icon/meshtool.png b/web/icon/meshtool.png new file mode 100644 index 00000000..7812952c Binary files /dev/null and b/web/icon/meshtool.png differ diff --git a/web/icon/voidform.png b/web/icon/voidform.png new file mode 100644 index 00000000..72088bbe Binary files /dev/null and b/web/icon/voidform.png differ 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; diff --git a/web/kiri/index.css b/web/kiri/index.css index b5f49987..4a654489 100644 --- a/web/kiri/index.css +++ b/web/kiri/index.css @@ -36,42 +36,6 @@ font-family: 'Russo One'; src: url('../moto/russo-one.ttf'); } -@media only screen and (max-width : 1000px) { - .top-menu > span > label { - display: none; - } -} -@media only screen and (max-width : 1200px) { - body { - font-size: smaller; - } -} -@media only screen and (max-width : 900px) { - body { - font-size: x-small; - } -} -@media only screen and (max-height : 1024px) { - body { - font-size: smaller; - } -} -@media only screen and (max-height : 750px) { - .lt-menu, .pop-lcol { - font-size: 16px !important; - } - .lt-menu svg { - font-size: 30px !important; - } - .pop-lcol svg { - font-size: 20px !important; - } - .mod-x { - position: absolute; - font-size: 30px; - top: 8px; - } -} html, body { position: relative; @@ -115,23 +79,17 @@ button[disabled] { color: #999; } input { - background-color: #f8f8ff; + margin-top: 1px; margin-bottom: 1px; - text-align: right; - border: 0.5px solid #bbb; padding-top: 0; padding-bottom: 0; - margin-top: 1px; position: relative; + text-align: right; } input:focus { outline: 0; box-shadow: 0 0 0 0.2rem rgb(0 100 255 / 10%); } -input[disabled] { - background-color: #ddd; - color: #000; -} input[type="range"] { width: 85px; background-color: transparent; @@ -170,8 +128,6 @@ textarea { overflow: scroll; } select { - border: 1px solid #bbb; - background-color: rgba(255,255,255,0.5); border-radius: 3px; } th, tr, td, span, div, label, button { @@ -224,6 +180,7 @@ details[open] summary::after { /* container for entire page / app */ #app { position: fixed; + font-size: 14px; font-weight: normal; font-family: sans-serif; top: 0; @@ -237,226 +194,209 @@ details[open] summary::after { } /** dark conversion */ -.dark button[disabled] { - color: #888; -} -.dark input[disabled] { - border-color: #666; - background-color: #444; - color: #888; -} -.dark input, -.dark select, -.dark option -{ - border-color: #888; - background-color: #333; - color: #fff; -} -.dark option:checked { - background-color: var(--blue-1); -} -.dark hr { - border-color: #555; -} -.dark textarea { - background-color: #333; - color: #fff; -} -.dark button { - color: #fff; - background-color: #555; - border: 1px solid #777; -} -.dark button:not([disabled]):hover { - background-color: var(--blue-2); - color: black; -} -.dark #alert-border:hover { - background-color: rgba(255,255,255,0.7); -} -.dark #progress { - background-color: #333; - border-top: 1px solid #555; - border-bottom: 1px solid #555; -} -.dark #mesh-info label, -.dark #mode-info label -{ - color: #fff; - border-color: #888; - background-color: var(--blue-0); -} -.dark #mesh-info span, -.dark #mode-info span -{ - color: #fff; - border-color: #888; - background-color: #333; -} -.dark #oplist div .del { - color: #aaa; -} -.dark #oplist div:hover .del { - color: #333 !important; -} -.dark .mod-top, .dark .mod-end { - background-color: var(--blue-00); -} -.dark .pop-sep, -.dark .set-sep -{ - background-color: #555 !important; -} -.dark #render-tools .pop, -.dark #render-tools svg, -.dark .pop-tics > div, -.dark #top, -.dark #top .content, -.dark #app-name, -.dark .set2-group, -.dark .set2-group .var-row, -.dark .set2-sep, -.dark .var-row, -.dark #layers .var-row, -.dark #slider-zero, -.dark #slider-max, -.dark .mdialog, -.dark .t-body, -.dark .cam-pop-op, -.dark .mod-print, -.dark .mod-print .header label -{ - background-color: #282828; - color: white; -} -.dark #dev-list:hover { - background-color: var(--blue-1); -} -.dark #render-tools .pop, -.dark #render-tools svg, -.dark .cam-pop-op { - border-color: #555; -} -.dark .btn-sel, -.dark .txt-sel button { - /* color: #fff; */ - background-color: var(--blue-1); -} -.dark #dialog { - background-color: rgba(60,60,60,0.85); - border-top: 8px solid rgba(100,100,100,1); - border-bottom: 7px solid rgba(100,100,100,1); -} -.dark #modal, .dark #dialog { - color: #fff !important; -} -.dark #modal select, .dark #dialog select { - color: #fff !important; -} -.dark .t-body, -.dark .set2-group { - border: 1px solid #444 !important; -} -.dark .set2-group .var-row:hover, -.dark .var-row:hover -{ - border-right: 3px solid var(--blue-3); - background-color: var(--blue-1) !important; -} -.dark #slider-line { - background-color: #444; -} -.dark .set-header { - background-color: var(--blue-00); -} -.dark #oplist > div, -.dark .opdiv -{ - background-color: #333; - border: 1px solid #888; -} -.dark #oplist > div:hover, -.dark #op-add:hover { - background-color: var(--blue-1); -} -.dark #oplist div:hover .del { - color: #777; -} -.dark #top .content > div:hover { - background-color: var(--blue-1); -} -.dark .top-menu label { - color: var(--dark-menu-text) -} -.dark #render-tools svg:hover { - background-color: var(--blue-1); - border-color: var(--blue-3); -} -.dark .top-menu > span:hover { - background-color: var(--blue-1); -} -.dark .top-menu > span:hover label, -.dark .top-menu > span:hover svg -{ - color: var(--dark-menu-text) !important; -} -.dark .top-menu > span.selected, -.dark .top-menu > span.selected svg, -.dark .top-menu-drop div.selected -{ - background-color: var(--blue-1); - color: #fff; -} -.dark #slider-hold { - background-color: var(--blue-0); -} -.dark #stats { - color: #888; -} -.dark #rnfo { - color: #aaa; - border: 1px solid rgba(255,255,255,0.25); - background-color: rgba(0,0,0,0.75); -} -.dark #rnfo label { - color: #ada; -} -.dark #slider-center { - /* background-color: rgba(255,255,255,0.15); */ -} -#slider-center { - /* background-color: rgba(255,255,255,0.5); */ -} -.dark #slider-hold .handle { - background-color: #999; -} -.dark #speedbar label { - color: black; -} -.dark .widopt { - background-color: #222; -} -.dark #camops summary { - background-color: var(--blue-0); +:root[data-theme="dark"] { + /* global alerts / labels */ + #alert-border:hover { + background-color: rgba(255,255,255,0.7); + } + #mesh-info label, + #mesh-info span, + #oplist div .del { + color: #aaa; + } + /* shared panels */ + .mod-top, .mod-end { + background-color: var(--blue-00); + } + .pop-sep, + .set-sep { + background-color: #555 !important; + } + .t-body { + border: 1px solid #555; + } + #render-tools .pop, + #render-tools svg, + .pop-tics > div, + #menubar.content, + #app-name, + .set2-sep, + .var-row, + #layers .var-row, + #slider-zero, + #slider-max, + .mdialog, + .t-body, + .cam-pop-op, + .mod-print, + .mod-print .header label { + background-color: #282828; + color: white; + } + /* borders + emphasis */ + #dev-list:hover { + background-color: var(--blue-1); + } + #render-tools .pop, + #render-tools svg, + .cam-pop-op { + border-color: #555; + } + .btn-sel, + .txt-sel button { + /* color: #fff; */ + background-color: var(--blue-1); + } + /* lists / rows */ + .set2-group .var-row:hover, + .var-row:hover { + border-right: 3px solid var(--blue-3); + background-color: var(--blue-1) !important; + } + #slider-line { + background-color: #444; + } + .set-header { + background-color: var(--blue-00); + } + #oplist > div, + .opdiv { + background-color: #282828; + border: 1px solid #666; + } + #oplist > div:hover, + #op-add:hover { + background-color: var(--blue-1); + } + #oplist div:hover .del { + color: #777 !important; + } + /* menu states */ + #menubar.content > div:hover { + background-color: var(--blue-1); + } + .top-menu label { + color: var(--dark-menu-text) + } + #render-tools svg:hover { + background-color: var(--blue-1); + border-color: var(--blue-3); + } + .top-menu > span:hover { + background-color: var(--blue-1); + } + .top-menu > span:hover label, + .top-menu > span:hover svg { + color: var(--dark-menu-text) !important; + } + .top-menu > span.selected, + .top-menu > span.selected svg, + .top-menu-drop div.selected { + background-color: var(--blue-1); + color: #fff; + } + /* status + side widgets */ + #slider-hold { + background-color: var(--blue-0); + } + #stats { + color: #888; + } + #rnfo { + color: #aaa; + border: 1px solid rgba(255,255,255,0.25); + background-color: rgba(0,0,0,0.75); + } + #rnfo label { + color: #ada; + } + #slider-hold .handle { + background-color: #999; + } + #speedbar label { + color: black; + } + .widopt { + background-color: #222; + } + #camops summary { + background-color: var(--blue-0); + } + /* overlays */ + #wassup { + background-color: rgba(0,255,0,0.2); + } + .blurb p:first-child, + .blurb p:last-child { + color: #fff; + } + #curtain { + color: white; + background-color: black; + } + /* dialogs + print */ + #op-add-list > div:hover { + color: black; + background-color: var(--blue-2) !important; + } + .mod-x { + border-color: #555; + background-color: rgba(0,0,0,0.15); + } + #mod-help a:hover { + color: #333; + } + .mod-print .box { + background-color: rgba(0,0,0,0.15); + border-color: #555; + } + .mod-print button { + background-color: #555; + border-color: #777; + } + /* layer controls */ + #layer-animate button { + border: 1px solid #555; + background-color: #444; + } + #layer-animate button:hover { + background-color: var(--main-color); + } + #layer-animate button svg { + color: #ddd; + } + #layer-animate label { + border: 1px solid #555; + background-color: #444; + } + #layer-animate input { + border: 1px solid #444; + } } /* top menu bar, drop menus */ -#top, #app-name { +#app-name { background-color: #fff; text-transform: capitalize; font-family: sans-serif; z-index: 15; - height: 36px; + color: var(--appname-blue); + font-weight: bold; + align-self: center; + justify-self: stretch; + height: 100%; } .top-menu { display: flex; gap: 0px; - align-items: center; white-space: nowrap; align-items: stretch; } .top-menu > span { + position: relative; + display: flex; + gap: 5px; + align-items: center; padding: 5px 9px 5px 9px; border-left: 1px solid transparent; border-right: 1px solid transparent; @@ -475,12 +415,6 @@ details[open] summary::after { .top-menu svg { color: var(--blue-1); } -.top-menu > span { - position: relative; - display: flex; - gap: 5px; - align-items: center; -} .top-menu > span:hover label { color: #000; } @@ -500,16 +434,17 @@ details[open] summary::after { .top-menu > span:hover .top-menu-drop { display: flex; } -.top-menu-left .content { +/* .top-menu-right .content { border-top-right-radius: 4px; -} -.top-menu-right { align-items: self-end; +} */ +.top-menu-left { + align-items: self-start; } .top-menu-center .content > div > label { text-align: center !important; } -.top-menu-right .content { +.top-menu-left .content { border-top-left-radius: 4px; } .top-menu-drop { @@ -525,11 +460,12 @@ details[open] summary::after { width: 15px; } -#top .content { - gap: 2px; +#menubar.content { display: flex; - align-items: center; flex-direction: column; + align-items: stretch; + gap: 2px; + padding: 2px; background-color: #fff; border-top: 1px dashed var(--blue-2); border-left: 1px solid var(--blue-2); @@ -538,65 +474,27 @@ details[open] summary::after { border-bottom-right-radius: 4px; border-bottom-left-radius: 4px; } -#top .content { - display: flex; - flex-direction: column; - align-items: stretch; - padding: 2px; -} -#top .content > div { +#menubar.content > div { display: flex; flex-direction: row; } -#top .content > div > label { - text-align: left; - flex-grow: 1; -} -#top .content > div > span { - text-align: right; -} -#top .content > div > span { +#menubar.content > div > span { padding: 2px 7px 2px 7px; padding-left: 10px; text-align: center; } -#top .content > div > label { +#menubar.content > div > label { + text-align: left; + flex-grow: 1; padding: 2px 7px 2px 7px; justify-self: stretch; align-self: stretch; border-radius: 3px; } -#top .content > div:hover { +#menubar.content > div:hover { background-color: var(--blue-4); } -#app-name { - position: absolute; - top: 0; - left: 50%; - z-index: 50; - transform: translateX(-50%); -} -#app-name { - cursor: pointer; - -} -#app-name hr { - margin: 1px; -} -#app-name:hover #app-name-pop { - display: flex; -} -#app-name-pop { - color: var(--fg-gray); - font-family: sans-serif; - position: absolute; - top: 100%; -} -#app-name-pop .content { - text-align: center; -} - #app,div { display: flex; padding: 0; @@ -815,9 +713,6 @@ details[open] summary::after { border-top: 5px solid rgba(0,255,100,0.5); border-bottom: 5px solid rgba(0,255,100,0.5); } -.dark #wassup { - background-color: rgba(0,255,0,0.2); -} .blurb { max-width: 200px; align-items: flex-end; @@ -830,9 +725,6 @@ details[open] summary::after { .blurb p { margin: 0; } -.dark .blurb p:first-child, .dark .blurb p:last-child { - color: #fff; -} .blurb p:first-child, .blurb p:last-child { color: #000; } @@ -852,6 +744,7 @@ details[open] summary::after { z-index: 1000; } + #tracker { top: 0; left: 0; @@ -894,56 +787,6 @@ details[open] summary::after { min-width: 100px; background-color: var(--bg-gray-lt); } -#mode-info { - white-space: nowrap; - font-size: smaller; - font-family: sans-serif; - align-items: stretch; - justify-content: center; - padding: 2px 0 2px 0; - display: grid !important; - margin-right: 0 !important; - margin-bottom: 2px !important; - grid-template-columns: auto 1fr; - row-gap: 1px; -} -#mode-info > div { - display: contents; -} -#mode-info label, #mesh-info label { - font-size: smaller; - font-weight: bold; - align-self: right; - margin: 0 0 0 5px; - padding: 3px 5px 3px 5px; - color: var(--fg-gray); - background-color: var(--blue-4); - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - border-top: 1px solid var(--blue-3); - border-left: 1px solid var(--blue-3); - border-right: 0; - border-bottom: 1px solid var(--blue-3); -} -#mode-info span, #mesh-info span { - font-size: smaller; - margin: 0 5px 0 0; - padding: 3px 5px 3px 5px; - color: var(--fg-gray); - background-color: white; - border-top-left-radius: 0; - border-top-right-radius: 5px; - border-bottom-left-radius: 0; - border-bottom-right-radius: 5px; - border-top: 1px solid var(--blue-3); - border-left: 0; - border-right: 1px solid var(--blue-3); - border-bottom: 1px solid var(--blue-3); -} -#mode-device, #mode-profile { - max-width: 100px; - overflow: hidden; -} #doit { display: none; @@ -984,59 +827,41 @@ details[open] summary::after { text-transform: none !important; } -.slideshow #panel-left .settings { - transform: translateX(-100%); - transition: transform 0.1s ease-in-out; - transition-delay: 0.3s; -} -.slideshow #panel-left:hover .settings { - transform: translateX(0); - transition-delay: 0.0s; -} -.slideshow #panel-left:hover #slide-show { - transform: translateX(-100%); - transition-delay: 0.0s; -} -.slideshow #slide-show { - transition-delay: 0.5s; - transform: translateX(-80%); - /* transform: translateX(0); */ - position: absolute; - top: 5px; - left: 0; - bottom: 5px; - width: 25px; - background-color: var(--slide-bg); - border-top: 1px solid var(--blue-1); - border-right: 1px solid var(--blue-1); - border-bottom: 1px solid var(--blue-1); - border-bottom-right-radius: 4px; - border-top-right-radius: 4px; - z-index: -1; -} -.slideshow #slide-show span { - display: flex; -} -#slide-show { - flex-direction: row; - align-items: center; - justify-content: center;; -} -#slide-show span { - display: none; -} #panel-right { overflow: unset; } -/* #panel-right { - direction: rtl; +#ws-widgets { + /* max-height: 250px; + overflow-y: auto; */ +} +#ws-widgets, #camops, #fdm-ranges { + padding: 0 4px 0 4px; +} +#camops { + padding-bottom: 2px; } -#panel-right * { - direction: ltr; -} */ #panel-left { padding-right: 5px; } +#panel-right > div.bottom, .set2-group.bottom { + border-bottom: 1px solid var(--border-gray); +} +.slideshow #panel-slide { + padding-right: 1px; + border-right: 20px solid rgba(128,128,128,0.3); + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + transform: translateX(calc(-100% + 20px)); + transition: transform 0.15s ease-in-out, border 0.15s ease-in-out; +} +.slideshow #panel-slide:hover { + padding-right: 0px; + transition: transform 0.15s ease-in-out, border 0.15s ease-in-out; + transform: translateX(0); + border-right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} #panel-left, #panel-right { max-height: 100%; background-color: var(--work-area); @@ -1073,16 +898,6 @@ details[open] summary::after { .widopt button.disabled { background-color: var(--blue-3); } -#top-sep { - display: none; -} -#top-sep { - z-index: 14; - height: 1px; - min-height: 1px; - max-height: 1px; - background-image: var(--gradbar); -} .line-sep2 { z-index: 14; height: 1px; @@ -1152,59 +967,43 @@ details[open] summary::after { background-color: var(--main-color); color: white; } - -/* top center toolbar */ -#render-tools { +/* mode toolbar */ +#mode-tools { pointer-events: all; - position: absolute; - gap: 2px; - top: 3px; + gap: 3px; + top: 0; left: 0; right: 0; align-items: center; justify-content: center; flex-direction: row; } -#render-tools span { - position: relative; +#mode-tools > span { + text-transform: capitalize; + display: flex; + flex-direction: row; + align-items: center; + color: var(--menubar-fore); + background-color: var(--menubar-back); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-left: 1px solid var(--border-gray); + border-right: 1px solid var(--border-gray); + border-bottom: 1px solid var(--border-gray); + padding: 4px 8px 4px 8px; } -#render-tools svg { - font-size: 20px; +#mode-tools svg { aspect-ratio: 1/1; - background-color: #fff; - border: 1px solid #aaa; - border-radius: 3px; - padding: 8px; + font-size: 20px; + padding: 0 5px 0 0; } -#render-tools svg:hover { - border: 1px solid blue; - background-color: var(--blue-4); +#mode-tools > span:hover { + border-color: var(--appname-blue); + background-color: var(--menu-hover-bg); } -/** tool pop menus */ -#render-tools .pop:before { - position: absolute; - top: -10px; - left: -20px; - width: calc(100% + 40px); - height: calc(100% + 20px); - /* background-color: red; */ - content: ""; -} -#render-tools .pop { - background-color: #fff; - border: 1px solid #aaa; - border-radius: 3px; - position: absolute; - margin-top: 3px; - padding: 3px; - top: 100%; - left: 50%; - transform: translateX(-50%); - text-align: center; - white-space: nowrap; -} -#render-tools > span:not(:hover) .pop { - display: none; +#mode-tools > span.selected { + border-color: var(--appname-blue); + background-color: var(--menu-active-bg); } /** fdm extruder drop-down */ #ft-nozzle .splat { @@ -1218,6 +1017,61 @@ details[open] summary::after { flex-direction: row; align-items: center; } + +/* draggable selection transform panels */ +.selection-panel { + position: fixed; + display: flex; + flex-direction: column; + z-index: 80; + min-width: 230px; + border: 1px solid var(--border-gray); + border-radius: 6px; + background-color: var(--menubar-back); + color: var(--menubar-fore); + box-shadow: 0 8px 22px rgba(0,0,0,0.25); + pointer-events: all; +} +#panel-rotate { + top: 92px; + right: 26px; +} +#panel-scale { + top: 270px; + right: 26px; +} +.selection-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 10px; + border-bottom: 1px solid var(--border-gray); + cursor: move; + user-select: none; +} +.selection-panel-head-title { + display: flex; + align-items: center; + gap: 8px; +} +.selection-panel-head label { + font-weight: 600; +} +.selection-panel-close { + border: none; + background: transparent; + color: inherit; + border-radius: 4px; + padding: 2px 6px; + cursor: pointer; +} +.selection-panel-close:hover { + background-color: var(--menu-hover-bg); +} +.selection-panel-body { + padding: 8px; +} + /** selection rotation */ #ft-rotate { gap: 2px; @@ -1383,7 +1237,6 @@ details[open] summary::after { } #camops { display: flex; - padding: 0 0 0 0; } #oplist { max-height: calc(70vh - 200px); @@ -1393,7 +1246,7 @@ details[open] summary::after { } .cam-pop-op { top: 0; - right: calc(100% + 4px); + right: calc(100% - 4px); text-align: left; position: absolute; white-space: nowrap; @@ -1472,10 +1325,11 @@ details[open] summary::after { align-items: left; justify-content: center; border: 1px solid rgba(0,0,0,0.25); - border-radius: 4px; + border-radius: 3px; padding: 5px 10px 5px 10px; background-color: rgba(255,255,255,0.75); white-space: nowrap; + font-size: smaller; } #oplist > div:hover, #op-add:hover { background-color: var(--blue-4); @@ -1533,8 +1387,28 @@ details[open] summary::after { position: absolute; display: none; top: 50%; - right: 100%; + right: calc(100% - 4px); transform: translateY(-50%); + isolation: isolate; +} +#op-add-pop::before { + content: ""; + position: absolute; + top: -50px; + left: -50px; + right: 0; + bottom: -50px; + /* background-color: red; */ +} +#op-add-pop::after { + content: ""; + position: absolute; + top: 50%; + left: -50px; + right: -100px; + bottom: -50px; + /* background-color: green; */ + z-index: -1; } #op-add:hover #op-add-pop { display: flex; @@ -1557,15 +1431,12 @@ details[open] summary::after { #op-add-list > div { padding: 3px 5px 3px 5px; display: flex; + font-size: 14px; flex-direction: column; border: 1px dashed gray; border-radius: 3px; text-align: center; } -.dark #op-add-list > div:hover { - color: black; - background-color: var(--blue-2) !important; -} #op-add-list > div:hover { background-color: var(--blue-4); } @@ -1575,39 +1446,75 @@ details[open] summary::after { top: 0; } -#progress { - left: 0; - right: 0; - height: 6px; - min-height: 6px; - text-align: center; - background-color: rgb(220,220,220); - border-top: 1px solid #888; - border-bottom: 1px solid #888; -} -#progbar { - width: 0; - text-align: left; - position: relative; - background-color: rgb(255,90,90); -} -#progtxt { - display: none; - position: absolute; - left: 0; - right: 0; - top: 200%; - color: #888; - margin: 0 5px 0 5px; - padding: 0 5px 2px 5px; - white-space: nowrap; - flex-direction: row; +#progress-overlay { + position: fixed; + inset: 0; + z-index: 3000; + display: flex; align-items: center; justify-content: center; + pointer-events: auto; + background: rgba(0,0,0,0.08); +} + +#progress-indicator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 120px; + padding: 14px 18px 12px; + border: 1px solid var(--border-gray); + border-radius: 10px; + background: var(--menubar-back); + color: var(--menubar-fore); + box-shadow: 0 8px 24px rgba(0,0,0,0.25); +} + +#progress-ring { + --progress-deg: 0deg; + width: 56px; + height: 56px; + border-radius: 50%; + background: + radial-gradient(circle at center, transparent 58%, var(--menubar-back) 59%), + conic-gradient(var(--appname-blue) var(--progress-deg), rgba(128,128,128,0.25) 0); +} + +#progress-ring.indeterminate { + background: none; + border: 5px solid rgba(128,128,128,0.3); + border-top-color: var(--appname-blue); + animation: progress-spin 0.9s linear infinite; +} + +#progress-pct { + min-height: 16px; + font-size: 12px; + font-weight: 600; + color: var(--menubar-fore); +} + +#progtxt { + min-height: 16px; + max-width: 280px; + text-align: center; + font-size: 12px; + color: var(--menubar-fore); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +@keyframes progress-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } #dialog { position: fixed; + z-index: 310; outline: none; color: black; top: 100px; @@ -1626,7 +1533,7 @@ details[open] summary::after { #modal { display: none; position: fixed; - z-index: 20; + z-index: 300; top: 50%; left: 0px; right: 0px; @@ -1638,9 +1545,6 @@ details[open] summary::after { border-top: 1px solid black; overflow: hidden; } -#modal button, #modal select { - /* color: black !important; */ -} #modal > div { position: absolute; transform: translateX(50%) translateY(-50%); @@ -1665,10 +1569,6 @@ details[open] summary::after { #tool-cols { max-height: 350px; } -.dark .mod-x { - border-color: #555; - background-color: rgba(0,0,0,0.15); -} .mod-x { border-radius: 5px; border: 2px solid rgba(255,255,255,0.5); @@ -1725,9 +1625,6 @@ details[open] summary::after { align-items: center; justify-content: flex-start; } -.dark #mod-help a:hover { - color: #333; -} #mod-help a:hover { background-color: #eee; } @@ -1739,10 +1636,6 @@ details[open] summary::after { font-size: larger; padding-bottom: 10px; } -#kiri-version { - font-family: monospace; - padding-top: 5px; -} #export-support { padding-top: 10px; text-align: center; @@ -1765,10 +1658,6 @@ details[open] summary::after { font-weight: normal; padding-top: 5px; } -.dark .mod-print .box { - background-color: rgba(0,0,0,0.15); - border-color: #555; -} .mod-print .box { background-color: rgba(255,255,255,0.5); border: 1px solid #bbb; @@ -1804,10 +1693,6 @@ details[open] summary::after { font-weight: bold; padding-right: 10px; } -.dark .mod-print button { - background-color: #555; - border-color: #777; -} .mod-print button { flex-direction: column; justify-content: center; @@ -2027,6 +1912,42 @@ details[open] summary::after { margin: 0 10px 0 10px; text-align: left; } +.image-convert-dialog { + gap: 8px; + padding: 10px 14px 6px; + min-width: 360px; +} +.image-convert-title { + margin: 0; + text-align: center; +} +.image-convert-copy { + width: 320px; + line-height: 1.5em; + padding: 5px 0 5px 0; + margin: 0; +} +.image-convert-fields { + justify-content: center; +} +.image-convert-fields table { + border-collapse: collapse; +} +.image-convert-fields th, +.image-convert-fields td { + padding: 3px 6px; + vertical-align: middle; +} +.image-convert-fields th { + font-weight: 600; +} +.image-convert-fields input[type="checkbox"] { + margin: 0; +} +.image-convert-actions { + width: 100%; + margin-top: 4px; +} .dev-type { font-family: 'Russo One', sans-serif; @@ -2123,23 +2044,6 @@ details[open] summary::after { color: #335; } -.dark #layer-animate button { - border: 1px solid #555; - background-color: #444; -} -.dark #layer-animate button:hover { - background-color: var(--main-color); -} -.dark #layer-animate button svg { - color: #ddd; -} -.dark #layer-animate label { - border: 1px solid #555; - background-color: #444; -} -.dark #layer-animate input { - border: 1px solid #444; -} #layer-animate { pointer-events: all; position: absolute; @@ -2176,34 +2080,6 @@ details[open] summary::after { border-radius: 4px; } -/* #layer-toolpos { - position: absolute; - right: 3px; - bottom: 3px; - z-index: 201; -} -#layer-toolpos label, #layer-toolpos input { - display: inline-flex; - height: 30px; - border-radius: 3px; - border: 1px solid #ccc; - justify-content: center; - align-items: center; - font-weight: bold; - background-color: #ddd; -} -#layer-toolpos label { - width: 30px; - padding: 1px 1px 1px 1px; - margin: 1px 1px 2px 6px; -} -#layer-toolpos input { - background-color: #eee; - font-family: monospace; - padding: 1px 4px 1px 4px; - margin: 1px 1px 2px 1px; -} */ - #layer-slider { position: absolute; font-size: smaller; @@ -2360,6 +2236,7 @@ details[open] summary::after { } .settings { + gap: 3px; max-height: 100%; overflow-y: auto; overflow-x: hidden; @@ -2379,16 +2256,20 @@ details[open] summary::after { #dev-list:hover { background-color: var(--blue-4); } +#panel-left .set2-group { + border-right: 1px solid var(--border-gray); +} +#panel-right .set2-group { + border-left: 1px solid var(--border-gray); +} .set2-group { - border-radius: 3px; - border: 1px solid #ddd !important; - background-color: #fff; + background-color: var(--menubar-back); flex-flow: column; - padding: 4px; - margin: 2px; + padding: 0 0 2px 0; + /* border-bottom: 1px solid var(--border-gray); */ } .set2-group .var-row, .var-row { - background: #fff; + background: var(--menubar-back); padding: 1px 4px 1px 4px; margin: 0 !important; border-left: 3px solid transparent; @@ -2418,12 +2299,11 @@ details[open] summary::after { background-color: #bbb !important; } .set-header { + overflow: hidden; white-space: nowrap; - border-radius: 2px; justify-content: left; background-color: var(--blue-3); border-left: 3px solid transparent; - border-right: 3px solid var(--main-color-hover); border-bottom: 0.5px solid var(--main-color-hover); padding: 4px; margin: 0 0 4px 0; @@ -2454,7 +2334,7 @@ details[open] summary::after { } .settings .ext-buttons { margin: 0 4px 0 4px; - gap: 3px; + gap: 2px; } #layers .var-row { border-radius: 10px; @@ -2475,6 +2355,12 @@ details[open] summary::after { #layers button:hover { background-color: var(--main-color); } + +.tiny.var-row select { + width: 10em; + max-width: 10em; + font-size: smaller; +} .var-row { white-space: nowrap; align-items: center; @@ -2493,7 +2379,6 @@ details[open] summary::after { justify-self: center; } .var-row input { - /* flex: 1 1 auto; */ min-width: 0; max-width: 7ch; margin-right: 0; @@ -2501,6 +2386,11 @@ details[open] summary::after { padding-bottom: 1px; padding-top: 1px; } +.var-row select { + border: 0; + border-radius: 0; + text-align: right; +} .var-row #tool-name { max-width: 15ch; } @@ -2650,4 +2540,8 @@ details[open] summary::after { .devel #code-preview > div, .devel #code-preview-textarea { height: 100%; -} \ No newline at end of file +} + +.em20 { + max-width: 20em; +} diff --git a/web/kiri/index.html b/web/kiri/index.html index 5e858104..ed3866da 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -1,5 +1,5 @@ - + @@ -14,355 +14,58 @@ - + Kiri:Moto - - + + + + + + - + - - - + + +
Kiri:Moto is Loading
- -
- -
-
- - - - - - - - - - - - - - - - - - - - -
-
-
- - Kiri:Moto -
-
-
- -
-
-
- -
-
-
- -
-
- -
-
- -
-
-
-
-
-
- - -
-
-
- - - -
-
-
- - -
-
-
- - -
-
- - - - -
-
-
- - -
-
-
-
- - - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
- - - -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- - - -
-
-
- -
-
- -
-
-
-
- - -
-
-
-
-
-
-
-
-
-
-
-
-
+ + + +
+
+
+
+
- -
loading
- -
-
- -
-
- -
-
- -
- -
- -
- -
- -
- -
- -
-
-
- -
-
-
-
- - - - - - - - - -
- -
-
-
- -
-
-
-
-
-
-
-
-
- - -
mirror selection
-
- - -
duplicate selection
-
- - -
choose a face to place
on the work surface
-
- - -
choose a face to align
to the y axis
-
- - -
choose focal point
camera orbit center
-
- - -
fit workspace contents
-
- - -
render objects solid
-
- - -
render objects wireframe
-
- - -
render objects transparent
-
- - -
trace edges
-
-
-
+
- -
-
+
+
@@ -373,30 +76,28 @@
-
+
-
+
-
+
-
+
-
-
@@ -422,7 +123,6 @@
-
@@ -432,21 +132,17 @@
-
-
-
-
-
+ -
+
operation list
@@ -478,9 +174,6 @@
-
-
-
diff --git a/web/kiri/lang/da.js b/web/kiri/lang/da.js index b98ac240..0ebbbea4 100644 --- a/web/kiri/lang/da.js +++ b/web/kiri/lang/da.js @@ -65,6 +65,18 @@ self.lang['da-dk'] = { view: "vis", // left view pop menu wire: "tråd", // left render pop menu volume: "volumen", // device build area + al_menu: "juster", + re_menu: "render", + sx_menu: "markering", + ed_menu: "rediger", + face_left: "venstre side", + re_edgs: "skift kanter", + al_menu: "juster", + re_menu: "render", + sx_menu: "markering", + ed_menu: "rediger", + face_left: "venstre side", + re_edgs: "skift kanter", acct_xpo: ["lav en sikkerhedskopi af din enhed","og enhedsprofiler med mulighed for at","medtage arbejdsområdeobjekter og positioner"], @@ -80,8 +92,8 @@ self.lang['da-dk'] = { rc_xobj: "eksporter OBJ", rc_xstl: "eksporter STL", sb_info: ["print hastighed","i mm/s"], - rc_merg: "flet objekt meshes", - rc_splt: "isoler legemer", + rc_merg: "flet objekter", + rc_splt: "isoler objekter", // DEVICE MENU and related dialogs dm_sldt: "vælg en enhedstype", @@ -712,6 +724,10 @@ self.lang['da-dk'] = { ct_nabl_s: "auto", ct_nabl_l: ["auto generer radiale tapper","projekteret fra del center","ved brug af antal og vinkel forskydning"], + // CNC AREA OP + ca_fini_s: "efterbehandlings snit", + ca_fini_l: "skærebane der følger konturen af den endelige del. tillader præcis step over ved rydning af større område.", + // OUTPUT ou_menu: "output", diff --git a/web/kiri/lang/de-de.js b/web/kiri/lang/de-de.js index 96329723..b4dce2ca 100644 --- a/web/kiri/lang/de-de.js +++ b/web/kiri/lang/de-de.js @@ -66,6 +66,18 @@ self.lang['de-de'] = { view: "Ansicht", // left view pop menu wire: "Drahtansicht", // left render pop menu volume: "Volumen", // device build area + al_menu: "ausrichten", + re_menu: "rendern", + sx_menu: "auswahl", + ed_menu: "Bearbeiten", + face_left: "linke seite", + re_edgs: "kanten umschalten", + al_menu: "ausrichten", + re_menu: "rendern", + sx_menu: "auswahl", + ed_menu: "Bearbeiten", + face_left: "linke seite", + re_edgs: "kanten umschalten", acct_xpo: ["Lege ein Backup Deiner Geräte","und Geräte-Profile an, mit der","Option, den Arbeitsbereich und","mit Objekten und Anordnung zu speichern"], @@ -81,8 +93,8 @@ self.lang['de-de'] = { rc_xobj: "Als OBJ exportieren", rc_xstl: "Als STL exportieren", sb_info: ["Druckgeschwindigkeit","in mm/s"], - rc_merg: "Objekt-Meshes zusammenführen", - rc_splt: "Körper isolieren", + rc_merg: "Objekte zusammenführen", + rc_splt: "Objekte isolieren", // DEVICE MENU and related dialogs dm_sldt: "Wähle einen Geräte-Typ aus", @@ -713,6 +725,10 @@ self.lang['de-de'] = { ct_nabl_s: "Auto", ct_nabl_l: ["Radiale Haltestege automatisch generieren","projiziert von Teil-Mitte","mit Anzahl und Winkel-Offset"], + // CNC AREA OP + ca_fini_s: "Schlichtwerkzeugweg", + ca_fini_l: "Schneidpfad, der der Kontur des Endteils folgt. ermöglicht präzisen Vorschub beim Räumen größerer Bereiche.", + // OUTPUT ou_menu: "Ausgabe", diff --git a/web/kiri/lang/en.js b/web/kiri/lang/en.js index 58546d42..a6bbf3fc 100644 --- a/web/kiri/lang/en.js +++ b/web/kiri/lang/en.js @@ -67,6 +67,18 @@ self.lang['en-us'] = { view: "view", // left view pop menu wire: "wire", // left render pop menu volume: "volume", // device build area + al_menu: "align", + re_menu: "render", + sx_menu: "selection", + ed_menu: "edit", + face_left: "face left", + re_edgs: "toggle edges", + al_menu: "align", + re_menu: "render", + sx_menu: "selection", + ed_menu: "edit", + face_left: "face left", + re_edgs: "toggle edges", acct_xpo: ["make a backup of your device","and device profiles with the","option to include workspace","objects and positions"], @@ -82,8 +94,8 @@ self.lang['en-us'] = { rc_xobj: "export OBJ", rc_xstl: "export STL", sb_info: ["print speed","in mm/s"], - rc_merg: "merge object meshes", - rc_splt: "isolate bodies", + rc_merg: "merge objects", + rc_splt: "isolate objects", // DEVICE MENU and related dialogs dm_sldt: "select a device type", @@ -472,7 +484,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", @@ -504,6 +516,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", @@ -549,6 +563,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", @@ -724,6 +740,8 @@ self.lang['en-us'] = { ca_sang_l: "crossing angle on the XY plane for contour lines", ca_wall_s: "walls", ca_wall_l: ["perform a single pass along vertical faces. leave interior spaces. similar to outline operation"], + ca_fini_s: "finish cut", + ca_fini_l: "cutting path that follows the contour of the final part. allows for precise step over when clearing a larger area.", // OUTPUT ou_menu: "output", diff --git a/web/kiri/lang/es-es.js b/web/kiri/lang/es-es.js index 62092d42..80557311 100644 --- a/web/kiri/lang/es-es.js +++ b/web/kiri/lang/es-es.js @@ -66,6 +66,18 @@ self.lang['es-es'] = { view: "vista", // left view pop menu wire: "alambre", // left render pop menu volume: "volumen", // device build area + al_menu: "alinear", + re_menu: "renderizar", + sx_menu: "selección", + ed_menu: "editar", + face_left: "cara izquierda", + re_edgs: "alternar bordes", + al_menu: "alinear", + re_menu: "renderizar", + sx_menu: "selección", + ed_menu: "editar", + face_left: "cara izquierda", + re_edgs: "alternar bordes", acct_xpo: ["hace una copia de seguridad de su dispositivo","y perfiles de dispositivo con la ","opción de incluir espacio de trabajo","objetos y posiciones"], @@ -81,8 +93,8 @@ self.lang['es-es'] = { rc_xobj: "exportar OBJ", rc_xstl: "exportar STL", sb_info: ["velocidad de impresión","en mm/s"], - rc_merg: "fusionar mallas de objeto", - rc_splt: "aislar cuerpos", + rc_merg: "fusionar objetos", + rc_splt: "aislar objetos", // DEVICE MENU and related dialogs dm_sldt: "seleccionar un tipo de dispositivo", @@ -713,6 +725,10 @@ self.lang['es-es'] = { ct_nabl_s: "automático", ct_nabl_l: ["generar automáticamente pestañas radiales","proyectadas desde el centro de la pieza","usando cantidad y desplazamiento de ángulo"], + // CNC AREA OP + ca_fini_s: "corte de acabado", + ca_fini_l: "trayectoria de corte que sigue el contorno de la pieza final. permite un paso preciso al limpiar un área más grande.", + // OUTPUT ou_menu: "salida", diff --git a/web/kiri/lang/fr.js b/web/kiri/lang/fr.js index 17f60a5a..bf192c1a 100644 --- a/web/kiri/lang/fr.js +++ b/web/kiri/lang/fr.js @@ -66,6 +66,18 @@ self.lang['fr-fr'] = { view: "vue", // left view pop menu wire: "filaire", // left render pop menu volume: "volume", // device build area + al_menu: "aligner", + re_menu: "rendu", + sx_menu: "sélection", + ed_menu: "éditer", + face_left: "face gauche", + re_edgs: "basculer arêtes", + al_menu: "aligner", + re_menu: "rendu", + sx_menu: "sélection", + ed_menu: "éditer", + face_left: "face gauche", + re_edgs: "basculer arêtes", acct_xpo: ["faites une sauvegarde de votre appareil","et des profils d'appareil avec","l'option d'inclure les objets","de l'espace de travail et les positions"], @@ -81,8 +93,8 @@ self.lang['fr-fr'] = { rc_xobj: "exporter OBJ", rc_xstl: "exporter STL", sb_info: ["vitesse d'impression","en mm/s"], - rc_merg: "fusionner les maillages d'objets", - rc_splt: "isoler les corps", + rc_merg: "fusionner les objets", + rc_splt: "isoler les objets", // DEVICE MENU and related dialogs dm_sldt: "sélectionner un type d'appareil", @@ -713,6 +725,10 @@ self.lang['fr-fr'] = { ct_nabl_s: "auto", ct_nabl_l: ["générer automatiquement des pattes radiales","projetées depuis le centre de la pièce","en utilisant le nombre et le décalage d'angle"], + // CNC AREA OP + ca_fini_s: "coupe de finition", + ca_fini_l: "chemin de coupe qui suit le contour de la pièce finale. permet un pas précis lors du dégagement d'une zone plus large.", + // OUTPUT ou_menu: "sortie", diff --git a/web/kiri/lang/pl-pl.js b/web/kiri/lang/pl-pl.js index 4134e581..057d423b 100644 --- a/web/kiri/lang/pl-pl.js +++ b/web/kiri/lang/pl-pl.js @@ -65,6 +65,18 @@ self.lang['pl-pl'] = { view: "widok", // left view pop menu wire: "szkielet", // left render pop menu volume: "objętość", // device build area + al_menu: "wyrównaj", + re_menu: "renderuj", + sx_menu: "zaznaczenie", + ed_menu: "edytuj", + face_left: "lewa ściana", + re_edgs: "przełącz krawędzie", + al_menu: "wyrównaj", + re_menu: "renderuj", + sx_menu: "zaznaczenie", + ed_menu: "edytuj", + face_left: "lewa ściana", + re_edgs: "przełącz krawędzie", acct_xpo: ["zrób kopię zapasową urządzenia","i profilów urządzenia z","opcją dołączenia obiektów","w przestrzeni roboczej i ich pozycji"], @@ -80,8 +92,8 @@ self.lang['pl-pl'] = { rc_xobj: "eksportuj OBJ", rc_xstl: "eksportuj STL", sb_info: ["prędkość druku","w mm/s"], - rc_merg: "połącz siatki obiektu", - rc_splt: "wyizoluj bryły", + rc_merg: "połącz obiekty", + rc_splt: "wyizoluj obiekty", // DEVICE MENU and related dialogs dm_sldt: "wybierz typ urządzenia", @@ -712,6 +724,10 @@ self.lang['pl-pl'] = { ct_nabl_s: "auto", ct_nabl_l: ["automatycznie generuj wypustki promieniowe","rzutowane od środka części","używając liczby i przesunięcia kąta"], + // CNC AREA OP + ca_fini_s: "cięcie wykończeniowe", + ca_fini_l: "ścieżka cięcia podążająca za konturem ostatecznej części. pozwala na precyzyjny krok przy usuwaniu większego obszaru.", + // OUTPUT ou_menu: "wyjście", diff --git a/web/kiri/lang/pt.js b/web/kiri/lang/pt.js index 447c5da8..ad63468b 100644 --- a/web/kiri/lang/pt.js +++ b/web/kiri/lang/pt.js @@ -66,6 +66,18 @@ self.lang['pt-pt'] = { view: "visualizar", // left view pop menu wire: "aramado", // left render pop menu volume: "volume", // device build area + al_menu: "alinhar", + re_menu: "renderizar", + sx_menu: "seleção", + ed_menu: "editar", + face_left: "face esquerda", + re_edgs: "alternar arestas", + al_menu: "alinhar", + re_menu: "renderizar", + sx_menu: "seleção", + ed_menu: "editar", + face_left: "face esquerda", + re_edgs: "alternar arestas", acct_xpo: ["faça uma cópia de segurança do seu dispositivo","e perfis de dispositivo com a opção","de incluir objectos e posições","do espaço de trabalho"], @@ -81,8 +93,8 @@ self.lang['pt-pt'] = { rc_xobj: "exportar OBJ", rc_xstl: "exportar STL", sb_info: ["velocidade de impressão","em mm/s"], - rc_merg: "fundir malhas de objecto", - rc_splt: "isolar corpos", + rc_merg: "fundir objectos", + rc_splt: "isolar objectos", // DEVICE MENU and related dialogs dm_sldt: "seleccione um tipo de dispositivo", @@ -713,6 +725,10 @@ self.lang['pt-pt'] = { ct_nabl_s: "automático", ct_nabl_l: ["gerar automaticamente abas radiais","projectadas do centro da peça","usando número e desvio de ângulo"], + // CNC AREA OP + ca_fini_s: "corte de acabamento", + ca_fini_l: "caminho de corte que segue o contorno da peça final. permite passo preciso ao limpar uma área maior.", + // OUTPUT ou_menu: "saída", diff --git a/web/kiri/lang/zh.js b/web/kiri/lang/zh.js index c34b6d9e..7313f976 100644 --- a/web/kiri/lang/zh.js +++ b/web/kiri/lang/zh.js @@ -65,6 +65,12 @@ self.lang['zh'] = { view: "视图", // 左侧视图弹出菜单 wire: "线框", // 左侧渲染弹出菜单 volume: "体积", // 设备构建区域 + al_menu: "对齐", + re_menu: "渲染", + sx_menu: "选择", + ed_menu: "编辑", + face_left: "面朝左", + re_edgs: "切换边线", acct_xpo: ["备份您的设备及","设备配置文件,","可选择包括工作区、","对象及位置"], @@ -80,8 +86,8 @@ self.lang['zh'] = { rc_xobj: "导出OBJ", rc_xstl: "导出STL", sb_info: ["打印速度","单位:毫米/秒"], - rc_merg: "合并对象网格", - rc_splt: "分离实体", + rc_merg: "合并对象", + rc_splt: "分离对象", // 设备菜单及相关对话框 dm_sldt: "选择设备类型", @@ -712,6 +718,10 @@ self.lang['zh'] = { ct_nabl_s: "自动", ct_nabl_l: ["自动生成径向连接片","从零件中心投影","使用数量和角度偏移"], + // 数控区域操作 + ca_fini_s: "精加工切削", + ca_fini_l: "遵循最终零件轮廓的切削路径。允许在清理较大区域时精确步进。", + // 输出 ou_menu: "输出", diff --git a/web/kiri/manifest.json b/web/kiri/manifest.json index b7ba182c..a1223bfb 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.7.0", "short_name": "Kiri:Moto", "description": "Slicer for 3D printers, CNC mills, laser cutters and more", "start_url": "/boot/index.html", @@ -9,7 +9,6 @@ "background_color": "#ffffff", "orientation": "landscape-primary", "icons": [ - { "src": "./logo-cube-512.png", "type": "image/png", "sizes": "512x512" }, - { "src": "./logo-cube-144.png", "type": "image/png", "sizes": "144x144" } + { "src": "/icon/kirimoto.png", "type": "image/png", "sizes": "512x512" } ] } diff --git a/web/mesh/index.css b/web/mesh/index.css index 218f306c..29a85e2d 100644 --- a/web/mesh/index.css +++ b/web/mesh/index.css @@ -1,11 +1,10 @@ :root { + --accent: #5a9fd4; --menu-blue: #0079ff; - --menu-back: rgba(255,255,255,0.55); - --dark-menu-back: rgba(80,80,80,0.75); - --border: #888; - --dark-border: #888; - --selected: rgba(0,255,0,0.5); - --selected-hover: rgba(0,255,0,0.8); + --menu-back: rgba(60,60,60,0.75); + --border: #444; + --selected: rgba(90,159,212,0.5); + --selected-hover: rgba(90,159,212,1); } @font-face { @@ -54,12 +53,18 @@ input { font-family: sans-serif; font-weight: normal; font-size: larger; + background: #0f1116; bottom: 0; right: 0; left: 0; top: 0; } +#app.booting #top, +#app.booting #app-body { + pointer-events: none; +} + #app-body { flex-grow: 1; } @@ -75,8 +80,13 @@ input { right: 0; bottom: 0; position: fixed; - background-color: #fff; + background: radial-gradient(circle at 50% 40%, #1a2436 0%, #0f1116 60%, #0a0c11 100%); + color: #d7deea; font-family: 'Russo One', sans-serif; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 1px 2px rgba(0,0,0,0.35); justify-content: center; align-items: center; } @@ -84,6 +94,7 @@ input { #container { z-index: 1; position: fixed; + background: #0f1116; width: 100%; height: 100%; } @@ -94,14 +105,9 @@ input { overflow: hidden; } -.dark #top { - color: #eee; - background-color: var(--dark-menu-back); -} - #top { z-index: 50; - color: #000; + color: #eee; font-size: 14px; flex-direction: row; background-color: var(--menu-back); @@ -126,6 +132,10 @@ input { margin-bottom: -14px !important; } +#modal button { + border-radius: 3px; +} + button { padding: 2px 3px 2px 3px; border: 1px solid rgba(150,150,150,0.5); @@ -134,12 +144,8 @@ button { outline: none; } -.dark button:hover { - background-color: rgba(220,220,220,1); -} - button:hover { - background-color: rgba(210,210,210,1); + background-color: rgba(220,220,220,1); } /** misc ui **/ @@ -168,47 +174,85 @@ button:hover { padding: 0 !important; } -.menu, #top-right > div { +.title { + cursor: default; + align-self: stretch; + align-items: center; + margin: 4px 0 4px 0; + padding: 4px 12px 4px 12px; + border-radius: 0; + user-select: none; + font-weight: bold; + color: var(--accent); +} + +.menubar-separator { + align-self: stretch; + align-items: center; + background: rgba(255,255,255,0.2); + margin: 5px; + width: 1px; +} + +.toolbar-separator { + align-self: stretch; + align-items: center; + background: rgba(255,255,255,0.2); + margin: 2px; + height: 1px; +} + +.menu, #top-doc-name, #top-mode-label { cursor: default; align-self: stretch; align-items: center; padding: 8px 12px 8px 12px; + border: 1px solid transparent; border-radius: 6px; user-select: none; + margin: 2px; } -.dark .menu hr { +.menu hr { border-top: 0.5px solid rgba(255,255,255,0.5); } .menu:hover, .menu-items > div:hover, .tools i:hover { - background-color: var(--menu-blue); + border: 1px solid var(--accent); + background-color: rgba(0,0,0,0.5); } .menu:hover .menu-items { display: flex; } -.dark .menu-items { - background: #666; - border: 1px solid #999; -} - .menu-items { display: none; position: absolute; flex-direction: column; - border: 1px solid #bbb; + border: 1px solid var(--border); border-radius: 6px; - background: #eee; + background: var(--menu-back); padding: 4px; - top: 100%; + top: calc(100% + 3px); left: 0; gap: 6px; } +/* invisible bridge between menu label and pop menu */ +.menu-items::before { + content: ""; + position: absolute; + top: -20px; + left: -10px; + right: -10px; + height: calc(100% + 20px); + background: transparent; +} + .menu-items > div { gap: 15px; + border: 1px solid transparent; border-radius: 6px; padding: 4px 8px 4px 8px; } @@ -221,39 +265,6 @@ button:hover { display: flex; } -#mode-label { - display: none; - position: absolute; - text-transform: capitalize; - padding: 2px 4px 2px 4px; - background-color: rgba(0,200,0,0.35); - border: 1px solid rgba(0,0,0,0.25); - border-radius: 3px; - transform: translateX(-50%); - top: calc(100% + 2px); - left: 50%; -} - -#mode-label:before { - content: ''; - position: absolute; - top: -10px; /* Adjust to position the caret above the menu */ - left: 50%; - transform: translateX(-50%); - border-width: 5px; - border-style: solid; - border-color: transparent transparent #777 transparent; -} - -.dark #mode-label:before { - border-color: transparent transparent #ddd transparent; -} - -.dark #mode-label { - background-color: rgba(0,200,0,0.65); - border-color: rgba(255,255,255,0.25); -} - #top-mid { flex-grow: 1; } @@ -268,6 +279,30 @@ button:hover { gap: 8px; } +#top-mode-label { + color: var(--accent); + white-space: nowrap; + justify-content: flex-end; + text-transform: capitalize; +} + +#top-doc-name { + cursor: pointer; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + justify-content: flex-end; + border-color: #404040; + background-color: #202020; + transition: border-color 0.15s, color 0.15s; +} + +#top-doc-name:hover { + border-color: var(--accent); + background-color: rgba(0,0,0,0.5); +} + /** specific modal dialogs */ .export { @@ -313,6 +348,52 @@ button:hover { text-align: center; } +.doc-open-list { + display: flex; + flex-direction: column; + min-width: 420px; + max-height: 60vh; + overflow: auto; + gap: 6px; +} + +.doc-open-row { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--menu-border, #bbb); + border-radius: 6px; + padding: 6px 8px; +} + +.doc-open-row:hover { + background-color: var(--menu-blue); +} + +.doc-open-name { + flex: 1; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-open-empty { + padding: 8px; + text-align: center; + opacity: 0.7; +} + +.doc-open-new { + width: 100%; +} + +.doc-open-del { + min-width: 28px; + padding: 2px 8px; + font-weight: 700; +} + .image-import { flex-direction: column; gap: 3px; @@ -348,12 +429,18 @@ button:hover { } #modal_frame { - border-radius: 4px !important; - background-color: #fff !important; + background-color: rgba(30,30,30,0.9); + border: 1px solid var(--border); + border-radius: 4px; + color: white; flex-direction: column; - padding: 0 !important; /* override common below */ - /* z-index: 51; */ gap: 2px; + margin: 0 0 2px 0; + padding: 3px; +} + +#modal_frame a { + color: var(--accent); } #modal_frame > div { @@ -362,10 +449,10 @@ button:hover { } #modal_title { - border-top-left-radius: 4px; - border-top-right-radius: 4px; - border-bottom: 1px solid gray; - background-color: rgba(0,0,0,0.2); + color: white; + border-radius: 2px; + border-bottom: 1px solid rgba(0,0,0,0.2); + background-color: rgba(60,60,60,0.7); font-family: 'Russo One', monospace; font-size: smaller; justify-content: center; @@ -374,13 +461,13 @@ button:hover { #modal_title_close { position: absolute; - color: #555; + color: #999; right: 3px; top: 2px; } #modal_title_close:hover { - color: black; + color: #eee; } /** welcome dialog **/ @@ -394,13 +481,12 @@ button:hover { } .welcome a { - color: #038; border-radius: 3px; - padding: 0 10px 0 10px; + padding: 2px 10px 2px 10px; } .welcome a:hover { - background-color: #ddd; + background-color: #111; } .welcome .choice { @@ -412,18 +498,18 @@ button:hover { .settings { gap: 5px; display: grid; - background-color: #fff; + /* background-color: #fff; */ grid: min-content 1fr / min-content 1fr; grid-template-columns: 1fr 1fr 1fr; } .settings > div { display: grid; - background-color: #f5f5f5; + background-color: rgba(0,0,0,0.1); grid: min-content 1fr / min-content 1fr; white-space: nowrap; align-items: center; - border: 1px solid #ddd; + border: 1px solid rgba(100,100,100,0.7); border-radius: 3px; padding: 3px; } @@ -433,7 +519,7 @@ button:hover { text-align: center; margin-bottom: 5px; border-radius: 3px; - background-color: #ccc; + background-color: rgba(100,100,100,0.8); padding: 0; } @@ -477,12 +563,8 @@ button:hover { /** common look & feel */ -.dark #modal_frame, .dark #actions > div, .dark #grouplist > div { - border: 1px solid #999; -} - -#modal_frame, #actions > div, #grouplist > div { - border: 1px solid gray; +#actions > div, #grouplist > div { + border: 1px solid #555; border-radius: 3px; margin: 0 0 2px 0; padding: 3px; @@ -502,24 +584,18 @@ button:hover { background-color: var(--selected-hover); } -/* .dark .head { +/* .head { color: #000 !important; } */ /** slide in/out logging window **/ -.dark #logger { - border-color: var(--dark-border); - background-color: var(--dark-menu-back); - color: #ddd; -} - #logger { border-top: 1px solid var(--border); border-right: 1px solid var(--border); background-color: var(--menu-back); display: none; - color: #555; + color: #ddd; min-width: 300px; flex-direction: column; position: absolute; @@ -561,28 +637,24 @@ button:hover { border-right: 1px solid var(--border); } -.dark .tools { - border-color: var(--dark-border); - background-color: var(--dark-menu-back); -} - -.dark .tools > div { - color: #fff; -} - .tools { align-items: flex-start; background-color: var(--menu-back); + padding-top: 2px; } .tools > div { + color: #fff; display: grid; grid-template-columns: 1fr; + margin: 2px; } .tools i { padding: 9px; - border-bottom: 1px solid var(--border); + border: 1px solid transparent; + border-radius: 6px; + /* border-bottom: 1px solid var(--border); */ } .tool > div { @@ -597,11 +669,6 @@ button:hover { width: 100%; } -.dark .tool label { - background-color: var(--dark-menu-back); - border: 1px solid var(--dark-border); -} - .tool label:before { content: ''; position: absolute; @@ -610,10 +677,6 @@ button:hover { transform: translateY(-50%); border-width: 6px; border-style: solid; - border-color: transparent #777 transparent transparent; -} - -.dark .tool label:before { border-color: transparent #ddd transparent transparent; } @@ -715,14 +778,10 @@ button:hover { margin: 0; } -.dark #grouplist .models .square > svg { - color: #333; -} - #grouplist .models .square > svg { aspect-ratio: 1; max-height: 20px; - color: #666; + color: #333; } #grouplist .models > div { @@ -851,21 +910,15 @@ button:hover { transform: translate(-50%, 50%); } -.dark #pinner { - background-color: rgba(255,255,255,0.15); - border-color: rgba(255,255,255,0.25); - border-top-color: rgba(255,255,255,0.6); -} - #pinner { z-index: 5000; display: none; width: 50px; height: 50px; - background-color: rgba(0,0,0,0.1); - border: 12px solid rgba(0,0,0,0.15); + background-color: rgba(255,255,255,0.15); + border: 12px solid rgba(255,255,255,0.25); border-radius: 50%; - border-top-color: rgba(0,0,0,0.6); + border-top-color: rgba(255,255,255,0.6); animation: spin 1.5s linear infinite; } diff --git a/web/mesh/index.html b/web/mesh/index.html index 2df11e36..7b29ffc4 100644 --- a/web/mesh/index.html +++ b/web/mesh/index.html @@ -1,5 +1,5 @@ - + @@ -11,15 +11,16 @@ Mesh:Tool - + + - - - - + + + +
diff --git a/web/moto/menubar.css b/web/moto/menubar.css new file mode 100644 index 00000000..dc46bc91 --- /dev/null +++ b/web/moto/menubar.css @@ -0,0 +1,172 @@ +#menubar { + position: relative; + z-index: 50; + background-color: var(--menubar-back); + color: var(--menubar-fore); + text-transform: capitalize; + align-items: center; + border-bottom: 1px solid var(--border-gray); +} + +#menubar.top{ + border-top: 1px solid var(--border-gray); +} + +.menubar-separator { + align-self: stretch; + align-items: center; + background: var(--menubar-vsep); + margin: 5px; + width: 1px; +} + +.menubar-appname, +.menubar-mode { + color: var(--appname-blue); + cursor: default; + align-self: stretch; + align-items: center; + margin: 4px 0 4px 0; + padding: 4px 12px 4px 12px; + border-radius: 0; + user-select: none; +} + +.menubar-appname { + font-weight: bold; +} + +#menubar .top-menu { + display: flex; + align-items: stretch; + white-space: nowrap; +} + +#menubar .top-menu > span { + cursor: default; + position: relative; + display: flex; + gap: 8px; + align-items: center; + margin: 2px; + padding: 8px 12px; + border: 1px solid transparent; + border-radius: 6px; + user-select: none; +} + +#menubar .top-menu > span:hover { + border-color: var(--appname-blue); + background-color: var(--menu-hover-bg); +} + +#menubar .top-menu > span:hover .top-menu-drop { + display: flex; +} + +#menubar .top-menu > span > .pop { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 90; + margin-top: 3px; + border: 1px solid var(--border-gray); + border-radius: 6px; + background: var(--menubar-back); + color: var(--menubar-fore); + padding: 4px; +} + +#menubar .top-menu > span > .pop::before { + content: ""; + position: absolute; + top: -6px; + left: 0; + right: 0; + height: 6px; + background: transparent; +} + +#menubar .top-menu > span:hover > .pop { + display: flex; +} + +#menubar #ft-rotate.pop, +#menubar #ft-scale.pop { + display: none; +} + +#menubar #tool-rotate:hover #ft-rotate.pop, +#menubar #tool-scale:hover #ft-scale.pop { + display: grid; +} + +#menubar .top-menu label, +#menubar .top-menu svg { + color: inherit; +} + +#menubar .top-menu hr { + width: 100%; + margin: 0; + border: 0; + border-top: 0.5px solid var(--menubar-vsep); +} + +#menubar .top-menu-drop { + display: none; + position: absolute; + top: 100%; + left: 0; + z-index: 90; + font-family: sans-serif; + padding-top: 3px; +} + +#menubar .top-menu-right { + left: auto; + right: 0; +} + +#menubar .top-menu-drop .content { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid var(--border-gray); + border-radius: 6px; + background: var(--menubar-back); + color: var(--menubar-fore); + padding: 4px; +} + +#menubar .top-menu-drop .content > div { + display: flex; + align-items: center; + gap: 15px; + border: 1px solid transparent; + border-radius: 6px; + padding: 4px 8px; +} + +#menubar .top-menu-drop .content > div > label { + flex-grow: 1; + text-align: left; +} + +#menubar .top-menu-drop .content > div > span { + display: flex; + justify-content: flex-end; + min-width: 16px; +} + +#menubar .top-menu-drop .content > div:hover { + border-color: var(--appname-blue); + background-color: var(--menu-hover-bg); +} + +#menubar .top-menu-drop .content > div.selected, +#menubar .top-menu > span.selected { + border-color: var(--appname-blue); + background-color: var(--menu-active-bg); +} diff --git a/web/moto/palette.css b/web/moto/palette.css new file mode 100644 index 00000000..25cc2d25 --- /dev/null +++ b/web/moto/palette.css @@ -0,0 +1,71 @@ +/* Shared color tokens for Kiri / Mesh / Void. + * Phase 1: define semantic palette + compatibility aliases. + * App CSS keeps final say by overriding locally as migration proceeds. + */ + +:root { + --appname-blue: #5a9fd4; + --menubar-vsep: rgba(0,0,0,0.2); + --menubar-back: rgba(245,245,245,1); + --menubar-fore: rgba(20,20,20,1.0); + --menu-hover-bg: rgba(250,250,250,1); + --menu-active-bg: rgba(90,159,212,0.4); + --border-gray: rgba(128,128,128,0.5); + --ui-text: #222; + --ui-text-muted: #888; + --ui-border: #ddd; + --ui-border-strong: #777; + --ui-hr: #ddd; + --ui-input-bg: #e8e8e8; + --ui-input-fg: #222; + --ui-input-disabled-bg: #ddd; + --ui-input-disabled-fg: #000; + --ui-select-bg: #e8e8e8; + --ui-option-selected-bg: var(--appname-blue); + --ui-textarea-bg: #fff; + --ui-textarea-fg: #222; + --ui-button-bg: #eee; + --ui-button-fg: #222; + --ui-button-border: #ccc; + --ui-button-hover-bg: #5a9fd4; + --ui-button-hover-fg: #fff; + --ui-button-disabled-fg: #999; + --ui-dialog-bg: rgba(255,255,255,0.85); + --ui-dialog-fg: #222; + --ui-dialog-border-top: rgba(180,180,180,1); + --ui-dialog-border-bottom: rgba(180,180,180,1); +} + +:root.dark, +:root[data-theme="dark"] { + --appname-blue: #5a9fd4; + --menubar-vsep: rgba(255,255,255,0.2); + --menubar-back: rgba(40,40,40,1); + --menubar-fore: rgba(250,250,250,1.0); + --menu-hover-bg: rgba(0,0,0,0.5); + --menu-active-bg: rgba(90,159,212,0.5); + --border-gray: rgba(128,128,128,0.5); + --ui-text: #fff; + --ui-text-muted: #888; + --ui-border: #444; + --ui-border-strong: #666; + --ui-hr: #555; + --ui-input-bg: #333; + --ui-input-fg: #fff; + --ui-input-disabled-bg: #444; + --ui-input-disabled-fg: #888; + --ui-select-bg: #333; + --ui-option-selected-bg: var(--blue-1, #5a9fd4); + --ui-textarea-bg: #333; + --ui-textarea-fg: #fff; + --ui-button-bg: #555; + --ui-button-fg: #fff; + --ui-button-border: #777; + --ui-button-hover-bg: var(--blue-2, #7e99b7); + --ui-button-hover-fg: #000; + --ui-button-disabled-fg: #888; + --ui-dialog-bg: rgba(60,60,60,0.85); + --ui-dialog-fg: #fff; + --ui-dialog-border-top: rgba(100,100,100,1); + --ui-dialog-border-bottom: rgba(100,100,100,1); +} diff --git a/web/moto/theme.css b/web/moto/theme.css new file mode 100644 index 00000000..97ecbac9 --- /dev/null +++ b/web/moto/theme.css @@ -0,0 +1,70 @@ +/* Shared theme rules. + * Phase 1 scope: Kiri fundamentals only. + */ + +:root[data-app="kiri"] { + color: var(--ui-text); +} + +:root[data-app="kiri"] hr { + border-color: var(--ui-hr); +} + +:root[data-app="kiri"] input, +:root[data-app="kiri"] select, +:root[data-app="kiri"] option { + border-style: solid; + border-width: 0px; + border-color: var(--ui-border); + background-color: var(--ui-input-bg); + color: var(--ui-input-fg); + border-bottom-width: 1px; +} + +:root[data-app="kiri"] select { + background-color: var(--ui-select-bg); +} + +:root[data-app="kiri"] option:checked { + background-color: var(--ui-option-selected-bg); +} + +:root[data-app="kiri"] input[disabled] { + border-color: var(--ui-border-strong); + background-color: var(--ui-input-disabled-bg); + color: var(--ui-input-disabled-fg); +} + +:root[data-app="kiri"] textarea { + background-color: var(--ui-textarea-bg); + color: var(--ui-textarea-fg); +} + +:root[data-app="kiri"] button { + color: var(--ui-button-fg); + background-color: var(--ui-button-bg); + border-color: var(--ui-button-border); +} + +:root[data-app="kiri"] button:not([disabled]):hover { + background-color: var(--ui-button-hover-bg); + color: var(--ui-button-hover-fg); +} + +:root[data-app="kiri"] button[disabled] { + color: var(--ui-button-disabled-fg); +} + +:root[data-app="kiri"] #dialog { + background-color: var(--ui-dialog-bg); + color: var(--ui-dialog-fg); + border-top-color: var(--ui-dialog-border-top); + border-bottom-color: var(--ui-dialog-border-bottom); +} + +:root[data-app="kiri"] #modal, +:root[data-app="kiri"] #dialog, +:root[data-app="kiri"] #modal select, +:root[data-app="kiri"] #dialog select { + color: var(--ui-dialog-fg) !important; +} diff --git a/web/void/index.html b/web/void/index.html new file mode 100644 index 00000000..f1d6b933 --- /dev/null +++ b/web/void/index.html @@ -0,0 +1,30 @@ + + + + + + Void:Form + + + + + + + +
+
+
+
+
Loading Void:Form...
+
+
+
+
+
+
+ +
+
+
+ + diff --git a/web/void/style.css b/web/void/style.css new file mode 100644 index 00000000..8180fe83 --- /dev/null +++ b/web/void/style.css @@ -0,0 +1,1234 @@ +/** Copyright Stewart Allen -- All Rights Reserved */ + +:root { + --bg-primary: #1a1a1a; + --bg-secondary: #2a2a2a; + --bg-tertiary: #3a3a3a; + --text-primary: #e0e0e0; + --text-secondary: #b0b0b0; + --border: #404040; + --accent: #5a9fd4; + --accent-hover: #7bb8e8; + --success: #4ade80; + --warning: #fbbf24; + --danger: #f87171; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 14px; + background: var(--bg-primary); + color: var(--text-primary); +} + +#app { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; +} + +/* Loading curtain */ +#curtain { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bg-primary); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +#curtain .loading { + text-align: center; +} + +#curtain .spinner { + width: 48px; + height: 48px; + margin: 0 auto 16px; + border: 4px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +#curtain .text { + color: var(--text-secondary); + font-size: 16px; +} + +/* Top toolbar */ +#top-bar { + flex: 0 0 50px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 12px; + gap: 4px; +} + +.toolbar-title { + font-weight: 600; + font-size: 16px; + color: var(--accent); +} + +.toolbar-spacer { + flex: 1; +} + +.toolbar-doc-name { + max-width: 320px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + background: var(--bg-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.toolbar-doc-name:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +/* Content area (left panel + viewport) */ +#content { + flex: 1; + display: flex; + overflow: hidden; +} + +/* Left tree panel */ +#left-panel { + flex: 0 0 250px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + padding: 8px; + font-size: 12px; + min-width: 190px; + max-width: 640px; +} + +#left-panel-resizer { + flex: 0 0 8px; + position: relative; + cursor: col-resize; + background: transparent; +} + +#left-panel-resizer::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 3px; + width: 2px; + background: rgba(255, 255, 255, 0.08); +} + +#left-panel-resizer:hover::after { + background: rgba(90, 159, 212, 0.5); +} + +body.left-panel-resizing { + cursor: col-resize; + user-select: none; +} + +body.left-panel-resizing * { + cursor: col-resize !important; +} + +/* Main 3D viewport */ +#container { + flex: 1; + position: relative; + background: var(--bg-primary); + overflow: hidden; +} + +#container canvas { + display: block; + width: 100%; + height: 100%; +} + +.sketch-marquee { + position: absolute; + pointer-events: none; + z-index: 140; + border-radius: 2px; +} + +.sketch-marquee-window { + background: rgba(90, 159, 212, 0.18); + border: 1px solid rgba(90, 159, 212, 0.95); +} + +.sketch-marquee-cross { + background: rgba(255, 153, 51, 0.16); + border: 1px dashed rgba(255, 153, 51, 0.95); +} + +.sketch-constraint-layer { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 145; +} + +.sketch-constraint-glyph { + position: absolute; + width: 18px; + height: 18px; + margin-left: -9px; + margin-top: -9px; + border-radius: 3px; + border: 1px solid rgba(40, 40, 40, 0.95); + background: rgba(28, 28, 28, 0.86); + color: #d4d4d4; + font-size: 11px; + font-weight: 700; + line-height: 16px; + text-align: center; + cursor: move; + pointer-events: auto; + user-select: none; + padding: 0; +} + +.sketch-constraint-glyph.hover { + color: #ff9933; + border-color: #ff9933; +} + +.sketch-constraint-glyph.selected { + color: #5a9fd4; + border-color: #5a9fd4; +} + +.sketch-constraint-glyph.dimension { + min-width: 26px; + width: auto; + padding: 0 8px; + margin-left: 0; + margin-top: 0px; + transform: translate(-50%, -50%); + font-size: 10px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + background: #1c1c1c; +} + +.sketch-constraint-glyph.dimension::after { + content: attr(data-mode); + position: absolute; + right: -5px; + top: -5px; + width: 10px; + height: 10px; + border-radius: 10px; + background: #4b4b4b; + color: #efefef; + font-size: 8px; + line-height: 10px; + text-align: center; + border: 1px solid rgba(40, 40, 40, 0.9); +} + +.sketch-constraint-glyph.dimension.driven { + color: #b8b8b8; + border-color: #8f8f8f; + background: rgba(36, 36, 36, 0.82); +} + +.sketch-constraint-glyph.dimension.driven::after { + background: #666; +} + +.sketch-constraint-leader { + position: absolute; + height: 1px; + pointer-events: none; + z-index: 146; + background: rgba(176, 124, 255, 0.95); + transform-origin: 0 50%; +} + +.sketch-dimension-line, +.sketch-dimension-cap, +.sketch-dimension-extension { + position: absolute; + pointer-events: none; + z-index: 148; + background: rgba(198, 198, 198, 0.95); +} + +.sketch-dimension-line { + height: 2px; + transform-origin: 0 50%; +} + +.sketch-dimension-cap { + height: 2px; + transform-origin: 50% 50%; +} + +.sketch-dimension-extension { + height: 1px; + transform-origin: 0 50%; + opacity: 0.9; +} + +.sketch-dimension-line.driven, +.sketch-dimension-cap.driven, +.sketch-dimension-extension.driven { + background: rgba(142, 142, 142, 0.8); +} + +.sketch-dimension-line.hover, +.sketch-dimension-cap.hover, +.sketch-dimension-extension.hover { + background: rgba(255, 153, 51, 0.95); +} + +.sketch-dimension-line.selected, +.sketch-dimension-cap.selected, +.sketch-dimension-extension.selected { + background: rgba(90, 159, 212, 0.95); +} + +/* Sketch overlay (2D SVG overlay) */ +#sketch-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 100; +} + +#sketch-overlay.active { + pointer-events: auto; +} + +#sketch-overlay.hidden { + display: none; +} + +/* Overlay element styles */ +#sketch-overlay svg { + overflow: visible; +} + +#sketch-overlay circle { + cursor: pointer; +} + +#sketch-overlay text { + user-select: none; + pointer-events: none; +} + +#sketch-overlay line { + pointer-events: none; +} + +/* Overlay element classes for specific styling */ +.overlay-point { + transition: r 0.15s; +} + +.overlay-point:hover { + r: 6; +} + +.overlay-text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +.overlay-dimension { + stroke: var(--text-secondary); + stroke-width: 1; +} + +.overlay-constraint { + stroke: var(--accent); + stroke-width: 1.5; +} + +/* Datum plane labels */ +.datum-label { + font-weight: 600; + font-size: 13px; + fill: var(--text-secondary); + text-shadow: 0 0 4px rgba(0, 0, 0, 0.8); +} + +/* Toolbar buttons (chicklets) */ +.toolbar-btn { + height: 36px; + min-width: 36px; + padding: 0 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-primary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + font-size: 13px; + transition: all 0.15s; +} + +.toolbar-btn:hover { + background: var(--bg-primary); + border-color: var(--accent); +} + +.toolbar-btn:active { + transform: scale(0.95); +} + +.toolbar-btn.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.toolbar-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar-btn.compact { + height: 30px; + font-size: 12px; + padding: 0 10px; +} + +.toolbar-btn.danger:hover { + border-color: var(--danger); + color: var(--danger); +} + +.toolbar-separator { + width: 1px; + height: 24px; + background: var(--border); + margin: 0 4px; +} + +.toolbar-menu { + position: relative; +} + +.toolbar-mode-group { + display: contents; +} + +.toolbar-mode-group.hidden { + display: none; +} + +.toolbar-menu-trigger::after { + content: "▾"; + font-size: 10px; + opacity: 0.85; +} + +.toolbar-menu-pop { + position: absolute; + top: 100%; + left: 0; + padding-top: 6px; + display: none; + z-index: 10025; +} + +.toolbar-menu-panel { + min-width: 140px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.35); + padding: 4px; + flex-direction: column; + display: flex; + gap: 2px; +} + +.toolbar-menu:hover .toolbar-menu-pop, +.toolbar-menu:focus-within .toolbar-menu-pop { + display: block; +} + +.toolbar-menu-item { + height: 30px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text-primary); + text-align: left; + padding: 0 10px; + font-size: 13px; + white-space: nowrap; + cursor: pointer; +} + +.toolbar-menu-item:hover { + background: var(--bg-primary); + border-color: var(--accent); +} + +.toolbar-menu-item.active { + background: rgba(90, 159, 212, 0.22); + border-color: var(--accent); +} + +.toolbar-menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.doc-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 10030; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; +} + +.doc-dialog { + width: min(760px, calc(100vw - 40px)); + max-height: min(560px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.doc-dialog.export-dialog { + width: min(380px, calc(100vw - 40px)); +} + +.doc-dialog-header { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + font-weight: 600; +} + +.doc-dialog-list { + flex: 1; + overflow: auto; + padding: 8px; +} + +.doc-dialog-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid transparent; + border-radius: 6px; +} + +.doc-dialog-row + .doc-dialog-row { + margin-top: 6px; +} + +.doc-dialog-row.active { + border-color: var(--accent); + background: rgba(90, 159, 212, 0.12); +} + +.doc-dialog-info { + flex: 1; + min-width: 0; +} + +.doc-dialog-name { + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.doc-dialog-meta { + color: var(--text-secondary); + font-size: 12px; +} + +.doc-dialog-row-actions { + display: flex; + gap: 6px; +} + +.doc-dialog-actions { + border-top: 1px solid var(--border); + padding: 10px 12px; + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.doc-dialog-empty { + padding: 16px; + color: var(--text-secondary); + font-style: italic; +} + +.hotkeys-dialog { + width: min(630px, calc(100vw - 40px)); +} + +.preferences-dialog { + width: min(460px, calc(100vw - 40px)); +} + +.prefs-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(92px, 0.38fr); + align-items: center; + gap: 10px; +} + +.prefs-input { + width: 100%; + max-width: 132px; + justify-self: end; + height: 28px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 12px; +} + +.prefs-input:focus { + outline: none; + border-color: var(--accent); +} + +#btn-preferences { + font-size: 15px; + line-height: 1; +} + +.hotkeys-row { + display: grid; + grid-template-columns: 180px 1fr; + gap: 10px; + align-items: center; + padding: 8px 10px; + border: 1px solid transparent; + border-radius: 6px; +} + +.hotkeys-row + .hotkeys-row { + margin-top: 4px; +} + +.hotkeys-row:hover { + border-color: rgba(90, 159, 212, 0.4); + background: rgba(90, 159, 212, 0.08); +} + +.hotkeys-section { + margin: 10px 0 6px; + padding: 0 4px; + color: var(--accent); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.hotkeys-key { + color: var(--text-primary); + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; +} + +.hotkeys-desc { + color: var(--text-secondary); +} + +/* Tree items */ +.tree-header { + font-weight: 600; + margin-bottom: 10px; + padding: 4px 0 8px; + border-bottom: 1px solid var(--border); +} + +.tree-search { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + padding: 5px 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); +} + +.tree-search-icon { + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + user-select: none; +} + +.tree-search-input { + flex: 1; + min-width: 0; + border: 1px solid var(--border); + border-radius: 4px; + outline: none; + background: rgba(0, 0, 0, 0.35); + color: var(--text-primary); + font-size: 12px; + padding: 4px 6px; +} + +.tree-search-input::placeholder { + color: var(--text-secondary); +} + +.tree-search-input:focus { + border-color: var(--accent); +} + +.tree-search-clear { + width: 18px; + height: 18px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.tree-search-clear:hover { + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.tree-divider { + height: 1px; + background: var(--border); + margin: 8px 4px; +} + +.tree-row, +.tree-item-row, +.tree-empty-row { + min-height: 24px; + margin: 1px 0; + border-radius: 4px; + display: flex; + align-items: center; + color: var(--text-primary); + font-size: 11px; +} + +.tree-timeline-marker { + position: relative; + display: block; + width: 100%; + height: 18px; + margin: 2px 0; + cursor: ns-resize; +} + +.tree-timeline-line { + position: absolute; + left: 8px; + right: 8px; + top: 50%; + height: 3px; + background: #4a4a4a; + transform: translateY(-1.5px); + border-radius: 2px; +} + +.tree-timeline-marker.active .tree-timeline-line { + background: var(--accent); +} + +.tree-timeline-marker.active .tree-timeline-knob { + color: var(--accent); +} + +.tree-row, +.tree-item-row { + cursor: pointer; +} + +.tree-row:hover, +.tree-item-row:hover { + background: var(--bg-tertiary); +} + +.tree-row.active { + background: var(--bg-tertiary); +} + +.tree-row.hovered { + background: var(--bg-tertiary); +} + +.tree-item-row.active { + background: rgba(90, 159, 212, 0.2); + border: 1px solid rgba(90, 159, 212, 0.55); +} + +.tree-item-row.hovered { + background: rgba(90, 159, 212, 0.2); + border: 1px solid rgba(90, 159, 212, 0.55); +} + +.tree-item-row.is-dragging { + opacity: 0.45; +} + +.tree-item-row.drag-over-before { + box-shadow: inset 0 2px 0 var(--accent); +} + +.tree-item-row.drag-over-after { + box-shadow: inset 0 -2px 0 var(--accent); +} + +.tree-item-row.is-suppressed, +.tree-item-row.is-future { + opacity: 0.55; +} + +.tree-item-row.is-disabled { + opacity: 0.35; + cursor: default; + filter: grayscale(0.15); +} + +.tree-row-left { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; +} + +.tree-row-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-twisty, +.tree-twisty-spacer { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; +} + +.tree-twisty { + border: none; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + padding: 0; +} + +.tree-twisty:hover { + color: var(--text-primary); +} + +.tree-eye { + width: 20px; + height: 20px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + margin-right: 2px; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s; + filter: grayscale(1) saturate(0); +} + +.tree-row:hover .tree-eye { + opacity: 1; + pointer-events: auto; +} +.tree-row.active .tree-eye { + opacity: 1; + pointer-events: auto; +} +.tree-item-row:hover .tree-eye { + opacity: 1; + pointer-events: auto; +} +.tree-item-row.active .tree-eye { + opacity: 1; + pointer-events: auto; +} +.tree-item-row.hovered .tree-eye { + opacity: 1; + pointer-events: auto; +} + +.tree-eye.visible { + color: var(--text-primary); +} + +.tree-eye.off { + color: var(--text-secondary); +} + +.tree-eye:hover { + background: rgba(255, 255, 255, 0.06); +} + +.tree-row.is-off .tree-row-label { + color: #7d7d7d; +} +.tree-item-row.is-off .tree-row-label, +.tree-item-row.is-off .tree-item-icon { + color: #7d7d7d; +} + +.tree-item-icon { + width: 16px; + text-align: center; + color: var(--text-secondary); + margin-right: 4px; +} + +.tree-item-actions { + display: flex; + align-items: center; + gap: 2px; + margin-right: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s; +} + +.tree-item-row:hover .tree-item-actions, +.tree-item-row.active .tree-item-actions { + opacity: 1; + pointer-events: auto; +} + +.tree-item-action { + width: 20px; + height: 20px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 0; +} + +.tree-item-action:hover { + border-color: var(--accent); + color: var(--text-primary); + background: rgba(255, 255, 255, 0.06); +} + +.tree-item-action:disabled { + opacity: 0.35; + cursor: default; +} + +.tree-item-action.is-suppressed { + color: var(--warning); +} + +.tree-empty-row { + color: #808080; + font-style: italic; +} + +.props-panel { + position: fixed; + z-index: 10020; + width: 255px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + overflow: hidden; +} + +.props-header { + height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 10px; + border-bottom: 1px solid var(--border); + background: #242424; + cursor: move; + user-select: none; +} + +.props-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.props-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.props-close { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.props-close:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text-primary); +} + +.props-body { + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.props-meta { + font-size: 11px; + letter-spacing: 0.08em; + color: var(--text-secondary); + opacity: 0.85; + margin-bottom: 2px; +} + +.props-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.props-picker-area { + padding: 6px; + border: 1px solid var(--border); + border-radius: 6px; + background: rgba(255, 255, 255, 0.02); + transition: border-color 0.12s, background-color 0.12s; + cursor: pointer; +} + +.props-picker-area.active { + border-color: var(--accent); + background: rgba(90, 159, 212, 0.14); +} + +.props-inline-actions { + margin-top: 6px; + display: flex; + justify-content: flex-end; +} + +.props-inline-button { + height: 24px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; +} + +.props-inline-button:hover { + border-color: var(--accent); + color: var(--text-primary); +} + +.props-field label { + font-size: 12px; + color: var(--text-secondary); +} + +.props-field input, +.props-field select, +.props-readonly { + height: 30px; + border: 1px solid var(--border); + border-radius: 4px; + padding: 0 8px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; +} + +.props-field input[type='checkbox'] { + width: 16px; + height: 16px; + padding: 0; + align-self: flex-start; +} + +.props-extrude-profiles { + max-height: 140px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-primary); +} + +.props-extrude-profile-row { + min-height: 28px; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background-color 0.12s, color 0.12s; +} + +.props-extrude-profile-row:hover { + background: rgba(90, 159, 212, 0.16); +} + +.props-extrude-profile-row:hover .props-extrude-profile-text { + color: var(--text-primary); +} + +.props-extrude-profile-row:last-child { + border-bottom: none; +} + +.props-extrude-profile-text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + font-size: 12px; +} + +.props-extrude-profile-remove { + width: 20px; + height: 20px; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + line-height: 1; + padding: 0; +} + +.props-extrude-profile-remove:hover { + border-color: var(--danger); + color: var(--danger); +} + +.props-extrude-profile-empty { + padding: 8px; + color: var(--text-secondary); + font-style: italic; + font-size: 12px; +} + +.props-field input:focus, +.props-field select:focus { + outline: none; + border-color: var(--accent); +} + +.props-readonly { + display: flex; + align-items: center; + color: var(--text-secondary); +} + +/* Utility classes */ +.hidden { + display: none !important; +} + +/* Scrollbars */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border); +}