merge experimental void app
This commit is contained in:
parent
9e142fbe1e
commit
02fb3cd30d
80 changed files with 30437 additions and 813 deletions
25
.github/workflows/prettier-check.yml
vendored
25
.github/workflows/prettier-check.yml
vendored
|
|
@ -1,4 +1,4 @@
|
||||||
name: 'Docs Fomatting Check'
|
name: 'Docs Formatting Check'
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -12,12 +12,19 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Setup bun
|
- name: Setup Node
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
bun-version: latest
|
node-version: '20'
|
||||||
- name: Install dependencies
|
- name: Check docs formatting (retry once on transient network failure)
|
||||||
run: bun install
|
shell: bash
|
||||||
- name: check format for docs
|
run: |
|
||||||
run:
|
for attempt in 1 2; do
|
||||||
bun docs-check
|
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
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -17,6 +17,7 @@ package-lock.json
|
||||||
src/ext/jszip-esm.js
|
src/ext/jszip-esm.js
|
||||||
src/ext/quickjs.js
|
src/ext/quickjs.js
|
||||||
src/ext/three.js
|
src/ext/three.js
|
||||||
|
src/void/solver
|
||||||
tmp
|
tmp
|
||||||
tmp_*/
|
tmp_*/
|
||||||
web/boot/bundle*
|
web/boot/bundle*
|
||||||
|
|
|
||||||
30
app.js
30
app.js
|
|
@ -134,10 +134,12 @@ function init(mod) {
|
||||||
"/boot" : redir((pre??"") + "/boot/", 301),
|
"/boot" : redir((pre??"") + "/boot/", 301),
|
||||||
"/kiri" : redir((pre??"") + "/kiri/", 301),
|
"/kiri" : redir((pre??"") + "/kiri/", 301),
|
||||||
"/mesh" : redir((pre??"") + "/mesh/", 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),
|
"/kiri/index.html" : redir((pre??"") + "/kiri/", 301),
|
||||||
"/mesh/index.html" : redir((pre??"") + "/mesh/", 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(handleVersion);
|
||||||
mod.add(fixedmap("/api/", api));
|
mod.add(fixedmap("/api/", api));
|
||||||
|
|
@ -153,12 +155,15 @@ function init(mod) {
|
||||||
mod.static("/lib/", "alt");
|
mod.static("/lib/", "alt");
|
||||||
mod.static("/lib/", "src");
|
mod.static("/lib/", "src");
|
||||||
mod.static("/obj/", "web/obj");
|
mod.static("/obj/", "web/obj");
|
||||||
mod.static("/font/", "web/font");
|
mod.static("/boot/", "web/boot");
|
||||||
mod.static("/fon2/", "web/fon2");
|
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("/mesh/", "web/mesh");
|
||||||
mod.static("/moto/", "web/moto");
|
mod.static("/moto/", "web/moto");
|
||||||
mod.static("/kiri/", "web/kiri");
|
mod.static("/void/", "web/void");
|
||||||
mod.static("/boot/", "web/boot");
|
|
||||||
|
|
||||||
// module loader
|
// module loader
|
||||||
function load_modules(root, force) {
|
function load_modules(root, force) {
|
||||||
|
|
@ -186,10 +191,10 @@ function init(mod) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// load development and 3rd party modules
|
// load development and app modules (onshape, thingiverse)
|
||||||
load_modules('mod');
|
load_modules('mod');
|
||||||
|
|
||||||
// load optional local modules
|
// load optional local modules (bambu)
|
||||||
load_modules('mods');
|
load_modules('mods');
|
||||||
|
|
||||||
// run load functions injected by modules
|
// run load functions injected by modules
|
||||||
|
|
@ -329,8 +334,10 @@ function initModule(mod, file, dir) {
|
||||||
const path = mod.dir + '/' + dir + '/' + file;
|
const path = mod.dir + '/' + dir + '/' + file;
|
||||||
try {
|
try {
|
||||||
const body = fs.readFileSync(path);
|
const body = fs.readFileSync(path);
|
||||||
if (debug && !single) logger.log({ inject: code, file, opt });
|
if (debug && !single) {
|
||||||
if (opt.first) {
|
logger.log({ inject: code, file, opt });
|
||||||
|
}
|
||||||
|
if (opt.first && append[code]) {
|
||||||
append[code] = body.toString() + '\n' + append[code];
|
append[code] = body.toString() + '\n' + append[code];
|
||||||
} else {
|
} else {
|
||||||
append[code] += body.toString() + '\n';
|
append[code] += body.toString() + '\n';
|
||||||
|
|
@ -369,8 +376,11 @@ function handleSetup(req, res, next) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const productionMap = {
|
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/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/main/kiri.js' : '/lib/pack/kiri-main.js',
|
||||||
'/lib/kiri/run/engine.js' : '/lib/pack/kiri-eng.js',
|
'/lib/kiri/run/engine.js' : '/lib/pack/kiri-eng.js',
|
||||||
'/lib/kiri/run/minion.js' : '/lib/pack/kiri-pool.js',
|
'/lib/kiri/run/minion.js' : '/lib/pack/kiri-pool.js',
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
{ "src": "src/pack/kiri-pool.js", "dst": "lib/kiri/run/minion.js" },
|
{ "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/gpu/raster-worker.js", "dst": "lib/gpu/raster-worker.js" },
|
||||||
{ "src": "src/ext/tween.js", "dst": "lib/ext/tween.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": [
|
"excludes": [
|
||||||
"src/pack",
|
"src/pack",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ const isProd = mode === 'prod';
|
||||||
|
|
||||||
console.log(`Building in ${mode} mode...`);
|
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_OUTFILE = 'src/pack/mesh-main.js';
|
||||||
const MESH_EXTRAS = [ ];
|
const MESH_EXTRAS = [ ];
|
||||||
|
|
||||||
|
|
@ -100,6 +103,20 @@ async function buildApp() {
|
||||||
// Concatenate kiri devices
|
// Concatenate kiri devices
|
||||||
generateDevices();
|
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
|
// Bundle mesh main app
|
||||||
await build(Object.assign({}, rec, {
|
await build(Object.assign({}, rec, {
|
||||||
entryPoints: [ 'src/main/mesh.js' ],
|
entryPoints: [ 'src/main/mesh.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 { LineGeometry } from '../node_modules/three/examples/jsm/lines/LineGeometry.js';
|
||||||
import { LineSegments2 } from '../node_modules/three/examples/jsm/lines/LineSegments2.js';
|
import { LineSegments2 } from '../node_modules/three/examples/jsm/lines/LineSegments2.js';
|
||||||
import { LineSegmentsGeometry } from '../node_modules/three/examples/jsm/lines/LineSegmentsGeometry.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';
|
import * as MeshBVHLib from '../node_modules/three-mesh-bvh/build/index.module.js';
|
||||||
|
|
||||||
|
|
@ -19,5 +20,6 @@ export {
|
||||||
LineGeometry,
|
LineGeometry,
|
||||||
LineSegments2,
|
LineSegments2,
|
||||||
LineSegmentsGeometry,
|
LineSegmentsGeometry,
|
||||||
|
TrackballControls,
|
||||||
MeshBVHLib
|
MeshBVHLib
|
||||||
};
|
};
|
||||||
|
|
@ -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/<mode>` 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)
|
|
||||||
960
docs/agents.md
Normal file
960
docs/agents.md
Normal file
|
|
@ -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:<faceKey>:<segIndex>`
|
||||||
|
- 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)
|
||||||
108
docs/future.md
Normal file
108
docs/future.md
Normal file
|
|
@ -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/
|
||||||
390
docs/release.md
Normal file
390
docs/release.md
Normal file
|
|
@ -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
|
||||||
81
docs/void/plan-derived.md
Normal file
81
docs/void/plan-derived.md
Normal file
|
|
@ -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.
|
||||||
185
docs/void/plan-geomgraph.md
Normal file
185
docs/void/plan-geomgraph.md
Normal file
|
|
@ -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.
|
||||||
119
notes.md
119
notes.md
|
|
@ -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/
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
|
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
|
||||||
"@gridspace/net-level-client": "^0.2.3",
|
"@gridspace/net-level-client": "^0.2.3",
|
||||||
"@gridspace/raster-path": "^1.1.1",
|
"@gridspace/raster-path": "^1.1.1",
|
||||||
|
"@salusoft89/planegcs": "^1.1.7",
|
||||||
"@tracespace/parser": "^5.0.0-next.0",
|
"@tracespace/parser": "^5.0.0-next.0",
|
||||||
"@tweenjs/tween.js": "^16.6.0",
|
"@tweenjs/tween.js": "^16.6.0",
|
||||||
"aedes": "^0.51.3",
|
"aedes": "^0.51.3",
|
||||||
|
|
@ -103,6 +104,7 @@
|
||||||
"clean": "rm -rf alt build data dist src/pack src.old tmp web.old .bcache",
|
"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",
|
"dev": "npm run pack-dev && gs-app-server --debug --single",
|
||||||
"docs-build": "docusaurus build --config conf/docusaurus.config.js",
|
"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-check": "prettier --config ./conf/prettier.config.js ./docs --check",
|
||||||
"docs-dev": "docusaurus start --config conf/docusaurus.config.js",
|
"docs-dev": "docusaurus start --config conf/docusaurus.config.js",
|
||||||
"docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004",
|
"docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004",
|
||||||
|
|
@ -121,7 +123,8 @@
|
||||||
"start-ddb": "npm run prebuild && electron . --devel --debugg",
|
"start-ddb": "npm run prebuild && electron . --devel --debugg",
|
||||||
"start-dev": "npm run prebuild prod && electron . --devel",
|
"start-dev": "npm run prebuild prod && electron . --devel",
|
||||||
"start": "npm run prebuild && electron .",
|
"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-qjs": "npx webpack --config bin/webpack-quickjs-esm.js",
|
||||||
"webpack-src": "node bin/esbuild.config.mjs",
|
"webpack-src": "node bin/esbuild.config.mjs",
|
||||||
"webpack-three": "npx webpack --config bin/webpack-three-esm.js",
|
"webpack-three": "npx webpack --config bin/webpack-three-esm.js",
|
||||||
|
|
|
||||||
401
release.md
401
release.md
|
|
@ -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
|
|
||||||
|
|
@ -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`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
@ -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});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -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 ---
|
|
||||||
```
|
|
||||||
|
|
@ -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;
|
|
||||||
// };
|
|
||||||
|
|
||||||
});
|
|
||||||
116
src/load/stl.js
116
src/load/stl.js
|
|
@ -13,14 +13,26 @@ const { Vector3, computeFaceNormal } = THREE;
|
||||||
* @returns {Uint8Array} binary STL data
|
* @returns {Uint8Array} binary STL data
|
||||||
*/
|
*/
|
||||||
export function encode(recs, header = '') {
|
export function encode(recs, header = '') {
|
||||||
// Calculate total vertices
|
// Calculate total triangles with strict validation.
|
||||||
let vtot = 0;
|
let triCount = 0;
|
||||||
for (let rec of recs) {
|
for (let rec of recs || []) {
|
||||||
vtot += (rec.varr.length / 3);
|
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)
|
// 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 dat = new DataView(stl.buffer);
|
||||||
let pos = 84;
|
let pos = 84;
|
||||||
|
|
||||||
|
|
@ -32,11 +44,12 @@ export function encode(recs, header = '') {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write triangle count at byte 80
|
// Write triangle count at byte 80
|
||||||
dat.setInt32(80, vtot/3, true);
|
dat.setUint32(80, triCount, true);
|
||||||
|
|
||||||
// Write triangles
|
// Write triangles
|
||||||
for (let rec of recs) {
|
for (let rec of recs) {
|
||||||
let { varr } = rec;
|
let { varr } = rec;
|
||||||
|
if (!varr || (varr.length % 9) !== 0) continue;
|
||||||
for (let i = 0, l = varr.length; i < l;) {
|
for (let i = 0, l = varr.length; i < l;) {
|
||||||
// Read three vertices
|
// Read three vertices
|
||||||
let p0 = new Vector3(varr[i++], varr[i++], varr[i++]);
|
let p0 = new Vector3(varr[i++], varr[i++], varr[i++]);
|
||||||
|
|
@ -72,6 +85,97 @@ export function encode(recs, header = '') {
|
||||||
return stl;
|
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 {
|
export class STL {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.vertices = null;
|
this.vertices = null;
|
||||||
|
|
|
||||||
331
src/main/void.js
Normal file
331
src/main/void.js
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import '../add/array.js';
|
||||||
|
import '../add/class.js';
|
||||||
|
import '../add/three.js';
|
||||||
|
|
||||||
|
import { $ } from '../moto/webui.js';
|
||||||
|
import { api } from '../void/api.js';
|
||||||
|
import { space } from '../moto/space.js';
|
||||||
|
import { open as dataOpen } from '../data/index.js';
|
||||||
|
import { toolbar } from '../void/toolbar.js';
|
||||||
|
import { tree } from '../void/tree.js';
|
||||||
|
import { overlay } from '../void/overlay.js';
|
||||||
|
import { datum } from '../void/datum.js';
|
||||||
|
import { interact } from '../void/interact.js';
|
||||||
|
import { properties } from '../void/properties.js';
|
||||||
|
import { ViewCube } from '../void/viewcube.js';
|
||||||
|
import { initSketchConstraintsSolver } from '../void/sketch/constraints.js';
|
||||||
|
|
||||||
|
const version = '0.1.0';
|
||||||
|
const dbindex = ["admin", "documents", "versions"];
|
||||||
|
const VOID_HOME_LEFT = Math.PI / 4;
|
||||||
|
// Match view direction along the test line vector (1,1,1) toward origin.
|
||||||
|
const VOID_HOME_UP = Math.acos(1 / Math.sqrt(3));
|
||||||
|
const LEFT_PANEL_WIDTH_KEY = 'left_panel_width';
|
||||||
|
const LEFT_PANEL_MIN_PX = 190;
|
||||||
|
const VIEWPORT_MIN_PX = 320;
|
||||||
|
|
||||||
|
function setupLeftPanelResize(db) {
|
||||||
|
const content = $('content');
|
||||||
|
const left = $('left-panel');
|
||||||
|
const container = $('container');
|
||||||
|
if (!content || !left || !container) return;
|
||||||
|
|
||||||
|
let handle = $('left-panel-resizer');
|
||||||
|
if (!handle) {
|
||||||
|
handle = document.createElement('div');
|
||||||
|
handle.id = 'left-panel-resizer';
|
||||||
|
handle.title = 'Resize tree panel';
|
||||||
|
content.insertBefore(handle, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampWidth = width => {
|
||||||
|
const maxByLayout = Math.max(LEFT_PANEL_MIN_PX, (content.clientWidth || 1200) - VIEWPORT_MIN_PX);
|
||||||
|
const max = Math.min(640, maxByLayout);
|
||||||
|
return Math.max(LEFT_PANEL_MIN_PX, Math.min(max, Number(width) || LEFT_PANEL_MIN_PX));
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyWidth = width => {
|
||||||
|
const w = clampWidth(width);
|
||||||
|
left.style.flex = `0 0 ${w}px`;
|
||||||
|
left.style.width = `${w}px`;
|
||||||
|
return w;
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistWidth = width => {
|
||||||
|
const w = applyWidth(width);
|
||||||
|
try { localStorage.setItem(LEFT_PANEL_WIDTH_KEY, String(w)); } catch {}
|
||||||
|
db?.admin?.put?.(LEFT_PANEL_WIDTH_KEY, w);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreLocal = () => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LEFT_PANEL_WIDTH_KEY);
|
||||||
|
if (raw !== null) {
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed)) applyWidth(parsed);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
restoreLocal();
|
||||||
|
db?.admin?.get?.(LEFT_PANEL_WIDTH_KEY).then(width => {
|
||||||
|
if (Number.isFinite(width)) applyWidth(width);
|
||||||
|
});
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startW = 0;
|
||||||
|
|
||||||
|
const onMove = event => {
|
||||||
|
if (!dragging) return;
|
||||||
|
const dx = (event?.clientX || 0) - startX;
|
||||||
|
const w = applyWidth(startW + dx);
|
||||||
|
space.update();
|
||||||
|
try { localStorage.setItem(LEFT_PANEL_WIDTH_KEY, String(w)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = event => {
|
||||||
|
if (!dragging) return;
|
||||||
|
dragging = false;
|
||||||
|
document.body.classList.remove('left-panel-resizing');
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
const dx = (event?.clientX || 0) - startX;
|
||||||
|
persistWidth(startW + dx);
|
||||||
|
space.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
handle.onmousedown = event => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
dragging = true;
|
||||||
|
startX = event.clientX || 0;
|
||||||
|
startW = left.getBoundingClientRect().width;
|
||||||
|
document.body.classList.add('left-panel-resizing');
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
const curr = left.getBoundingClientRect().width;
|
||||||
|
applyWidth(curr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main initialization function
|
||||||
|
async function init() {
|
||||||
|
console.log({ void_form_init: version });
|
||||||
|
|
||||||
|
// Initialize IndexedDB
|
||||||
|
let stores = dataOpen('void', { stores: dbindex, version: 2 }).init();
|
||||||
|
let db = api.db = {
|
||||||
|
admin: stores.promise('admin'),
|
||||||
|
documents: stores.promise('documents'),
|
||||||
|
versions: stores.promise('versions')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mark init time and use count
|
||||||
|
db.admin.put("init", Date.now());
|
||||||
|
db.admin.get("uses").then(v => db.admin.put("uses", (v || 0) + 1));
|
||||||
|
|
||||||
|
// Initialize API
|
||||||
|
api.init();
|
||||||
|
await initSketchConstraintsSolver();
|
||||||
|
await api.solids.init();
|
||||||
|
|
||||||
|
// Setup 3D workspace
|
||||||
|
space.setAntiAlias(true);
|
||||||
|
// Void owns its own keymap (Onshape-style); disable space.js defaults.
|
||||||
|
space.useDefaultKeys(false);
|
||||||
|
// Default void to orthographic (CAD-like), while saved camera projection
|
||||||
|
// restoration below can still override per-document/session.
|
||||||
|
space.init($('container'), delta => {}, true);
|
||||||
|
api.sketchRuntime.init(space.world);
|
||||||
|
api.solids.attach(space.world);
|
||||||
|
|
||||||
|
// Initialize 2D overlay system
|
||||||
|
overlay.init();
|
||||||
|
|
||||||
|
// Initialize datum planes
|
||||||
|
const datumGroup = datum.init({ size: 200, visible: true });
|
||||||
|
space.world.add(datumGroup);
|
||||||
|
|
||||||
|
// Add datum labels to overlay
|
||||||
|
datum.updateLabels(overlay);
|
||||||
|
|
||||||
|
// Hook overlay to update datum labels on camera movement
|
||||||
|
overlay.onUpdate = () => {
|
||||||
|
datum.updateLabels(overlay);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize interaction system (hover, select, drag)
|
||||||
|
interact.init();
|
||||||
|
api.document.bindRuntimeObservers();
|
||||||
|
|
||||||
|
// Initialize ViewCube navigation widget
|
||||||
|
const viewcube = new ViewCube({
|
||||||
|
size: 80, // Size in pixels
|
||||||
|
padding: 20, // Padding from corner
|
||||||
|
cubeSize: 1.5 // 3D cube size
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register viewcube to render after main scene
|
||||||
|
space.afterRender((renderer) => {
|
||||||
|
viewcube.render(renderer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure sky and platform
|
||||||
|
space.sky.set({
|
||||||
|
grid: false,
|
||||||
|
color: 0x101010
|
||||||
|
});
|
||||||
|
|
||||||
|
space.view.setCtrl('void');
|
||||||
|
// Rebind overlay camera/control hooks after Orbit -> Trackball swap.
|
||||||
|
overlay.onProjectionChanged();
|
||||||
|
space.view.setFitVisibleOnly(true);
|
||||||
|
space.view.setHome(VOID_HOME_LEFT, VOID_HOME_UP);
|
||||||
|
|
||||||
|
space.platform.set({
|
||||||
|
visible: false,
|
||||||
|
size: { width: 1000, depth: 1000, height: 0 },
|
||||||
|
zoom: { reverse: true, speed: 1 },
|
||||||
|
grid: {
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable camera-aligned tracking plane for drag operations
|
||||||
|
space.tracking.setMode('camera-aligned');
|
||||||
|
space.tracking.setDistance(10000); // Far behind camera to catch all rays
|
||||||
|
|
||||||
|
// Save camera position on movement
|
||||||
|
space.platform.onMove(() => {
|
||||||
|
db.admin.put('camera', {
|
||||||
|
place: space.view.save(),
|
||||||
|
focus: space.view.getFocus(),
|
||||||
|
projection: space.view.getProjection()
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Restore saved camera position
|
||||||
|
db.admin.get('camera').then(cam => {
|
||||||
|
if (cam && cam.place) {
|
||||||
|
if (cam.projection && cam.projection !== space.view.getProjection()) {
|
||||||
|
space.view.setProjection(cam.projection);
|
||||||
|
space.view.setCtrl('void');
|
||||||
|
overlay.onProjectionChanged();
|
||||||
|
toolbar.updateProjectionLabel();
|
||||||
|
}
|
||||||
|
space.view.load(cam.place);
|
||||||
|
if (cam.focus) {
|
||||||
|
space.view.setFocus(cam.focus);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use void-specific default home view when no saved camera exists.
|
||||||
|
space.view.home();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build UI components
|
||||||
|
toolbar.build();
|
||||||
|
toolbar.updateProjectionLabel();
|
||||||
|
properties.init();
|
||||||
|
tree.build();
|
||||||
|
setupLeftPanelResize(db);
|
||||||
|
|
||||||
|
// Document history hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y
|
||||||
|
window.addEventListener('keydown', async event => {
|
||||||
|
const isMeta = event.metaKey || event.ctrlKey;
|
||||||
|
if (!isMeta) return;
|
||||||
|
|
||||||
|
const activeTag = document.activeElement?.tagName;
|
||||||
|
const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
|
||||||
|
if (editing) return;
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
if (key === 'z' && event.shiftKey) {
|
||||||
|
handled = await api.document.redo();
|
||||||
|
} else if (key === 'z') {
|
||||||
|
handled = await api.document.undo();
|
||||||
|
} else if (key === 'y') {
|
||||||
|
handled = await api.document.redo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
toolbar.updateDocumentTitle();
|
||||||
|
tree.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure scene redraw when app state changes from non-canvas UI interactions
|
||||||
|
// (tree toggles, toolbar actions, property edits, etc.).
|
||||||
|
window.addEventListener('void-state-change', () => {
|
||||||
|
space.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep rendering responsive for keyboard-driven interactions even when
|
||||||
|
// the pointer is not over the canvas and idle-throttling is active.
|
||||||
|
window.addEventListener('keydown', event => {
|
||||||
|
const activeTag = document.activeElement?.tagName;
|
||||||
|
const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
|
||||||
|
if (!editing) {
|
||||||
|
space.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore last active document, or seed a new blank one.
|
||||||
|
await api.document.restoreOrCreate();
|
||||||
|
api.geometryStore?.seedFromDocument?.(api.document.current);
|
||||||
|
api.sketchRuntime.sync();
|
||||||
|
await api.solids.rebuild('startup');
|
||||||
|
api.geometryStore?.seedFromDocument?.(api.document.current);
|
||||||
|
toolbar.updateDocumentTitle();
|
||||||
|
tree.render();
|
||||||
|
|
||||||
|
// TEST: Add example overlay elements
|
||||||
|
// These demonstrate the 2D overlay tracking 3D points
|
||||||
|
if (true) { // Set to false to disable test overlays
|
||||||
|
const { THREE } = window;
|
||||||
|
|
||||||
|
// Show overlay
|
||||||
|
overlay.show();
|
||||||
|
|
||||||
|
// Add test points at origin and along axes
|
||||||
|
overlay.add('origin-point', 'point', {
|
||||||
|
pos3d: new THREE.Vector3(0, 0, 0),
|
||||||
|
radius: 4.8,
|
||||||
|
color: 'rgba(140, 140, 140, 0.45)',
|
||||||
|
stroke: '#5a9fd4',
|
||||||
|
strokeWidth: 2
|
||||||
|
});
|
||||||
|
api.origin.syncOverlayPoint();
|
||||||
|
|
||||||
|
console.log({ test_overlays_added: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading curtain
|
||||||
|
const curtain = $('curtain');
|
||||||
|
if (curtain) {
|
||||||
|
curtain.style.opacity = '0';
|
||||||
|
curtain.style.transition = 'opacity 0.3s';
|
||||||
|
setTimeout(() => {
|
||||||
|
curtain.style.display = 'none';
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({ void_form_ready: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const root = await navigator.storage.getDirectory();
|
const root = await navigator.storage?.getDirectory();
|
||||||
|
|
||||||
function resolvePath(path) {
|
function resolvePath(path) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,13 @@ class Orbit extends EventDispatcher {
|
||||||
PAN: MOUSE.MIDDLE
|
PAN: MOUSE.MIDDLE
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Onshape
|
||||||
|
this.mouseVoid = {
|
||||||
|
ORBIT: MOUSE.RIGHT,
|
||||||
|
// ZOOM: MOUSE.LEFT,
|
||||||
|
PAN: MOUSE.MIDDLE
|
||||||
|
};
|
||||||
|
|
||||||
this.mouseButtons = this.mouseDefault;
|
this.mouseButtons = this.mouseDefault;
|
||||||
|
|
||||||
this.setMouse = function(bindings) {
|
this.setMouse = function(bindings) {
|
||||||
|
|
@ -116,6 +123,7 @@ class Orbit extends EventDispatcher {
|
||||||
pan = new Vector3(),
|
pan = new Vector3(),
|
||||||
lastPosition = new Vector3(),
|
lastPosition = new Vector3(),
|
||||||
lastQuaternion = new Quaternion(),
|
lastQuaternion = new Quaternion(),
|
||||||
|
lastZoom = object.zoom !== undefined ? object.zoom : 1,
|
||||||
// so camera.up is the orbit axis
|
// so camera.up is the orbit axis
|
||||||
quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)),
|
quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)),
|
||||||
quatInverse = quat.clone().invert(),
|
quatInverse = quat.clone().invert(),
|
||||||
|
|
@ -325,11 +333,13 @@ class Orbit extends EventDispatcher {
|
||||||
// min(camera displacement, camera rotation in radians)^2 > EPS
|
// min(camera displacement, camera rotation in radians)^2 > EPS
|
||||||
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
|
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
|
||||||
if (lastPosition.distanceToSquared(this.object.position) > EPS
|
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);
|
this.dispatchEvent(changeEvent);
|
||||||
lastPosition.copy(this.object.position);
|
lastPosition.copy(this.object.position);
|
||||||
lastQuaternion.copy(this.object.quaternion);
|
lastQuaternion.copy(this.object.quaternion);
|
||||||
|
lastZoom = this.object.zoom;
|
||||||
if (notify) notify(position, true);
|
if (notify) notify(position, true);
|
||||||
} else {
|
} else {
|
||||||
if (notify) notify(position, false);
|
if (notify) notify(position, false);
|
||||||
|
|
@ -681,7 +691,23 @@ class Orbit extends EventDispatcher {
|
||||||
|
|
||||||
this.onMouseUp = onMouseUp;
|
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('mousedown', onMouseDown, false);
|
||||||
domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox)
|
domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox)
|
||||||
domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari
|
domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { THREE } from '../ext/three.js';
|
import { THREE } from '../ext/three.js';
|
||||||
import { Orbit } from './orbit.js';
|
import { Orbit } from './orbit.js';
|
||||||
|
import { Trackball } from './trackball.js';
|
||||||
import { Text3D } from './text3d.js';
|
import { Text3D } from './text3d.js';
|
||||||
import '../ext/tween.js';
|
import '../ext/tween.js';
|
||||||
|
|
||||||
|
|
@ -36,8 +37,14 @@ let WIN = self.window || {},
|
||||||
refreshRequested = false,
|
refreshRequested = false,
|
||||||
selectRecurse = false,
|
selectRecurse = false,
|
||||||
defaultKeys = true,
|
defaultKeys = true,
|
||||||
|
fitVisibleOnly = false,
|
||||||
|
fitPaddingPerspective = 0.5,
|
||||||
|
fitPaddingOrthographic = 0.9,
|
||||||
initialized = false,
|
initialized = false,
|
||||||
alignedTracking = false,
|
alignedTracking = false,
|
||||||
|
trackingMode = 'platform', // 'platform', 'camera-aligned', 'world-xy'
|
||||||
|
trackingDistance = 1000, // Distance from camera for camera-aligned mode
|
||||||
|
afterRenderCallbacks = [],
|
||||||
skyAmbient,
|
skyAmbient,
|
||||||
skyGridColor = 0xcccccc,
|
skyGridColor = 0xcccccc,
|
||||||
skyGridMaterial = undefined,
|
skyGridMaterial = undefined,
|
||||||
|
|
@ -66,6 +73,7 @@ let WIN = self.window || {},
|
||||||
mouseUp,
|
mouseUp,
|
||||||
mouseDown,
|
mouseDown,
|
||||||
mouseHover,
|
mouseHover,
|
||||||
|
mouseHoverNull,
|
||||||
mouseDrag,
|
mouseDrag,
|
||||||
grid = {
|
grid = {
|
||||||
origin: origin,
|
origin: origin,
|
||||||
|
|
@ -135,7 +143,8 @@ let WIN = self.window || {},
|
||||||
antiAlias = WIN.devicePixelRatio <= 1,
|
antiAlias = WIN.devicePixelRatio <= 1,
|
||||||
lastAction = Date.now(),
|
lastAction = Date.now(),
|
||||||
renderTime = 0,
|
renderTime = 0,
|
||||||
fps = 0;
|
fps = 0,
|
||||||
|
controlMode = 'default';
|
||||||
|
|
||||||
if (DOC) {
|
if (DOC) {
|
||||||
if (typeof DOC.hidden !== "undefined") {
|
if (typeof DOC.hidden !== "undefined") {
|
||||||
|
|
@ -158,6 +167,27 @@ function updateLastAction() {
|
||||||
lastAction = Date.now();
|
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) {
|
function delayed(key, time, fn) {
|
||||||
clearTimeout(timers[key]);
|
clearTimeout(timers[key]);
|
||||||
timers[key] = setTimeout(fn, time);
|
timers[key] = setTimeout(fn, time);
|
||||||
|
|
@ -167,6 +197,15 @@ function valueOr(val, def) {
|
||||||
return val !== undefined ? 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) => {
|
WORLD.contains = (obj) => {
|
||||||
return WORLD.children.contains(obj);
|
return WORLD.children.contains(obj);
|
||||||
};
|
};
|
||||||
|
|
@ -182,7 +221,7 @@ function tweenit() {
|
||||||
|
|
||||||
tweenit();
|
tweenit();
|
||||||
|
|
||||||
function tweenCamPan(x,y,z,left,up) {
|
function tweenCamPan(x,y,z,left,up,time,upVec) {
|
||||||
updateLastAction();
|
updateLastAction();
|
||||||
let pos = viewControl.getPosition();
|
let pos = viewControl.getPosition();
|
||||||
pos.panX = x;
|
pos.panX = x;
|
||||||
|
|
@ -190,17 +229,54 @@ function tweenCamPan(x,y,z,left,up) {
|
||||||
pos.panZ = z;
|
pos.panZ = z;
|
||||||
if (left !== undefined) pos.left = left;
|
if (left !== undefined) pos.left = left;
|
||||||
if (up !== undefined) pos.up = up;
|
if (up !== undefined) pos.up = up;
|
||||||
|
if (time !== undefined) pos.time = time;
|
||||||
|
if (upVec) pos.upVec = upVec;
|
||||||
tweenCam(pos);
|
tweenCam(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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 () {
|
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();
|
updateLastAction();
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
let from = Object.clone(viewControl.getPosition());
|
let from = Object.clone(viewControl.getPosition());
|
||||||
let to = Object.clone(pos);
|
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);
|
let dist = Math.abs(from.left - to.left);
|
||||||
if (dist > Math.PI) {
|
if (dist > Math.PI) {
|
||||||
if (from.left < to.left) {
|
if (from.left < to.left) {
|
||||||
|
|
@ -210,10 +286,29 @@ function tweenCam(pos) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
new TWEEN.Tween(from).
|
new TWEEN.Tween(from).
|
||||||
to(to, tweenTime).
|
to(to, tweenDuration).
|
||||||
onUpdate(tf).
|
onUpdate(tf).
|
||||||
onComplete(() => {
|
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();
|
updateLastAction();
|
||||||
refresh();
|
refresh();
|
||||||
let { then } = pos;
|
let { then } = pos;
|
||||||
|
|
@ -224,16 +319,176 @@ function tweenCam(pos) {
|
||||||
start();
|
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
|
* 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() }
|
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) {
|
function addEventListener(el, key, fn) {
|
||||||
el.addEventListener(key, fn);
|
el.addEventListener(key, fn);
|
||||||
}
|
}
|
||||||
|
|
@ -862,6 +1117,9 @@ function intersect(objects, recurse) {
|
||||||
******************************************************************* */
|
******************************************************************* */
|
||||||
|
|
||||||
function onMouseDown(event) {
|
function onMouseDown(event) {
|
||||||
|
if (isVoidUiEventTarget(event?.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateLastAction();
|
updateLastAction();
|
||||||
if (event.target === renderer.domElement) {
|
if (event.target === renderer.domElement) {
|
||||||
DOC.activeElement.blur();
|
DOC.activeElement.blur();
|
||||||
|
|
@ -872,18 +1130,19 @@ function onMouseDown(event) {
|
||||||
if (mouseDownSelect) {
|
if (mouseDownSelect) {
|
||||||
selection = mouseDownSelect(undefined, event);
|
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();
|
// selection = selection.map(o => o.isGroup ? o.children : o).flat();
|
||||||
// console.log({ selection });
|
|
||||||
trackTo.visible = true;
|
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;
|
trackTo.visible = isVis;
|
||||||
if (int.length > 0) {
|
if (int.length > 0) {
|
||||||
let trackInt, selectInt;
|
let trackInt, selectInt;
|
||||||
for (let i=0; i<int.length; i++) {
|
for (let i=0; i<int.length; i++) {
|
||||||
if (!trackInt && int[i].object === trackTo) {
|
if (!trackInt && int[i].object === trackTo) {
|
||||||
trackInt = int[i];
|
trackInt = int[i];
|
||||||
} else if (!selectInt && selection.contains(int[i].object)) {
|
} else if (!selectInt && selection && selection.contains(int[i].object)) {
|
||||||
selectInt = int[i];
|
selectInt = int[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -911,20 +1170,19 @@ function onMouseDown(event) {
|
||||||
} else {
|
} else {
|
||||||
viewControl.enabled = false;
|
viewControl.enabled = false;
|
||||||
}
|
}
|
||||||
mouseStart = {
|
mouseStart = eventToNDC(event);
|
||||||
x: (event.clientX / width()) * 2 - 1,
|
|
||||||
y: -(event.clientY / height()) * 2 + 1};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp(event) {
|
function onMouseUp(event) {
|
||||||
|
if (isVoidUiEventTarget(event?.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateLastAction();
|
updateLastAction();
|
||||||
if (!viewControl.enabled) {
|
if (!viewControl.enabled) {
|
||||||
viewControl.enabled = true;
|
viewControl.enabled = true;
|
||||||
viewControl.onMouseUp(event);
|
viewControl.onMouseUp(event);
|
||||||
}
|
}
|
||||||
let mouseEnd = {
|
let mouseEnd = eventToNDC(event);
|
||||||
x: (event.clientX / width()) * 2 - 1,
|
|
||||||
y: -(event.clientY / height()) * 2 + 1};
|
|
||||||
// only fire on mouse move between mouseStart (down) and up
|
// only fire on mouse move between mouseStart (down) and up
|
||||||
if (mouseStart && mouseEnd.x - mouseStart.x + mouseEnd.y - mouseStart.y === 0) {
|
if (mouseStart && mouseEnd.x - mouseStart.x + mouseEnd.y - mouseStart.y === 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -968,20 +1226,26 @@ function onMouseUp(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseMove(event) {
|
function onMouseMove(event) {
|
||||||
|
if (isVoidUiEventTarget(event?.target)) {
|
||||||
|
updateLastAction();
|
||||||
|
requestRefresh();
|
||||||
|
return;
|
||||||
|
}
|
||||||
updateLastAction();
|
updateLastAction();
|
||||||
let int, vis, dragTrack;
|
let int, vis, dragTrack;
|
||||||
|
|
||||||
const mv = new THREE.Vector2();
|
const ndc = eventToNDC(event);
|
||||||
mv.x = ( event.clientX / window.innerWidth ) * 2 - 1;
|
const mv = new THREE.Vector2(ndc.x, ndc.y);
|
||||||
mv.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
|
|
||||||
raycaster.setFromCamera( mv, camera );
|
raycaster.setFromCamera( mv, camera );
|
||||||
|
|
||||||
if (viewControl.enabled) {
|
if (viewControl.enabled) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (!event.buttons) {
|
||||||
let selection = mouseHover ? mouseHover() : null;
|
let selection = mouseHover ? mouseHover() : null;
|
||||||
if (selection && selection.length > 0) {
|
if (selection && selection.length > 0) {
|
||||||
int = intersect(selection, selectRecurse);
|
int = intersect(selection, selectRecurse);
|
||||||
if (int.length > 0) mouseHover(int[0], event, int);
|
if (int.length > 0) mouseHover(int[0], event, int);
|
||||||
|
else if (mouseHoverNull) mouseHoverNull();
|
||||||
}
|
}
|
||||||
if ((!int || int.length == 0) && platformHover) {
|
if ((!int || int.length == 0) && platformHover) {
|
||||||
vis = platform.visible;
|
vis = platform.visible;
|
||||||
|
|
@ -990,6 +1254,9 @@ function onMouseMove(event) {
|
||||||
platform.visible = vis;
|
platform.visible = vis;
|
||||||
if (int && int.length > 0) platformHover(int[0].point);
|
if (int && int.length > 0) platformHover(int[0].point);
|
||||||
}
|
}
|
||||||
|
} else if (mouseHoverNull) {
|
||||||
|
mouseHoverNull();
|
||||||
|
}
|
||||||
} else if (mouseDragPoint && mouseDrag && (dragTrack = mouseDrag())) {
|
} else if (mouseDragPoint && mouseDrag && (dragTrack = mouseDrag())) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let trackTo = alignedTracking ? trackPlane : platform;
|
let trackTo = alignedTracking ? trackPlane : platform;
|
||||||
|
|
@ -1019,9 +1286,13 @@ function onMouseMove(event) {
|
||||||
requestRefresh();
|
requestRefresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mouse = {
|
mouse = eventToNDC(event);
|
||||||
x: (event.clientX / width()) * 2 - 1,
|
}
|
||||||
y: -(event.clientY / height()) * 2 + 1};
|
|
||||||
|
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 = {}) {
|
function setSky(opt = {}) {
|
||||||
let { grid, color, gridColor } = opt;
|
let { grid, color, gridColor } = opt;
|
||||||
if (grid) Space.sky.showGrid(grid);
|
if (grid) Space.sky.showGrid(grid);
|
||||||
|
|
@ -1061,7 +1366,8 @@ function setSky(opt = {}) {
|
||||||
|
|
||||||
function setPlatform(opt = {}) {
|
function setPlatform(opt = {}) {
|
||||||
let platform = Space.platform;
|
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;
|
let { visible, volume, zOffset, origin, light } = opt;
|
||||||
if (light) {
|
if (light) {
|
||||||
lightInfo.intensity = light;
|
lightInfo.intensity = light;
|
||||||
|
|
@ -1077,12 +1383,14 @@ function setPlatform(opt = {}) {
|
||||||
platform.setSize(width, depth, height, maxz);
|
platform.setSize(width, depth, height, maxz);
|
||||||
}
|
}
|
||||||
if (grid) {
|
if (grid) {
|
||||||
let { zOffset } = grid;
|
let { below, disabled, zOffset } = grid;
|
||||||
let { major = 25, minor = 5 } = grid;
|
let { major = 25, minor = 5 } = grid;
|
||||||
let { colorX, colorY, colorMajor, colorMinor } = grid;
|
let { colorX, colorY, colorMajor, colorMinor } = grid;
|
||||||
platform.setGrid(major, minor);
|
platform.setGrid(major, minor);
|
||||||
platform.setGridColor({ colorX, colorY, colorMajor, colorMinor });
|
platform.setGridColor({ colorX, colorY, colorMajor, colorMinor });
|
||||||
if (zOffset !== undefined) platform.setGridZOff(zOffset);
|
if (zOffset !== undefined) platform.setGridZOff(zOffset);
|
||||||
|
if (disabled) platform.showGrid(false);
|
||||||
|
if (below) platform.showGridBelow(true);
|
||||||
}
|
}
|
||||||
if (origin) {
|
if (origin) {
|
||||||
let { x, y, z, show } = origin;
|
let { x, y, z, show } = origin;
|
||||||
|
|
@ -1100,12 +1408,24 @@ function setPlatform(opt = {}) {
|
||||||
if (visible !== undefined) {
|
if (visible !== undefined) {
|
||||||
platform.setVisible(visible);
|
platform.setVisible(visible);
|
||||||
}
|
}
|
||||||
|
if (zoom !== undefined) {
|
||||||
|
Space.view.setZoom(zoom.reverse, zoom.speed);
|
||||||
|
}
|
||||||
|
if (hiding !== undefined) {
|
||||||
|
platform.setHiding(hiding);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let Space = {
|
let Space = {
|
||||||
refresh: refresh,
|
refresh: refresh,
|
||||||
update: requestRefresh,
|
update: requestRefresh,
|
||||||
|
|
||||||
|
afterRender(callback) {
|
||||||
|
if (callback && typeof callback === 'function') {
|
||||||
|
afterRenderCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
setAntiAlias(b) { antiAlias = b ? true : false },
|
setAntiAlias(b) { antiAlias = b ? true : false },
|
||||||
raycast: intersect,
|
raycast: intersect,
|
||||||
|
|
||||||
|
|
@ -1224,7 +1544,7 @@ let Space = {
|
||||||
},
|
},
|
||||||
|
|
||||||
preset: {
|
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},
|
back: {left: PI, up: PI2, panX, panY, panZ},
|
||||||
home: {left: home, up, panX, panY, panZ},
|
home: {left: home, up, panX, panY, panZ},
|
||||||
front: {left: 0, up: PI2, panX, panY, panZ},
|
front: {left: 0, up: PI2, panX, panY, panZ},
|
||||||
|
|
@ -1233,25 +1553,29 @@ let Space = {
|
||||||
},
|
},
|
||||||
|
|
||||||
view: {
|
view: {
|
||||||
top: (then) => { tweenCam({left: home, up: 0, panX, panY, panZ, then}) },
|
top: (then) => { runPreset(0, 0, then) },
|
||||||
back: (then) => { tweenCam({left: PI, up: PI2, panX, panY, panZ, then}) },
|
bottom: (then) => { runPreset(0, PI, then) },
|
||||||
home: (then) => { tweenCam({left: home, up, panX, panY, panZ, then}) },
|
back: (then) => { runPreset(PI, PI2, then) },
|
||||||
front: (then) => { tweenCam({left: 0, up: PI2, panX, panY, panZ, then}) },
|
home: (then) => { runPreset(home, up, then) },
|
||||||
right: (then) => { tweenCam({left: PI2, up: PI2, panX, panY, panZ, then}) },
|
front: (then) => { runPreset(0, PI2, then) },
|
||||||
left: (then) => { tweenCam({left: -PI2, up: PI2, panX, panY, panZ, then}) },
|
right: (then) => { runPreset(PI2, PI2, then) },
|
||||||
|
left: (then) => { runPreset(-PI2, PI2, then) },
|
||||||
reset: () => { viewControl.reset(); requestRefresh() },
|
reset: () => { viewControl.reset(); requestRefresh() },
|
||||||
load: (cam) => { viewControl.setPosition(cam); requestRefresh() },
|
load: (cam) => { viewControl.setPosition(cam); requestRefresh() },
|
||||||
save: () => { return viewControl.getPosition(true) },
|
save: () => { return viewControl.getPosition(true) },
|
||||||
panTo: (x,y,z,l,u) => { tweenCamPan(x,y,z,l,u) },
|
panTo: (x,y,z,l,u,t,upVec) => { tweenCamPan(x,y,z,l,u,t,upVec) },
|
||||||
setZoom: (r,v) => { viewControl.setZoom(r,v) },
|
setZoom: (r,v) => { viewControl.setZoom(r,v) },
|
||||||
fit: (then, opts = {}) => {
|
fit: (then, opts = {}) => {
|
||||||
// Calculate bounding box of all objects in the workspace
|
// Calculate bounding box of all objects in the workspace
|
||||||
const box = new THREE.Box3();
|
const box = new THREE.Box3();
|
||||||
let hasObjects = false;
|
let hasObjects = false;
|
||||||
|
const visibleOnly = opts.visibleOnly !== undefined ? !!opts.visibleOnly : fitVisibleOnly;
|
||||||
|
|
||||||
// Recursively expand box for all visible objects with geometry
|
// Recursively expand box for all visible objects with geometry
|
||||||
WORLD.traverse(obj => {
|
WORLD.traverse(obj => {
|
||||||
if (obj.visible && obj.geometry) {
|
if (!obj.geometry) return;
|
||||||
|
if (visibleOnly && !isEffectivelyVisible(obj)) return;
|
||||||
|
if (obj.visible) {
|
||||||
box.expandByObject(obj);
|
box.expandByObject(obj);
|
||||||
hasObjects = true;
|
hasObjects = true;
|
||||||
}
|
}
|
||||||
|
|
@ -1277,10 +1601,15 @@ let Space = {
|
||||||
|
|
||||||
// Use the maximum dimension for distance calculation
|
// Use the maximum dimension for distance calculation
|
||||||
const maxDim = Math.max(size.x, size.y, size.z);
|
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
|
// 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 desiredDistance;
|
||||||
|
let orthoScaleSaveTarget = null;
|
||||||
|
|
||||||
if (camera.isPerspectiveCamera) {
|
if (camera.isPerspectiveCamera) {
|
||||||
// For perspective, calculate distance to fit object in view
|
// For perspective, calculate distance to fit object in view
|
||||||
|
|
@ -1288,55 +1617,166 @@ let Space = {
|
||||||
// Use maxDim directly (not half) for more conservative framing
|
// Use maxDim directly (not half) for more conservative framing
|
||||||
desiredDistance = maxDim / Math.tan(fov / 2) * padding;
|
desiredDistance = maxDim / Math.tan(fov / 2) * padding;
|
||||||
} else {
|
} else {
|
||||||
// For orthographic, calculate equivalent distance
|
// For orthographic, fit based on camera-plane extents (not perspective distance).
|
||||||
// The ortho frustum size is proportional to distance * tan(fov/2)
|
// This avoids chronic over-zoom-out in ortho mode.
|
||||||
// We want similar framing to perspective mode
|
const min = box.min;
|
||||||
const fov = perspective * (Math.PI / 180);
|
const max = box.max;
|
||||||
desiredDistance = maxDim / Math.tan(fov / 2) * padding;
|
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 requestedView = opts.left !== undefined || opts.up !== undefined || !!opts.upVec;
|
||||||
const pos = viewControl.getPosition();
|
let camMinX = Infinity;
|
||||||
const left = opts.left !== undefined ? opts.left : pos.left;
|
let camMaxX = -Infinity;
|
||||||
const upAngle = opts.up !== undefined ? opts.up : pos.up;
|
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
|
// Map scene coordinates to pan coordinates
|
||||||
// The target position in orbit control is in scene space
|
// The target position in orbit control is in scene space
|
||||||
const newPanX = center.x;
|
const newPanX = center.x;
|
||||||
const newPanY = center.y;
|
const newPanY = center.y;
|
||||||
const newPanZ = center.z;
|
const newPanZ = center.z;
|
||||||
|
const currentScaleSave = viewControl.getPosition(true).scale || 1;
|
||||||
|
const currentDistToCenter = camera.position.distanceTo(center);
|
||||||
|
|
||||||
// First, tween to center the view on the object
|
const fitPos = {
|
||||||
viewControl.setPosition({
|
|
||||||
left,
|
left,
|
||||||
up: upAngle,
|
up: upAngle,
|
||||||
panX: newPanX,
|
panX: newPanX,
|
||||||
panY: newPanY,
|
panY: newPanY,
|
||||||
panZ: newPanZ,
|
panZ: newPanZ,
|
||||||
});
|
};
|
||||||
|
let fitScaleRatio = 1;
|
||||||
|
|
||||||
if (camera.isPerspectiveCamera) {
|
if (camera.isPerspectiveCamera) {
|
||||||
const currentDist = camera.position.distanceTo(viewControl.getTarget());
|
fitScaleRatio = desiredDistance / currentDistToCenter;
|
||||||
const scale = desiredDistance / currentDist;
|
|
||||||
viewControl.setPosition({ scale });
|
|
||||||
} else {
|
} else {
|
||||||
// For orthographic, set zoom directly based on viewing size
|
// For orthographic, set the absolute target zoom scale directly.
|
||||||
// The camera frustum height is determined by the orthographic bounds
|
const targetScaleSave = Number.isFinite(orthoScaleSaveTarget) ? orthoScaleSaveTarget : currentScaleSave;
|
||||||
// We want the object to fit within the view with padding
|
fitScaleRatio = targetScaleSave / currentScaleSave;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
viewControl.update();
|
|
||||||
|
|
||||||
|
// 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();
|
if (typeof(then) === 'function') then();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setCtrl: (name) => {
|
setCtrl: (name) => {
|
||||||
if (name === 'onshape') {
|
const nextMode = name || 'default';
|
||||||
viewControl.setMouse(viewControl.mouseOnshape);
|
const wantsTrackball = nextMode === 'void';
|
||||||
} else {
|
const hasTrackball = !!viewControl?.isTrackballAdapter;
|
||||||
viewControl.setMouse(viewControl.mouseDefault);
|
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 },
|
getFPS () { return fps },
|
||||||
|
|
@ -1352,8 +1792,8 @@ let Space = {
|
||||||
updateFocus();
|
updateFocus();
|
||||||
},
|
},
|
||||||
setHome(r,u) {
|
setHome(r,u) {
|
||||||
home = r || 0;
|
home = r ?? 0;
|
||||||
up = u || PI4;
|
up = u ?? PI4;
|
||||||
},
|
},
|
||||||
spin(then, count) {
|
spin(then, count) {
|
||||||
Space.view.front(() => {
|
Space.view.front(() => {
|
||||||
|
|
@ -1387,9 +1827,51 @@ let Space = {
|
||||||
downSelect: (f) => { mouseDownSelect = f },
|
downSelect: (f) => { mouseDownSelect = f },
|
||||||
upSelect: (f) => { mouseUpSelect = f },
|
upSelect: (f) => { mouseUpSelect = f },
|
||||||
onDrag: (f) => { mouseDrag = 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: {
|
tween: {
|
||||||
setTime: (t) => { tweenTime = t || 500 },
|
setTime: (t) => { tweenTime = t || 500 },
|
||||||
setDelay: (d) => { tweenDelay = d || 20 }
|
setDelay: (d) => { tweenDelay = d || 20 }
|
||||||
|
|
@ -1452,7 +1934,7 @@ let Space = {
|
||||||
},
|
},
|
||||||
|
|
||||||
internals() {
|
internals() {
|
||||||
return { renderer, camera, platform };
|
return { renderer, camera, platform, container, raycaster };
|
||||||
},
|
},
|
||||||
|
|
||||||
isOrtho() {
|
isOrtho() {
|
||||||
|
|
@ -1498,7 +1980,11 @@ let Space = {
|
||||||
|
|
||||||
// Copy camera position
|
// Copy camera position
|
||||||
newCamera.position.copy(camera.position);
|
newCamera.position.copy(camera.position);
|
||||||
|
if (controlMode === 'void') {
|
||||||
newCamera.up.copy(camera.up);
|
newCamera.up.copy(camera.up);
|
||||||
|
} else {
|
||||||
|
newCamera.up.set(0, 1, 0);
|
||||||
|
}
|
||||||
newCamera.lookAt(target);
|
newCamera.lookAt(target);
|
||||||
|
|
||||||
// Store old control properties
|
// Store old control properties
|
||||||
|
|
@ -1515,43 +2001,17 @@ let Space = {
|
||||||
cameraType = type;
|
cameraType = type;
|
||||||
|
|
||||||
// Recreate viewControl with new camera and proper callbacks
|
// Recreate viewControl with new camera and proper callbacks
|
||||||
viewControl = new Orbit(camera, container, (position, moved) => {
|
if (viewControl?.dispose) {
|
||||||
if (platform) {
|
viewControl.dispose();
|
||||||
platform.visible = hidePlatformBelow ?
|
|
||||||
initialized && position.y >= 0 && showPlatform : showPlatform;
|
|
||||||
volume.visible = showVolume && platform.visible;
|
|
||||||
}
|
}
|
||||||
if (grid.view) {
|
viewControl = createViewControl(camera, container, onViewControlMove, onViewControlZoom);
|
||||||
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);
|
|
||||||
});
|
|
||||||
viewControl.noKeys = noKeys;
|
viewControl.noKeys = noKeys;
|
||||||
viewControl.minDistance = minDistance;
|
viewControl.minDistance = minDistance;
|
||||||
viewControl.maxDistance = maxDistance;
|
viewControl.maxDistance = maxDistance;
|
||||||
viewControl.enabled = enabled;
|
viewControl.enabled = enabled;
|
||||||
viewControl.reverseZoom = reverseZoom;
|
viewControl.reverseZoom = reverseZoom;
|
||||||
viewControl.zoomSpeed = zoomSpeed;
|
viewControl.zoomSpeed = zoomSpeed;
|
||||||
|
applyControlBindings();
|
||||||
viewControl.setPosition(position);
|
viewControl.setPosition(position);
|
||||||
|
|
||||||
// Dispose old camera
|
// Dispose old camera
|
||||||
|
|
@ -1599,42 +2059,13 @@ let Space = {
|
||||||
|
|
||||||
raycaster = new THREE.Raycaster();
|
raycaster = new THREE.Raycaster();
|
||||||
|
|
||||||
viewControl = new Orbit(camera, domelement, (position, moved) => {
|
sliderCallback = slider;
|
||||||
if (platform) {
|
viewControl = createViewControl(camera, domelement, onViewControlMove, onViewControlZoom);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
viewControl.noKeys = true;
|
viewControl.noKeys = true;
|
||||||
viewControl.minDistance = 1;
|
viewControl.minDistance = 1;
|
||||||
viewControl.maxDistance = 1000;
|
viewControl.maxDistance = 1000;
|
||||||
|
applyControlBindings();
|
||||||
|
|
||||||
SCENE.add(skyAmbient = new THREE.AmbientLight(0x707070));
|
SCENE.add(skyAmbient = new THREE.AmbientLight(0x707070));
|
||||||
|
|
||||||
|
|
@ -1701,6 +2132,10 @@ let Space = {
|
||||||
if (docVisible && !freeze && Date.now() - lastAction < 1500) {
|
if (docVisible && !freeze && Date.now() - lastAction < 1500) {
|
||||||
renderStart = Date.now();
|
renderStart = Date.now();
|
||||||
renderer.render(SCENE, camera);
|
renderer.render(SCENE, camera);
|
||||||
|
// call after-render callbacks (e.g., for viewcube)
|
||||||
|
for (const callback of afterRenderCallbacks) {
|
||||||
|
callback(renderer);
|
||||||
|
}
|
||||||
// track frame render times
|
// track frame render times
|
||||||
renders.push(Date.now() - renderStart);
|
renders.push(Date.now() - renderStart);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1721,6 +2156,9 @@ let Space = {
|
||||||
};
|
};
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
|
||||||
|
// Initialize tracking plane orientation
|
||||||
|
updateTrackingPlane();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
377
src/moto/trackball.js
Normal file
377
src/moto/trackball.js
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { THREE } from '../ext/three.js';
|
||||||
|
import { TrackballControls } from '../ext/three.js';
|
||||||
|
|
||||||
|
const { MOUSE, Vector3 } = THREE;
|
||||||
|
const BUTTON = { LEFT: 0, MIDDLE: 1, RIGHT: 2 };
|
||||||
|
const ACTION = { ROTATE: 0, DOLLY: 1, PAN: 2 };
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const VOID_ROTATE_SPEED = 36.0;
|
||||||
|
const VOID_PAN_SPEED_PERSPECTIVE = 1.0;
|
||||||
|
const VOID_PAN_SPEED_ORTHO = 2.4;
|
||||||
|
const VOID_ZOOM_SPEED_PERSPECTIVE_MULT = 1.35;
|
||||||
|
const VOID_ZOOM_SPEED_ORTHO_MULT = 2.2;
|
||||||
|
|
||||||
|
class Trackball {
|
||||||
|
constructor(object, domElement, notify, slider) {
|
||||||
|
this.object = object;
|
||||||
|
this.domElement = domElement !== undefined ? domElement : document;
|
||||||
|
|
||||||
|
this.control = new TrackballControls(object, this.domElement);
|
||||||
|
this.control.staticMoving = true;
|
||||||
|
this.control.dynamicDampingFactor = 0;
|
||||||
|
this.control.rotateSpeed = VOID_ROTATE_SPEED;
|
||||||
|
this.control.zoomSpeed = 2.0;
|
||||||
|
this.control.panSpeed = VOID_PAN_SPEED_PERSPECTIVE;
|
||||||
|
|
||||||
|
this.target = this.control.target;
|
||||||
|
this.center = this.target;
|
||||||
|
|
||||||
|
this.mouseDefault = {
|
||||||
|
ORBIT: MOUSE.LEFT,
|
||||||
|
ZOOM: MOUSE.MIDDLE,
|
||||||
|
PAN: MOUSE.RIGHT
|
||||||
|
};
|
||||||
|
this.mouseOnshape = {
|
||||||
|
ORBIT: MOUSE.RIGHT,
|
||||||
|
ZOOM: MOUSE.LEFT,
|
||||||
|
PAN: MOUSE.MIDDLE
|
||||||
|
};
|
||||||
|
this.mouseVoid = {
|
||||||
|
ORBIT: MOUSE.RIGHT,
|
||||||
|
PAN: MOUSE.MIDDLE
|
||||||
|
};
|
||||||
|
this.mouseButtons = this.mouseDefault;
|
||||||
|
|
||||||
|
this.orbitPivotOnRight = false;
|
||||||
|
this.continuousRotate = true;
|
||||||
|
this.reverseZoom = false;
|
||||||
|
this.zoomSpeed = 1.0;
|
||||||
|
this._keysDisabled = false;
|
||||||
|
|
||||||
|
this.isTrackballAdapter = true;
|
||||||
|
|
||||||
|
const mapButtons = () => {
|
||||||
|
const actions = {
|
||||||
|
LEFT: -1,
|
||||||
|
MIDDLE: -1,
|
||||||
|
RIGHT: -1
|
||||||
|
};
|
||||||
|
const bind = this.mouseButtons || this.mouseDefault;
|
||||||
|
const buttonToAction = Object.create(null);
|
||||||
|
if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE;
|
||||||
|
if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY;
|
||||||
|
if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN;
|
||||||
|
actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT;
|
||||||
|
actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE;
|
||||||
|
actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT;
|
||||||
|
this.control.mouseButtons = actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
mapButtons();
|
||||||
|
|
||||||
|
const emitNotify = (moved) => {
|
||||||
|
if (notify) notify(this.object.position, moved);
|
||||||
|
};
|
||||||
|
this._emitNotify = emitNotify;
|
||||||
|
this._lastPosition = this.object.position.clone();
|
||||||
|
this._lastQuaternion = this.object.quaternion.clone();
|
||||||
|
this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1;
|
||||||
|
|
||||||
|
// Keep Space idle-halo semantics identical to Orbit: any control change
|
||||||
|
// is treated as active camera motion.
|
||||||
|
this.control.addEventListener('change', () => {
|
||||||
|
this._emitNotify?.(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirror Orbit semantics: always signal notify from update(), with moved=true/false.
|
||||||
|
// Patch underlying control.update() so internal handlers also flow through this path.
|
||||||
|
const rawUpdate = this.control.update.bind(this.control);
|
||||||
|
this.control.update = (...args) => {
|
||||||
|
rawUpdate(...args);
|
||||||
|
const moved =
|
||||||
|
this._lastPosition.distanceToSquared(this.object.position) > EPS
|
||||||
|
|| 8 * (1 - this._lastQuaternion.dot(this.object.quaternion)) > EPS
|
||||||
|
|| Math.abs((this.object.zoom || 1) - this._lastZoom) > EPS;
|
||||||
|
this._emitNotify?.(moved);
|
||||||
|
if (moved) {
|
||||||
|
this._lastPosition.copy(this.object.position);
|
||||||
|
this._lastQuaternion.copy(this.object.quaternion);
|
||||||
|
this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._animating = false;
|
||||||
|
this._raf = null;
|
||||||
|
this._tick = () => {
|
||||||
|
if (!this._animating) return;
|
||||||
|
if (this.control.enabled) {
|
||||||
|
this.control.update();
|
||||||
|
}
|
||||||
|
this._raf = self.requestAnimationFrame(this._tick);
|
||||||
|
};
|
||||||
|
this._startTick = () => {
|
||||||
|
if (this._animating) return;
|
||||||
|
this._animating = true;
|
||||||
|
this._tick();
|
||||||
|
};
|
||||||
|
this._stopTick = () => {
|
||||||
|
this._animating = false;
|
||||||
|
if (this._raf) {
|
||||||
|
self.cancelAnimationFrame(this._raf);
|
||||||
|
this._raf = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._onPointerDown = (event) => {
|
||||||
|
const b = event?.button;
|
||||||
|
if (b === BUTTON.LEFT || b === BUTTON.MIDDLE || b === BUTTON.RIGHT) {
|
||||||
|
this._startTick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._onPointerUp = () => this._stopTick();
|
||||||
|
this._onPointerCancel = () => this._stopTick();
|
||||||
|
this._onWheel = () => {
|
||||||
|
if (!this.control.enabled) return;
|
||||||
|
// Run after Trackball's wheel handler mutates zoom deltas.
|
||||||
|
self.requestAnimationFrame(() => {
|
||||||
|
if (this.control.enabled) {
|
||||||
|
this.control.update();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (this.domElement?.addEventListener) {
|
||||||
|
this.domElement.addEventListener('pointerdown', this._onPointerDown, true);
|
||||||
|
this.domElement.addEventListener('wheel', this._onWheel, false);
|
||||||
|
}
|
||||||
|
if (this.domElement?.ownerDocument?.addEventListener) {
|
||||||
|
this.domElement.ownerDocument.addEventListener('pointerup', this._onPointerUp, true);
|
||||||
|
this.domElement.ownerDocument.addEventListener('pointercancel', this._onPointerCancel, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(this, 'enabled', {
|
||||||
|
get: () => this.control.enabled,
|
||||||
|
set: (v) => { this.control.enabled = !!v; }
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, 'noKeys', {
|
||||||
|
get: () => !!this._keysDisabled,
|
||||||
|
set: (v) => {
|
||||||
|
const next = !!v;
|
||||||
|
if (next === this._keysDisabled) return;
|
||||||
|
this._keysDisabled = next;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
if (next) {
|
||||||
|
window.removeEventListener('keydown', this.control._onKeyDown);
|
||||||
|
window.removeEventListener('keyup', this.control._onKeyUp);
|
||||||
|
} else {
|
||||||
|
window.addEventListener('keydown', this.control._onKeyDown);
|
||||||
|
window.addEventListener('keyup', this.control._onKeyUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, 'minDistance', {
|
||||||
|
get: () => this.control.minDistance,
|
||||||
|
set: (v) => { this.control.minDistance = v; }
|
||||||
|
});
|
||||||
|
Object.defineProperty(this, 'maxDistance', {
|
||||||
|
get: () => this.control.maxDistance,
|
||||||
|
set: (v) => { this.control.maxDistance = v; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setMouse(bindings) {
|
||||||
|
this.mouseButtons = bindings || this.mouseDefault;
|
||||||
|
const bind = this.mouseButtons || this.mouseDefault;
|
||||||
|
const actions = {
|
||||||
|
LEFT: -1,
|
||||||
|
MIDDLE: -1,
|
||||||
|
RIGHT: -1
|
||||||
|
};
|
||||||
|
const buttonToAction = Object.create(null);
|
||||||
|
if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE;
|
||||||
|
if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY;
|
||||||
|
if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN;
|
||||||
|
actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT;
|
||||||
|
actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE;
|
||||||
|
actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT;
|
||||||
|
this.control.mouseButtons = actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrbitPivotOnRight(enabled) {
|
||||||
|
this.orbitPivotOnRight = !!enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContinuousRotate(enabled) {
|
||||||
|
this.continuousRotate = !!enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoom(reverse, speed) {
|
||||||
|
this.reverseZoom = !!reverse;
|
||||||
|
this.zoomSpeed = speed || 1.0;
|
||||||
|
const mult = this.object.isOrthographicCamera
|
||||||
|
? VOID_ZOOM_SPEED_ORTHO_MULT
|
||||||
|
: VOID_ZOOM_SPEED_PERSPECTIVE_MULT;
|
||||||
|
this.control.zoomSpeed = this.zoomSpeed * mult;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTarget() {
|
||||||
|
return this.control.target;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTarget(t) {
|
||||||
|
this.control.target.copy(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition(set) {
|
||||||
|
const t = this.control.target;
|
||||||
|
if (set.panX !== undefined) t.x = set.panX;
|
||||||
|
if (set.panY !== undefined) t.y = set.panY;
|
||||||
|
if (set.panZ !== undefined) t.z = set.panZ;
|
||||||
|
|
||||||
|
const hasCamPos = Number.isFinite(set?.camX) && Number.isFinite(set?.camY) && Number.isFinite(set?.camZ);
|
||||||
|
if (hasCamPos) {
|
||||||
|
this.object.position.set(set.camX, set.camY, set.camZ);
|
||||||
|
if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) {
|
||||||
|
const up = new Vector3(set.upX, set.upY, set.upZ);
|
||||||
|
if (up.lengthSq() > 1e-12) this.object.up.copy(up.normalize());
|
||||||
|
}
|
||||||
|
this.object.lookAt(t);
|
||||||
|
if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) {
|
||||||
|
if (this.object.isPerspectiveCamera) {
|
||||||
|
const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale);
|
||||||
|
this.object.position.copy(t).add(eye);
|
||||||
|
} else if (this.object.isOrthographicCamera) {
|
||||||
|
this.object.zoom = this.object.zoom / set.scale;
|
||||||
|
this.object.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let off = this.object.position.clone().sub(t);
|
||||||
|
let radius = off.length();
|
||||||
|
if (!isFinite(radius) || radius <= 0) radius = 1;
|
||||||
|
|
||||||
|
const left = set.left !== undefined ? set.left : Math.atan2(off.x, off.z);
|
||||||
|
const up = set.up !== undefined ? set.up : Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y);
|
||||||
|
|
||||||
|
off = new Vector3(
|
||||||
|
radius * Math.sin(up) * Math.sin(left),
|
||||||
|
radius * Math.cos(up),
|
||||||
|
radius * Math.sin(up) * Math.cos(left)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Avoid singular lookAt matrices at poles by ensuring camera.up is not parallel to view direction.
|
||||||
|
const viewDir = off.clone().normalize();
|
||||||
|
let upVec = null;
|
||||||
|
if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) {
|
||||||
|
upVec = new Vector3(set.upX, set.upY, set.upZ);
|
||||||
|
} else {
|
||||||
|
upVec = this.object.up.clone();
|
||||||
|
}
|
||||||
|
if (upVec.lengthSq() < 1e-12) upVec.set(0, 1, 0);
|
||||||
|
upVec.projectOnPlane(viewDir);
|
||||||
|
if (upVec.lengthSq() < 1e-8) upVec = new Vector3(0, 0, 1).projectOnPlane(viewDir);
|
||||||
|
if (upVec.lengthSq() < 1e-8) upVec = new Vector3(1, 0, 0).projectOnPlane(viewDir);
|
||||||
|
if (upVec.lengthSq() > 1e-12) this.object.up.copy(upVec.normalize());
|
||||||
|
|
||||||
|
this.object.position.copy(t).add(off);
|
||||||
|
this.object.lookAt(t);
|
||||||
|
|
||||||
|
if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) {
|
||||||
|
if (this.object.isPerspectiveCamera) {
|
||||||
|
const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale);
|
||||||
|
this.object.position.copy(t).add(eye);
|
||||||
|
} else if (this.object.isOrthographicCamera) {
|
||||||
|
this.object.zoom = this.object.zoom / set.scale;
|
||||||
|
this.object.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPosition(scaled) {
|
||||||
|
const t = this.control.target;
|
||||||
|
const off = this.object.position.clone().sub(t);
|
||||||
|
const left = Math.atan2(off.x, off.z);
|
||||||
|
const up = Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y);
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
up,
|
||||||
|
panX: t.x,
|
||||||
|
panY: t.y,
|
||||||
|
panZ: t.z,
|
||||||
|
camX: this.object.position.x,
|
||||||
|
camY: this.object.position.y,
|
||||||
|
camZ: this.object.position.z,
|
||||||
|
upX: this.object.up.x,
|
||||||
|
upY: this.object.up.y,
|
||||||
|
upZ: this.object.up.z,
|
||||||
|
scale: scaled ? (this.object.isOrthographicCamera ? 1 / this.object.zoom : 1) : 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
this.control.panSpeed = this.object.isOrthographicCamera
|
||||||
|
? VOID_PAN_SPEED_ORTHO
|
||||||
|
: VOID_PAN_SPEED_PERSPECTIVE;
|
||||||
|
const zoomMult = this.object.isOrthographicCamera
|
||||||
|
? VOID_ZOOM_SPEED_ORTHO_MULT
|
||||||
|
: VOID_ZOOM_SPEED_PERSPECTIVE_MULT;
|
||||||
|
this.control.zoomSpeed = this.zoomSpeed * zoomMult;
|
||||||
|
this.control.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type, listener) {
|
||||||
|
this.control.addEventListener(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type, listener) {
|
||||||
|
this.control.removeEventListener(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event) {
|
||||||
|
this.control.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.control.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMouseUp() {
|
||||||
|
// TrackballControls manages pointer lifecycle internally.
|
||||||
|
}
|
||||||
|
|
||||||
|
resetInputState() {
|
||||||
|
// Clear any latched key/mouse state (e.g. if native prompt swallowed keyup/mouseup)
|
||||||
|
// and ensure key listeners are restored according to noKeys policy.
|
||||||
|
this.control.state = -1;
|
||||||
|
this.control.keyState = -1;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('keydown', this.control._onKeyDown);
|
||||||
|
window.removeEventListener('keyup', this.control._onKeyUp);
|
||||||
|
if (!this._keysDisabled) {
|
||||||
|
window.addEventListener('keydown', this.control._onKeyDown);
|
||||||
|
window.addEventListener('keyup', this.control._onKeyUp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this._stopTick();
|
||||||
|
if (this.domElement?.removeEventListener) {
|
||||||
|
this.domElement.removeEventListener('pointerdown', this._onPointerDown, true);
|
||||||
|
this.domElement.removeEventListener('wheel', this._onWheel, false);
|
||||||
|
}
|
||||||
|
if (this.domElement?.ownerDocument?.removeEventListener) {
|
||||||
|
this.domElement.ownerDocument.removeEventListener('pointerup', this._onPointerUp, true);
|
||||||
|
this.domElement.ownerDocument.removeEventListener('pointercancel', this._onPointerCancel, true);
|
||||||
|
}
|
||||||
|
this.control.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Trackball };
|
||||||
111
src/void/api.js
Normal file
111
src/void/api.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
616
src/void/api/document.js
Normal file
616
src/void/api/document.js
Normal file
|
|
@ -0,0 +1,616 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { space } from '../../moto/space.js';
|
||||||
|
|
||||||
|
function createDocumentApi(getApi, cfg) {
|
||||||
|
const {
|
||||||
|
DOC_SCHEMA_VERSION,
|
||||||
|
ADMIN_CURRENT_DOC_KEY,
|
||||||
|
ADMIN_CURRENT_REV_KEY,
|
||||||
|
UNDOABLE_OP_TYPES,
|
||||||
|
idFactory,
|
||||||
|
revString
|
||||||
|
} = cfg;
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: null,
|
||||||
|
isHydrating: false,
|
||||||
|
_runtimeSaveTimer: null,
|
||||||
|
_runtimeSavePending: null,
|
||||||
|
_datumHandlers: new Map(),
|
||||||
|
_datumRootHandler: null,
|
||||||
|
_originHandler: null,
|
||||||
|
_runtimeFlushBound: false,
|
||||||
|
_redoStack: [],
|
||||||
|
_atomicEdit: null,
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = {
|
||||||
|
id: idFactory(),
|
||||||
|
schema_version: DOC_SCHEMA_VERSION,
|
||||||
|
name: 'Untitled',
|
||||||
|
created_at: Date.now(),
|
||||||
|
modified_at: Date.now(),
|
||||||
|
version: { major: 0, micro: 0 },
|
||||||
|
head_rev: null,
|
||||||
|
features: [],
|
||||||
|
tree: {
|
||||||
|
folders: [
|
||||||
|
{ id: 'features', name: 'Features', collapsed: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
index: null
|
||||||
|
},
|
||||||
|
generated: {
|
||||||
|
solids: []
|
||||||
|
},
|
||||||
|
geometry_store: api.geometryStore?.defaultState?.() || null,
|
||||||
|
scene: {
|
||||||
|
datum: api.datum.defaultState(),
|
||||||
|
origin: api.origin.defaultState()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.current = doc;
|
||||||
|
this._redoStack = [];
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
|
||||||
|
normalizeName(name) {
|
||||||
|
const clean = String(name || '').trim();
|
||||||
|
return clean || 'Untitled';
|
||||||
|
},
|
||||||
|
|
||||||
|
migrate(doc) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!doc) return doc;
|
||||||
|
let changed = false;
|
||||||
|
if (doc.schema_version === undefined) {
|
||||||
|
doc.schema_version = DOC_SCHEMA_VERSION;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.created_at && doc.created) {
|
||||||
|
doc.created_at = doc.created;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.modified_at && doc.modified) {
|
||||||
|
doc.modified_at = doc.modified;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.version) {
|
||||||
|
doc.version = { major: 0, micro: 0 };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (doc.head_rev === undefined) {
|
||||||
|
doc.head_rev = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(doc.features)) {
|
||||||
|
doc.features = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.tree || !Array.isArray(doc.tree.folders)) {
|
||||||
|
doc.tree = {
|
||||||
|
folders: [
|
||||||
|
{ id: 'features', name: 'Features', collapsed: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.timeline || typeof doc.timeline !== 'object') {
|
||||||
|
doc.timeline = { index: null };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (doc.timeline.index !== null && !Number.isFinite(doc.timeline.index)) {
|
||||||
|
doc.timeline.index = null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.generated || typeof doc.generated !== 'object') {
|
||||||
|
doc.generated = { solids: [] };
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(doc.generated.solids)) {
|
||||||
|
doc.generated.solids = [];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.geometry_store) {
|
||||||
|
doc.geometry_store = api.geometryStore?.defaultState?.() || null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (api.geometryStore?.normalize) {
|
||||||
|
const normalized = api.geometryStore.normalize(doc.geometry_store);
|
||||||
|
if (JSON.stringify(normalized) !== JSON.stringify(doc.geometry_store)) {
|
||||||
|
doc.geometry_store = normalized;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!doc.scene) {
|
||||||
|
doc.scene = {};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.scene.datum) {
|
||||||
|
doc.scene.datum = api.datum.defaultState();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!doc.scene.origin) {
|
||||||
|
doc.scene.origin = api.origin.defaultState();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return { doc, changed };
|
||||||
|
},
|
||||||
|
|
||||||
|
nextRevision(kind = 'micro') {
|
||||||
|
const current = this.current?.version || { major: 0, micro: 0 };
|
||||||
|
if (kind === 'major') {
|
||||||
|
return { major: current.major + 1, micro: 0 };
|
||||||
|
}
|
||||||
|
return { major: current.major, micro: current.micro + 1 };
|
||||||
|
},
|
||||||
|
|
||||||
|
revisionKey(docId, rev) {
|
||||||
|
return `${docId}:${revString(rev)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
toSnapshot() {
|
||||||
|
if (!this.current) return null;
|
||||||
|
this.current.scene = this.captureRuntimeState();
|
||||||
|
return JSON.parse(JSON.stringify(this.current));
|
||||||
|
},
|
||||||
|
|
||||||
|
captureRuntimeState() {
|
||||||
|
const api = getApi();
|
||||||
|
return {
|
||||||
|
datum: api.datum.toJSON(),
|
||||||
|
origin: api.origin.toJSON()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrateRuntimeState(doc) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!doc) return;
|
||||||
|
api.geometryStore?.hydrate?.(doc);
|
||||||
|
const scene = doc.scene || {};
|
||||||
|
const datumState = scene.datum || api.datum.defaultState();
|
||||||
|
const originState = scene.origin || api.origin.defaultState();
|
||||||
|
this.isHydrating = true;
|
||||||
|
try {
|
||||||
|
api.origin.applyJSON(originState, false);
|
||||||
|
api.datum.applyJSON(datumState);
|
||||||
|
} finally {
|
||||||
|
this.isHydrating = false;
|
||||||
|
}
|
||||||
|
api.datum.updateLabels(api.overlay);
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
space.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
bindRuntimeObservers() {
|
||||||
|
const api = getApi();
|
||||||
|
if (!api.datum || !api.datum.getPlanes) return;
|
||||||
|
|
||||||
|
for (const [id, rec] of this._datumHandlers) {
|
||||||
|
rec.plane.offChange(rec.handler);
|
||||||
|
}
|
||||||
|
this._datumHandlers.clear();
|
||||||
|
if (this._datumRootHandler) {
|
||||||
|
api.datum.offChange(this._datumRootHandler);
|
||||||
|
this._datumRootHandler = null;
|
||||||
|
}
|
||||||
|
if (this._originHandler) {
|
||||||
|
api.origin.offChange(this._originHandler);
|
||||||
|
this._originHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plane of api.datum.getPlanes()) {
|
||||||
|
const handler = () => {
|
||||||
|
this.scheduleRuntimeSave('datum.update', { plane_id: plane.id });
|
||||||
|
};
|
||||||
|
this._datumHandlers.set(plane.id, { plane, handler });
|
||||||
|
plane.onChange(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._datumRootHandler = () => {
|
||||||
|
this.scheduleRuntimeSave('datum.root.update', { scope: 'datum' });
|
||||||
|
};
|
||||||
|
api.datum.onChange(this._datumRootHandler);
|
||||||
|
|
||||||
|
this._originHandler = () => {
|
||||||
|
this.scheduleRuntimeSave('origin.update', { scope: 'origin' });
|
||||||
|
};
|
||||||
|
api.origin.onChange(this._originHandler);
|
||||||
|
|
||||||
|
if (!this._runtimeFlushBound) {
|
||||||
|
this._runtimeFlushBound = true;
|
||||||
|
const flush = () => this.flushRuntimeSave();
|
||||||
|
window.addEventListener('pagehide', flush);
|
||||||
|
window.addEventListener('beforeunload', flush);
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleRuntimeSave(opType = 'runtime.update', payload = null) {
|
||||||
|
if (this.isHydrating || !this.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._runtimeSavePending = { opType, payload };
|
||||||
|
clearTimeout(this._runtimeSaveTimer);
|
||||||
|
this._runtimeSaveTimer = setTimeout(() => {
|
||||||
|
this.flushRuntimeSave();
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
|
||||||
|
flushRuntimeSave() {
|
||||||
|
if (this.isHydrating || !this.current || !this._runtimeSavePending) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
clearTimeout(this._runtimeSaveTimer);
|
||||||
|
const pending = this._runtimeSavePending;
|
||||||
|
this._runtimeSavePending = null;
|
||||||
|
return this.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: pending.opType,
|
||||||
|
payload: pending.payload
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
load(id) {
|
||||||
|
const api = getApi();
|
||||||
|
return api.db.documents.get(id).then(doc => {
|
||||||
|
if (doc) {
|
||||||
|
const migrated = this.migrate(doc);
|
||||||
|
this.current = migrated.doc;
|
||||||
|
api.geometryStore?.attachToDocument?.(this.current);
|
||||||
|
this.current.name = this.normalizeName(this.current.name);
|
||||||
|
this._redoStack = [];
|
||||||
|
this.hydrateRuntimeState(this.current);
|
||||||
|
if (migrated.changed) {
|
||||||
|
return api.db.documents.put(this.current.id, this.current).then(() => this.current);
|
||||||
|
}
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
save(options = {}) {
|
||||||
|
const api = getApi();
|
||||||
|
if (this.current) {
|
||||||
|
const now = Date.now();
|
||||||
|
const kind = options.kind || 'micro';
|
||||||
|
const opType = options.opType || (kind === 'major' ? 'snapshot' : 'delta');
|
||||||
|
const undoable = options.undoable !== undefined ? !!options.undoable :
|
||||||
|
(kind === 'major' || UNDOABLE_OP_TYPES.has(opType));
|
||||||
|
const next = this.nextRevision(kind);
|
||||||
|
const revId = this.revisionKey(this.current.id, next);
|
||||||
|
this.current.modified_at = now;
|
||||||
|
this.current.name = this.normalizeName(this.current.name);
|
||||||
|
|
||||||
|
if (!undoable) {
|
||||||
|
const currentRev = this.current.head_rev || null;
|
||||||
|
return Promise.all([
|
||||||
|
api.db.documents.put(this.current.id, this.current),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_REV_KEY, currentRev)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revision = {
|
||||||
|
doc_id: this.current.id,
|
||||||
|
rev: next,
|
||||||
|
rev_id: revId,
|
||||||
|
parent_rev: this.current.head_rev || null,
|
||||||
|
schema_version: DOC_SCHEMA_VERSION,
|
||||||
|
op_type: opType,
|
||||||
|
payload: options.payload || null,
|
||||||
|
snapshot: this.toSnapshot(),
|
||||||
|
created_at: now
|
||||||
|
};
|
||||||
|
|
||||||
|
this.current.version = next;
|
||||||
|
this.current.head_rev = revId;
|
||||||
|
this.current.scene = revision.snapshot.scene;
|
||||||
|
if (undoable && options.clearRedo !== false) {
|
||||||
|
this._redoStack = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
api.db.versions.put(revId, revision),
|
||||||
|
api.db.documents.put(this.current.id, this.current),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
|
createAndSelect() {
|
||||||
|
this.create();
|
||||||
|
this.hydrateRuntimeState(this.current);
|
||||||
|
return this.save({
|
||||||
|
kind: 'major',
|
||||||
|
opType: 'snapshot',
|
||||||
|
payload: { reason: 'seed' }
|
||||||
|
}).then(() => this.current);
|
||||||
|
},
|
||||||
|
|
||||||
|
list() {
|
||||||
|
const api = getApi();
|
||||||
|
return api.db.documents.iterate().then(entries => {
|
||||||
|
return entries
|
||||||
|
.map(({ value }) => value)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(doc => {
|
||||||
|
const migrated = this.migrate(doc);
|
||||||
|
migrated.doc.name = this.normalizeName(migrated.doc.name);
|
||||||
|
return migrated.doc;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.modified_at || 0) - (a.modified_at || 0));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
select(id) {
|
||||||
|
const api = getApi();
|
||||||
|
return this.load(id).then(doc => {
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
this._redoStack = [];
|
||||||
|
return Promise.all([
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
|
||||||
|
]).then(() => this.current);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
open(id) {
|
||||||
|
return this.select(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
rename(name) {
|
||||||
|
if (!this.current) return Promise.resolve(null);
|
||||||
|
const nextName = this.normalizeName(name);
|
||||||
|
if (nextName === this.current.name) {
|
||||||
|
return Promise.resolve(this.current);
|
||||||
|
}
|
||||||
|
const previous = this.current.name;
|
||||||
|
this.current.name = nextName;
|
||||||
|
return this.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'doc.rename',
|
||||||
|
undoable: false,
|
||||||
|
payload: {
|
||||||
|
previous,
|
||||||
|
next: nextName
|
||||||
|
}
|
||||||
|
}).then(() => this.current);
|
||||||
|
},
|
||||||
|
|
||||||
|
getRevision(revId) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!revId) return Promise.resolve(null);
|
||||||
|
return api.db.versions.get(revId);
|
||||||
|
},
|
||||||
|
|
||||||
|
applyRevision(revision) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!revision || !revision.snapshot) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const previousAtomicEdit = this._atomicEdit ? { ...this._atomicEdit } : null;
|
||||||
|
const previousDocId = this.current?.id || null;
|
||||||
|
const preserved = this.current ? {
|
||||||
|
name: this.current.name,
|
||||||
|
tree: JSON.parse(JSON.stringify(this.current.tree || { folders: [] }))
|
||||||
|
} : null;
|
||||||
|
const migrated = this.migrate(JSON.parse(JSON.stringify(revision.snapshot)));
|
||||||
|
this.current = migrated.doc;
|
||||||
|
api.geometryStore?.attachToDocument?.(this.current);
|
||||||
|
if (previousAtomicEdit && previousDocId && previousDocId === this.current?.id) {
|
||||||
|
const featureId = previousAtomicEdit.feature_id || null;
|
||||||
|
const hasFeature = featureId && Array.isArray(this.current?.features)
|
||||||
|
? this.current.features.some(feature => feature?.id === featureId)
|
||||||
|
: false;
|
||||||
|
this._atomicEdit = hasFeature ? previousAtomicEdit : null;
|
||||||
|
} else {
|
||||||
|
this._atomicEdit = null;
|
||||||
|
}
|
||||||
|
if (preserved) {
|
||||||
|
this.current.name = this.normalizeName(preserved.name);
|
||||||
|
this.current.tree = preserved.tree;
|
||||||
|
}
|
||||||
|
this.current.version = revision.rev || this.current.version;
|
||||||
|
this.current.head_rev = revision.rev_id || this.revisionKey(this.current.id, this.current.version);
|
||||||
|
this.current.modified_at = revision.created_at || this.current.modified_at || Date.now();
|
||||||
|
this.hydrateRuntimeState(this.current);
|
||||||
|
api.solids?.scheduleRebuild?.('document.hydrate');
|
||||||
|
return Promise.all([
|
||||||
|
api.db.documents.put(this.current.id, this.current),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
|
||||||
|
]).then(() => this.current);
|
||||||
|
},
|
||||||
|
|
||||||
|
canRedo() {
|
||||||
|
return this._redoStack.length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
undo() {
|
||||||
|
const head = this.current?.head_rev;
|
||||||
|
if (!head) return Promise.resolve(false);
|
||||||
|
return this.getRevision(head).then(revision => {
|
||||||
|
const parent = revision?.parent_rev;
|
||||||
|
if (!parent) return false;
|
||||||
|
return this.getRevision(parent).then(parentRevision => {
|
||||||
|
if (!parentRevision) return false;
|
||||||
|
this._redoStack.push(head);
|
||||||
|
return this.applyRevision(parentRevision).then(() => true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
redo() {
|
||||||
|
if (!this._redoStack.length) return Promise.resolve(false);
|
||||||
|
const nextRev = this._redoStack.pop();
|
||||||
|
return this.getRevision(nextRev).then(revision => {
|
||||||
|
if (!revision) return false;
|
||||||
|
if (revision.parent_rev && revision.parent_rev !== this.current?.head_rev) {
|
||||||
|
this._redoStack = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.applyRevision(revision).then(() => true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!id) return Promise.resolve(false);
|
||||||
|
const isCurrent = this.current?.id === id;
|
||||||
|
const lower = `${id}:`;
|
||||||
|
const upper = `${id}:\uffff`;
|
||||||
|
return api.db.versions.iterate({ lower, upper }).then(entries => {
|
||||||
|
const deletes = entries.map(({ key }) => api.db.versions.remove(key));
|
||||||
|
deletes.push(api.db.documents.remove(id));
|
||||||
|
return Promise.all(deletes);
|
||||||
|
}).then(() => {
|
||||||
|
if (!isCurrent) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
this._redoStack = [];
|
||||||
|
return this.list().then(docs => {
|
||||||
|
const next = docs.find(doc => doc.id !== id);
|
||||||
|
if (next) {
|
||||||
|
return this.select(next.id).then(() => true);
|
||||||
|
}
|
||||||
|
return this.createAndSelect().then(() => true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
restoreOrCreate() {
|
||||||
|
const api = getApi();
|
||||||
|
return api.db.admin.get(ADMIN_CURRENT_DOC_KEY).then(docId => {
|
||||||
|
if (!docId) {
|
||||||
|
return this.createAndSelect();
|
||||||
|
}
|
||||||
|
return this.select(docId).then(doc => {
|
||||||
|
if (doc) {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
return this.list().then(docs => {
|
||||||
|
if (docs.length) {
|
||||||
|
return this.select(docs[0].id).then(() => this.current);
|
||||||
|
}
|
||||||
|
return this.createAndSelect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
isAtomicEditActive() {
|
||||||
|
return !!this._atomicEdit;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAtomicEditFeatureId() {
|
||||||
|
return this._atomicEdit?.feature_id || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
beginAtomicEdit(meta = {}) {
|
||||||
|
this._atomicEdit = {
|
||||||
|
feature_id: meta.feature_id || null,
|
||||||
|
feature_type: meta.feature_type || null,
|
||||||
|
start_rev: this.current?.head_rev || null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
endAtomicEdit(options = {}) {
|
||||||
|
const api = getApi();
|
||||||
|
const session = this._atomicEdit;
|
||||||
|
this._atomicEdit = null;
|
||||||
|
if (!session) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
if (options.commit !== true) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
const startRev = session.start_rev || null;
|
||||||
|
const currentHead = this.current?.head_rev || null;
|
||||||
|
if (startRev === currentHead) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
if (!this.current) return Promise.resolve(false);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const next = this.nextRevision('micro');
|
||||||
|
const revId = this.revisionKey(this.current.id, next);
|
||||||
|
const snapshot = this.toSnapshot();
|
||||||
|
const revision = {
|
||||||
|
doc_id: this.current.id,
|
||||||
|
rev: next,
|
||||||
|
rev_id: revId,
|
||||||
|
parent_rev: startRev,
|
||||||
|
schema_version: DOC_SCHEMA_VERSION,
|
||||||
|
op_type: options.opType || 'feature.atomic.edit',
|
||||||
|
payload: options.payload || {
|
||||||
|
feature_id: session.feature_id,
|
||||||
|
feature_type: session.feature_type
|
||||||
|
},
|
||||||
|
snapshot,
|
||||||
|
created_at: now
|
||||||
|
};
|
||||||
|
|
||||||
|
this.current.version = next;
|
||||||
|
this.current.head_rev = revId;
|
||||||
|
this.current.scene = snapshot.scene;
|
||||||
|
this.current.modified_at = now;
|
||||||
|
this._redoStack = [];
|
||||||
|
|
||||||
|
return Promise.all([
|
||||||
|
api.db.versions.put(revId, revision),
|
||||||
|
api.db.documents.put(this.current.id, this.current),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
|
||||||
|
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
|
||||||
|
]).then(() => true);
|
||||||
|
},
|
||||||
|
|
||||||
|
getTimelineCount() {
|
||||||
|
const features = Array.isArray(this.current?.features) ? this.current.features : [];
|
||||||
|
if (!features.length) return 0;
|
||||||
|
const rawIndex = this.current?.timeline?.index;
|
||||||
|
if (rawIndex === null || rawIndex === undefined) {
|
||||||
|
return features.length;
|
||||||
|
}
|
||||||
|
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(rawIndex)));
|
||||||
|
return index + 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTimelineCount(count) {
|
||||||
|
const api = getApi();
|
||||||
|
if (!this.current) return Promise.resolve(false);
|
||||||
|
const features = Array.isArray(this.current.features) ? this.current.features : [];
|
||||||
|
const maxCount = features.length;
|
||||||
|
const nextCount = Math.max(0, Math.min(maxCount, Math.floor(Number(count) || 0)));
|
||||||
|
const prevCount = this.getTimelineCount();
|
||||||
|
if (nextCount === prevCount) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
this.current.timeline = this.current.timeline || { index: null };
|
||||||
|
this.current.timeline.index = nextCount >= maxCount ? null : (nextCount - 1);
|
||||||
|
return this.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'timeline.set',
|
||||||
|
payload: { previous: prevCount, next: nextCount }
|
||||||
|
}).then(() => {
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('timeline.set');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createDocumentApi };
|
||||||
238
src/void/api/features.js
Normal file
238
src/void/api/features.js
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
function createFeaturesApi(getApi) {
|
||||||
|
function getEffectiveTimelineCount(doc, editFeatureId = null) {
|
||||||
|
const features = Array.isArray(doc?.features) ? doc.features : [];
|
||||||
|
if (!features.length) return 0;
|
||||||
|
const raw = doc?.timeline?.index;
|
||||||
|
let count;
|
||||||
|
if (raw === null || raw === undefined) {
|
||||||
|
count = features.length;
|
||||||
|
} else {
|
||||||
|
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(raw)));
|
||||||
|
count = index + 1;
|
||||||
|
}
|
||||||
|
if (editFeatureId) {
|
||||||
|
const editIndex = features.findIndex(feature => feature?.id === editFeatureId);
|
||||||
|
if (editIndex >= 0) {
|
||||||
|
count = Math.min(count, editIndex + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list() {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
return doc ? doc.features : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
listBuilt() {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc) return [];
|
||||||
|
const features = Array.isArray(doc.features) ? doc.features : [];
|
||||||
|
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
|
||||||
|
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
|
||||||
|
return features.filter((feature, index) => index < timelineCount && feature?.suppressed !== true);
|
||||||
|
},
|
||||||
|
|
||||||
|
isIndexBuilt(index) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc || !Array.isArray(doc.features)) return false;
|
||||||
|
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
|
||||||
|
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
|
||||||
|
return index >= 0 && index < timelineCount;
|
||||||
|
},
|
||||||
|
|
||||||
|
isBuilt(featureId) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc || !Array.isArray(doc.features)) return false;
|
||||||
|
const index = doc.features.findIndex(f => f?.id === featureId);
|
||||||
|
if (index < 0) return false;
|
||||||
|
return this.isIndexBuilt(index);
|
||||||
|
},
|
||||||
|
|
||||||
|
add(feature) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (doc) {
|
||||||
|
if (feature?.visible === undefined) {
|
||||||
|
feature.visible = true;
|
||||||
|
}
|
||||||
|
if (feature?.suppressed === undefined) {
|
||||||
|
feature.suppressed = false;
|
||||||
|
}
|
||||||
|
doc.features = Array.isArray(doc.features) ? doc.features : [];
|
||||||
|
const rawTimeline = doc.timeline?.index;
|
||||||
|
const hasTimelineMarker = rawTimeline !== null && rawTimeline !== undefined && Number.isFinite(rawTimeline);
|
||||||
|
const timelineIndex = hasTimelineMarker
|
||||||
|
? Math.max(-1, Math.min(doc.features.length - 1, Math.floor(rawTimeline)))
|
||||||
|
: (doc.features.length - 1);
|
||||||
|
const insertIndex = hasTimelineMarker ? (timelineIndex + 1) : doc.features.length;
|
||||||
|
doc.features.splice(insertIndex, 0, feature);
|
||||||
|
api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'feature.add',
|
||||||
|
payload: {
|
||||||
|
type: feature?.type || 'unknown',
|
||||||
|
id: feature?.id || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hasTimelineMarker) {
|
||||||
|
// Keep marker at the newly inserted feature so downstream remains disabled.
|
||||||
|
doc.timeline.index = insertIndex;
|
||||||
|
}
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('feature.add');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(feature) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (doc) {
|
||||||
|
const index = doc.features.indexOf(feature);
|
||||||
|
if (index >= 0) {
|
||||||
|
doc.features.splice(index, 1);
|
||||||
|
if (doc.timeline && doc.timeline.index !== null && doc.timeline.index !== undefined) {
|
||||||
|
const timelineIndex = Math.floor(doc.timeline.index);
|
||||||
|
if (timelineIndex >= doc.features.length) {
|
||||||
|
doc.timeline.index = doc.features.length ? doc.features.length - 1 : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'feature.remove',
|
||||||
|
payload: {
|
||||||
|
type: feature?.type || 'unknown',
|
||||||
|
id: feature?.id || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('feature.remove');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
findById(id) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc || !Array.isArray(doc.features)) return null;
|
||||||
|
return doc.features.find(f => f?.id === id) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
update(featureId, mutator, options = {}) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc || !featureId) return null;
|
||||||
|
const feature = this.findById(featureId);
|
||||||
|
if (!feature) return null;
|
||||||
|
|
||||||
|
if (typeof mutator === 'function') {
|
||||||
|
mutator(feature);
|
||||||
|
} else if (mutator && typeof mutator === 'object') {
|
||||||
|
Object.assign(feature, mutator);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: options.opType || 'feature.update',
|
||||||
|
payload: {
|
||||||
|
id: feature.id,
|
||||||
|
type: feature.type || 'unknown',
|
||||||
|
changes: options.payload || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('feature.update');
|
||||||
|
return feature;
|
||||||
|
},
|
||||||
|
|
||||||
|
mutateTransient(featureId, mutator) {
|
||||||
|
const api = getApi();
|
||||||
|
const feature = this.findById(featureId);
|
||||||
|
if (!feature) return null;
|
||||||
|
if (typeof mutator === 'function') {
|
||||||
|
mutator(feature);
|
||||||
|
} else if (mutator && typeof mutator === 'object') {
|
||||||
|
Object.assign(feature, mutator);
|
||||||
|
}
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
return feature;
|
||||||
|
},
|
||||||
|
|
||||||
|
commit(featureId, options = {}) {
|
||||||
|
const api = getApi();
|
||||||
|
const feature = this.findById(featureId);
|
||||||
|
if (!feature) return null;
|
||||||
|
api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: options.opType || 'feature.update',
|
||||||
|
payload: {
|
||||||
|
id: feature.id,
|
||||||
|
type: feature.type || 'unknown',
|
||||||
|
changes: options.payload || null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('feature.commit');
|
||||||
|
return feature;
|
||||||
|
},
|
||||||
|
|
||||||
|
rename(featureId, name) {
|
||||||
|
const nextName = String(name || '').trim();
|
||||||
|
if (!nextName) return null;
|
||||||
|
return this.update(featureId, feature => {
|
||||||
|
feature.name = nextName;
|
||||||
|
}, {
|
||||||
|
opType: 'feature.rename',
|
||||||
|
payload: { name: nextName }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setVisible(featureId, visible) {
|
||||||
|
return this.update(featureId, feature => {
|
||||||
|
feature.visible = !!visible;
|
||||||
|
}, {
|
||||||
|
opType: 'feature.update',
|
||||||
|
payload: { field: 'visible', value: !!visible }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setSuppressed(featureId, suppressed) {
|
||||||
|
return this.update(featureId, feature => {
|
||||||
|
feature.suppressed = !!suppressed;
|
||||||
|
}, {
|
||||||
|
opType: 'feature.suppress',
|
||||||
|
payload: { field: 'suppressed', value: !!suppressed }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
move(featureId, toIndex) {
|
||||||
|
const api = getApi();
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc || !featureId || !Array.isArray(doc.features)) return false;
|
||||||
|
const fromIndex = doc.features.findIndex(f => f?.id === featureId);
|
||||||
|
if (fromIndex < 0) return false;
|
||||||
|
const clamped = Math.max(0, Math.min(doc.features.length - 1, Math.floor(Number(toIndex))));
|
||||||
|
if (clamped === fromIndex) return false;
|
||||||
|
const [feature] = doc.features.splice(fromIndex, 1);
|
||||||
|
doc.features.splice(clamped, 0, feature);
|
||||||
|
api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'feature.move',
|
||||||
|
payload: { id: featureId, from: fromIndex, to: clamped }
|
||||||
|
});
|
||||||
|
api.sketchRuntime?.sync();
|
||||||
|
api.solids?.scheduleRebuild?.('feature.move');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createFeaturesApi };
|
||||||
98
src/void/api/geometry_store.js
Normal file
98
src/void/api/geometry_store.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
function createGeometryStoreApi(getApi) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
state: null,
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return {
|
||||||
|
schema_version: this.schemaVersion,
|
||||||
|
surfaces: [],
|
||||||
|
boundaries: [],
|
||||||
|
segments: [],
|
||||||
|
points: [],
|
||||||
|
regions: [],
|
||||||
|
topology: {
|
||||||
|
surface_to_segments: {},
|
||||||
|
segment_to_surfaces: {}
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
feature_count: 0,
|
||||||
|
generated_at: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
normalize(raw) {
|
||||||
|
const base = this.defaultState();
|
||||||
|
const out = raw && typeof raw === 'object'
|
||||||
|
? { ...base, ...raw }
|
||||||
|
: base;
|
||||||
|
out.schema_version = Number(out.schema_version) || this.schemaVersion;
|
||||||
|
out.surfaces = Array.isArray(out.surfaces) ? out.surfaces : [];
|
||||||
|
out.boundaries = Array.isArray(out.boundaries) ? out.boundaries : [];
|
||||||
|
out.segments = Array.isArray(out.segments) ? out.segments : [];
|
||||||
|
out.points = Array.isArray(out.points) ? out.points : [];
|
||||||
|
out.regions = Array.isArray(out.regions) ? out.regions : [];
|
||||||
|
out.topology = out.topology && typeof out.topology === 'object'
|
||||||
|
? out.topology
|
||||||
|
: base.topology;
|
||||||
|
out.meta = out.meta && typeof out.meta === 'object'
|
||||||
|
? out.meta
|
||||||
|
: base.meta;
|
||||||
|
out.meta.feature_count = Number(out.meta.feature_count) || 0;
|
||||||
|
out.meta.generated_at = Number(out.meta.generated_at) || 0;
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
hydrate(docLike) {
|
||||||
|
const raw = docLike?.geometry_store || null;
|
||||||
|
this.state = this.normalize(raw);
|
||||||
|
return this.state;
|
||||||
|
},
|
||||||
|
|
||||||
|
attachToDocument(doc) {
|
||||||
|
if (!doc || typeof doc !== 'object') return null;
|
||||||
|
const state = this.normalize(doc.geometry_store);
|
||||||
|
doc.geometry_store = state;
|
||||||
|
this.state = state;
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot() {
|
||||||
|
const state = this.state || this.defaultState();
|
||||||
|
return JSON.parse(JSON.stringify(state));
|
||||||
|
},
|
||||||
|
|
||||||
|
seedFromDocument(doc) {
|
||||||
|
const api = getApi();
|
||||||
|
const state = this.attachToDocument(doc) || this.defaultState();
|
||||||
|
const features = Array.isArray(api.document?.current?.features)
|
||||||
|
? api.document.current.features
|
||||||
|
: [];
|
||||||
|
state.meta = state.meta || {};
|
||||||
|
state.meta.feature_count = features.length;
|
||||||
|
state.meta.generated_at = Date.now();
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
|
||||||
|
applySolidSnapshot(doc, snapshot = {}) {
|
||||||
|
const state = this.attachToDocument(doc) || this.defaultState();
|
||||||
|
state.surfaces = Array.isArray(snapshot.surfaces) ? snapshot.surfaces : [];
|
||||||
|
state.boundaries = Array.isArray(snapshot.boundaries) ? snapshot.boundaries : [];
|
||||||
|
state.segments = Array.isArray(snapshot.segments) ? snapshot.segments : [];
|
||||||
|
state.points = Array.isArray(snapshot.points) ? snapshot.points : [];
|
||||||
|
state.regions = Array.isArray(snapshot.regions) ? snapshot.regions : [];
|
||||||
|
state.topology = snapshot.topology && typeof snapshot.topology === 'object'
|
||||||
|
? snapshot.topology
|
||||||
|
: state.topology;
|
||||||
|
state.meta = state.meta || {};
|
||||||
|
state.meta.feature_count = Number(snapshot?.meta?.feature_count) || state.meta.feature_count || 0;
|
||||||
|
state.meta.generated_at = Date.now();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createGeometryStoreApi };
|
||||||
79
src/void/api/origin.js
Normal file
79
src/void/api/origin.js
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
function createOriginApi(getApi) {
|
||||||
|
return {
|
||||||
|
state: { x: 0, y: 0, z: 0, show: true },
|
||||||
|
changeHandlers: new Set(),
|
||||||
|
|
||||||
|
defaultState() {
|
||||||
|
return { x: 0, y: 0, z: 0, show: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
toJSON() {
|
||||||
|
const { x, y, z, show } = this.state;
|
||||||
|
return { x, y, z, show };
|
||||||
|
},
|
||||||
|
|
||||||
|
applyJSON(data = {}, notify = true) {
|
||||||
|
const next = {
|
||||||
|
x: data.x ?? 0,
|
||||||
|
y: data.y ?? 0,
|
||||||
|
z: data.z ?? 0,
|
||||||
|
show: data.show !== undefined ? !!data.show : true
|
||||||
|
};
|
||||||
|
this.state = next;
|
||||||
|
this.syncOverlayPoint();
|
||||||
|
if (notify) {
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
return this.state;
|
||||||
|
},
|
||||||
|
|
||||||
|
isVisible() {
|
||||||
|
return !!this.state.show;
|
||||||
|
},
|
||||||
|
|
||||||
|
setVisible(visible) {
|
||||||
|
const next = !!visible;
|
||||||
|
if (this.state.show === next) {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.applyJSON({ ...this.state, show: next }, true);
|
||||||
|
return this.state;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleVisible() {
|
||||||
|
return this.setVisible(!this.isVisible());
|
||||||
|
},
|
||||||
|
|
||||||
|
onChange(handler) {
|
||||||
|
if (typeof handler === 'function') {
|
||||||
|
this.changeHandlers.add(handler);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
offChange(handler) {
|
||||||
|
this.changeHandlers.delete(handler);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
notifyChange() {
|
||||||
|
for (const handler of this.changeHandlers) {
|
||||||
|
handler(this.state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncOverlayPoint() {
|
||||||
|
const api = getApi();
|
||||||
|
const item = api.overlay?.elements?.get('origin-point');
|
||||||
|
if (item?.el) {
|
||||||
|
item.opts = item.opts || {};
|
||||||
|
item.opts.hidden = !this.state.show;
|
||||||
|
item.el.style.display = this.state.show ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createOriginApi };
|
||||||
2651
src/void/api/solids.js
Normal file
2651
src/void/api/solids.js
Normal file
File diff suppressed because it is too large
Load diff
382
src/void/datum.js
Normal file
382
src/void/datum.js
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
369
src/void/interact.js
Normal file
369
src/void/interact.js
Normal file
|
|
@ -0,0 +1,369 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { THREE } from '../ext/three.js';
|
||||||
|
import { space } from '../moto/space.js';
|
||||||
|
import { datum } from './datum.js';
|
||||||
|
import { api } from './api.js';
|
||||||
|
import { properties } from './properties.js';
|
||||||
|
import * as targetOps from './interact/targets.js';
|
||||||
|
import * as pointOps from './interact/points.js';
|
||||||
|
import * as selectionOps from './interact/selection.js';
|
||||||
|
import * as planeOps from './interact/planes.js';
|
||||||
|
import * as sketchOps from './sketch/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interaction manager for void:form primitives
|
||||||
|
* Handles selection, hover, and dragging behaviors
|
||||||
|
*/
|
||||||
|
const interact = {
|
||||||
|
selectedPlanes: new Set(),
|
||||||
|
hoveredPlane: null,
|
||||||
|
selectedPoints: new Set(),
|
||||||
|
hoveredPoint: null,
|
||||||
|
draggedHandle: null,
|
||||||
|
draggedPlane: null,
|
||||||
|
dragHandleName: null,
|
||||||
|
dragAnchorPos: null,
|
||||||
|
dragStartSizes: new Map(),
|
||||||
|
dragStartCenters: new Map(),
|
||||||
|
planes: [],
|
||||||
|
upSelectCalled: false,
|
||||||
|
wasHandleDrag: false,
|
||||||
|
hoverIntersection: null,
|
||||||
|
handleScreenRadiusPx: 7,
|
||||||
|
handleBaseRadius: 4,
|
||||||
|
pointHitRadiusPx: 10,
|
||||||
|
pointIds: ['origin-point'],
|
||||||
|
_tmpWorldPos: new THREE.Vector3(),
|
||||||
|
sketchTool: 'select',
|
||||||
|
selectedSketchEntities: new Set(),
|
||||||
|
selectedSketchProfiles: new Set(),
|
||||||
|
selectedSolidFaceKeys: new Set(),
|
||||||
|
selectedSolidEdgeKeys: new Set(),
|
||||||
|
selectedSketchArcCenters: new Set(),
|
||||||
|
selectedSketchConstraints: new Set(),
|
||||||
|
hoveredSketchEntityId: null,
|
||||||
|
hoveredDerivedCandidate: null,
|
||||||
|
selectedDerivedSelections: new Map(),
|
||||||
|
hoveredSketchProfileKey: null,
|
||||||
|
hoveredSolidFaceKey: null,
|
||||||
|
hoveredSolidEdgeKey: null,
|
||||||
|
hoveredSketchConstraintId: null,
|
||||||
|
sketchPointerDown: null,
|
||||||
|
sketchDrag: null,
|
||||||
|
sketchLineStart: null,
|
||||||
|
sketchLineStartRefId: null,
|
||||||
|
sketchLineStartSeq: null,
|
||||||
|
sketchArcStart: null,
|
||||||
|
sketchArcStartRefId: null,
|
||||||
|
sketchArcEnd: null,
|
||||||
|
sketchArcEndRefId: null,
|
||||||
|
sketchArcPreview: null,
|
||||||
|
sketchCircleCenter: null,
|
||||||
|
sketchCircleSecond: null,
|
||||||
|
sketchCircleCenterRefId: null,
|
||||||
|
sketchCircleSecondRefId: null,
|
||||||
|
sketchCircleStartSeq: null,
|
||||||
|
sketchRectStart: null,
|
||||||
|
sketchRectStartRefId: null,
|
||||||
|
sketchRectStartSeq: null,
|
||||||
|
sketchRectPreview: null,
|
||||||
|
sketchRectCenterMode: false,
|
||||||
|
sketchMirrorMode: false,
|
||||||
|
sketchMirrorAxisId: null,
|
||||||
|
sketchCircularPatternMode: false,
|
||||||
|
sketchCircularPatternCenterRef: null,
|
||||||
|
sketchGridPatternMode: false,
|
||||||
|
sketchGridPatternCenterRef: null,
|
||||||
|
sketchPointerSeq: 0,
|
||||||
|
sketchMarquee: null,
|
||||||
|
sketchMarqueeEl: null,
|
||||||
|
_lastSketchDownStamp: null,
|
||||||
|
_lastSketchUpStamp: null,
|
||||||
|
_skipNextWindowSketchDown: false,
|
||||||
|
_skipNextWindowSketchUp: false,
|
||||||
|
_focusRaycaster: new THREE.Raycaster(),
|
||||||
|
_focusNDC: new THREE.Vector2(),
|
||||||
|
focusTweenMs: 120,
|
||||||
|
isSketchRetargetMode() {
|
||||||
|
if (!(this.isSketchEditing && this.isSketchEditing())) return false;
|
||||||
|
const featureId = properties.currentFeatureId || null;
|
||||||
|
if (!featureId) return false;
|
||||||
|
const feature = api.features?.findById?.(featureId);
|
||||||
|
if (!feature || feature.type !== 'sketch') return false;
|
||||||
|
const source = feature?.target?.source || null;
|
||||||
|
return !(source && source.type);
|
||||||
|
},
|
||||||
|
|
||||||
|
optionFocusOnDown(event) {
|
||||||
|
if (!event || event.button !== 2 || !event.altKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const { camera, renderer } = space.internals();
|
||||||
|
const canvas = renderer?.domElement || null;
|
||||||
|
if (!camera || !canvas) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
if (!rect.width || !rect.height) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
this._focusNDC.set(x, y);
|
||||||
|
this._focusRaycaster.setFromCamera(this._focusNDC, camera);
|
||||||
|
const hits = this._focusRaycaster.intersectObjects(space.objects(), true);
|
||||||
|
if (!hits?.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hit = hits.find(rec => rec?.object?.visible !== false) || hits[0];
|
||||||
|
const point = hit?.point;
|
||||||
|
if (!point) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
space.view.panTo(point.x, point.y, point.z, undefined, undefined, this.focusTweenMs);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.planes = datum.getPlanes();
|
||||||
|
|
||||||
|
window.addEventListener('keydown', event => {
|
||||||
|
if (space.isFocused()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && event.code === 'KeyF') {
|
||||||
|
space.view.fit(null, { tween: true });
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let handled = false;
|
||||||
|
switch (event.code) {
|
||||||
|
case 'Space':
|
||||||
|
{
|
||||||
|
const currentFeatureId = properties.currentFeatureId || null;
|
||||||
|
const currentFeature = currentFeatureId ? api.features.findById(currentFeatureId) : null;
|
||||||
|
const editingChamfer = currentFeature?.type === 'chamfer' && currentFeature?.id === currentFeatureId;
|
||||||
|
if (editingChamfer) {
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.deselectAll();
|
||||||
|
}
|
||||||
|
handled = true;
|
||||||
|
break;
|
||||||
|
case 'KeyN':
|
||||||
|
handled = this.viewNormalToHover();
|
||||||
|
break;
|
||||||
|
case 'KeyP':
|
||||||
|
handled = this.toggleDatumPlanesVisibility();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
handled = this.handleSketchKeyDown(event);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (handled) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('mousemove', event => {
|
||||||
|
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
|
||||||
|
this.handleSketchPointerMove?.(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.down((event) => {
|
||||||
|
this.optionFocusOnDown(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.downSelect((int, event, ints) => {
|
||||||
|
if (event && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
|
||||||
|
if (!int && int !== null) {
|
||||||
|
return this.getInteractiveObjects();
|
||||||
|
}
|
||||||
|
this._lastSketchDownStamp = event?.timeStamp ?? null;
|
||||||
|
this._skipNextWindowSketchDown = true;
|
||||||
|
this.handleSketchPointerDown(event, ints);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int && int !== null) {
|
||||||
|
return this.getInteractiveObjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetInt = int;
|
||||||
|
if (ints && ints.length > 0) {
|
||||||
|
const handleInt = ints.find(hit => {
|
||||||
|
const handleType = hit?.object?.userData?.handleType;
|
||||||
|
const plane = hit?.object?.userData?.plane;
|
||||||
|
return handleType === 'plane-resize' && plane && this.selectedPlanes.has(plane);
|
||||||
|
});
|
||||||
|
if (handleInt) {
|
||||||
|
targetInt = handleInt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = targetInt?.object;
|
||||||
|
const handleType = obj?.userData?.handleType;
|
||||||
|
if (handleType === 'plane-resize') {
|
||||||
|
this.startHandleDrag(obj, targetInt, event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.upSelect((int, event, ints) => {
|
||||||
|
if (event && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
|
||||||
|
// Query phase from space.js: return selectable objects only.
|
||||||
|
if (!event && int === undefined) {
|
||||||
|
return this.getInteractiveObjects();
|
||||||
|
}
|
||||||
|
this.upSelectCalled = true;
|
||||||
|
this._skipNextWindowSketchUp = true;
|
||||||
|
this.handleSketchMouseUp(event, ints);
|
||||||
|
this._lastSketchUpStamp = event?.timeStamp ?? null;
|
||||||
|
this.sketchPointerDown = null;
|
||||||
|
this.wasHandleDrag = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!int && int !== null) {
|
||||||
|
this.wasHandleDrag = false;
|
||||||
|
return this.getInteractiveObjects();
|
||||||
|
}
|
||||||
|
this.upSelectCalled = true;
|
||||||
|
if (!this.wasHandleDrag) {
|
||||||
|
this.handleMouseUp(int, event, ints);
|
||||||
|
}
|
||||||
|
this.wasHandleDrag = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.up((event, ints) => {
|
||||||
|
if (event && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isSketchEditing() && !this.isSketchRetargetMode()) {
|
||||||
|
// sketch completion is handled by mouseUpSelect (preferred)
|
||||||
|
// or window mouseup fallback when mouseUpSelect is skipped.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.upSelectCalled && !this.draggedHandle && !this.wasHandleDrag && ints && ints.length > 0) {
|
||||||
|
this.handleMouseUp(ints[0], event, ints);
|
||||||
|
}
|
||||||
|
this.upSelectCalled = false;
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', event => {
|
||||||
|
if (event && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isSketchEditing() || this.isSketchRetargetMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._skipNextWindowSketchUp) {
|
||||||
|
this._skipNextWindowSketchUp = false;
|
||||||
|
this.upSelectCalled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event?.timeStamp && this._lastSketchUpStamp === event.timeStamp) {
|
||||||
|
this.upSelectCalled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// When space.js doesn't emit up/upSelect (empty selection array),
|
||||||
|
// complete the sketch interaction here.
|
||||||
|
if (!this.upSelectCalled && this.sketchPointerDown) {
|
||||||
|
this.handleSketchMouseUp(event);
|
||||||
|
this._lastSketchUpStamp = event?.timeStamp ?? null;
|
||||||
|
this.sketchPointerDown = null;
|
||||||
|
}
|
||||||
|
this.upSelectCalled = false;
|
||||||
|
});
|
||||||
|
window.addEventListener('mousedown', event => {
|
||||||
|
if (event && event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.isSketchEditing() || this.isSketchRetargetMode()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._skipNextWindowSketchDown) {
|
||||||
|
this._skipNextWindowSketchDown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._lastSketchDownStamp !== null && event.timeStamp === this._lastSketchDownStamp) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { container } = space.internals();
|
||||||
|
if (!container || !container.contains(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.handleSketchPointerDown(event);
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.onHover((int, event, ints) => {
|
||||||
|
if (!int && int !== null) {
|
||||||
|
return this.getInteractiveObjects();
|
||||||
|
}
|
||||||
|
this.handleHover(int, event, ints);
|
||||||
|
if (this.isSketchEditing()) {
|
||||||
|
this.handleSketchHover(event, ints);
|
||||||
|
}
|
||||||
|
}, () => {
|
||||||
|
this.handleHover();
|
||||||
|
});
|
||||||
|
|
||||||
|
space.mouse.onDrag((delta, offset, isDone, intersections) => {
|
||||||
|
if (delta === undefined) {
|
||||||
|
if (this.draggedHandle) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (this.isSketchEditing()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isSketchEditing()) {
|
||||||
|
this.handleSketchDrag(delta, offset, isDone, intersections);
|
||||||
|
if (isDone) {
|
||||||
|
this.sketchPointerDown = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDone && this.draggedHandle) {
|
||||||
|
this.wasHandleDrag = true;
|
||||||
|
this.draggedHandle = null;
|
||||||
|
this.draggedPlane = null;
|
||||||
|
this.dragHandleName = null;
|
||||||
|
this.dragAnchorPos = null;
|
||||||
|
this.dragStartSizes.clear();
|
||||||
|
this.dragStartCenters.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDone && !this.draggedHandle && offset) {
|
||||||
|
const offsetMag = Math.sqrt(offset.x * offset.x + offset.y * offset.y);
|
||||||
|
if (offsetMag < 5) {
|
||||||
|
if (intersections && intersections.length > 0) {
|
||||||
|
this.handleMouseUp(intersections[0], { ctrlKey: false, metaKey: false }, intersections);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleDrag(delta, offset, isDone, intersections);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupHandleScaleHooks();
|
||||||
|
this.updateHandleScreenScales();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(interact, pointOps);
|
||||||
|
Object.assign(interact, selectionOps);
|
||||||
|
Object.assign(interact, planeOps);
|
||||||
|
Object.assign(interact, targetOps);
|
||||||
|
Object.assign(interact, sketchOps);
|
||||||
|
|
||||||
|
export { interact };
|
||||||
1338
src/void/interact/planes.js
Normal file
1338
src/void/interact/planes.js
Normal file
File diff suppressed because it is too large
Load diff
137
src/void/interact/points.js
Normal file
137
src/void/interact/points.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
134
src/void/interact/selection.js
Normal file
134
src/void/interact/selection.js
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
185
src/void/interact/selection_resolver.js
Normal file
185
src/void/interact/selection_resolver.js
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
149
src/void/interact/targets.js
Normal file
149
src/void/interact/targets.js
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
386
src/void/overlay.js
Normal file
386
src/void/overlay.js
Normal file
|
|
@ -0,0 +1,386 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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<id, {el, pos3d, type, opts}>
|
||||||
|
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 };
|
||||||
53
src/void/palette.js
Normal file
53
src/void/palette.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
558
src/void/plane.js
Normal file
558
src/void/plane.js
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
1088
src/void/properties.js
Normal file
1088
src/void/properties.js
Normal file
File diff suppressed because it is too large
Load diff
36
src/void/sketch/api.js
Normal file
36
src/void/sketch/api.js
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
17
src/void/sketch/constants.js
Normal file
17
src/void/sketch/constants.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
358
src/void/sketch/constraints.js
Normal file
358
src/void/sketch/constraints.js
Normal file
|
|
@ -0,0 +1,358 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
882
src/void/sketch/constraints_actions.js
Normal file
882
src/void/sketch/constraints_actions.js
Normal file
|
|
@ -0,0 +1,882 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
2125
src/void/sketch/constraints_fallback.js
Normal file
2125
src/void/sketch/constraints_fallback.js
Normal file
File diff suppressed because it is too large
Load diff
172
src/void/sketch/constraints_tangent.js
Normal file
172
src/void/sketch/constraints_tangent.js
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
2132
src/void/sketch/create.js
Normal file
2132
src/void/sketch/create.js
Normal file
File diff suppressed because it is too large
Load diff
112
src/void/sketch/curve.js
Normal file
112
src/void/sketch/curve.js
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
1033
src/void/sketch/geometry.js
Normal file
1033
src/void/sketch/geometry.js
Normal file
File diff suppressed because it is too large
Load diff
484
src/void/sketch/index.js
Normal file
484
src/void/sketch/index.js
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
275
src/void/sketch/marquee.js
Normal file
275
src/void/sketch/marquee.js
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
1226
src/void/sketch/pointer.js
Normal file
1226
src/void/sketch/pointer.js
Normal file
File diff suppressed because it is too large
Load diff
918
src/void/sketch/runtime.js
Normal file
918
src/void/sketch/runtime.js
Normal file
|
|
@ -0,0 +1,918 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
186
src/void/sketch/runtime_arc.js
Normal file
186
src/void/sketch/runtime_arc.js
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
247
src/void/sketch/runtime_markers.js
Normal file
247
src/void/sketch/runtime_markers.js
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 <common>
|
||||||
|
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 <tonemapping_fragment>
|
||||||
|
#include <colorspace_fragment>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
411
src/void/sketch/runtime_profiles.js
Normal file
411
src/void/sketch/runtime_profiles.js
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
1705
src/void/sketch/runtime_ui.js
Normal file
1705
src/void/sketch/runtime_ui.js
Normal file
File diff suppressed because it is too large
Load diff
543
src/void/sketch/tools.js
Normal file
543
src/void/sketch/tools.js
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { SKETCH_VIRTUAL_ORIGIN_ID } from './constants.js';
|
||||||
|
|
||||||
|
function getEditingSketchFeature() {
|
||||||
|
const sketchId = api.sketchRuntime?.editingId;
|
||||||
|
if (!sketchId) return null;
|
||||||
|
const feature = api.features.findById(sketchId);
|
||||||
|
return feature?.type === 'sketch' ? feature : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSketchEditing() {
|
||||||
|
return !!this.getEditingSketchFeature();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSketchTool(tool = 'select') {
|
||||||
|
if (tool === 'arc') tool = 'arc-3pt';
|
||||||
|
if (tool === 'circle') tool = 'circle-center';
|
||||||
|
const allowed = new Set([
|
||||||
|
'select',
|
||||||
|
'point',
|
||||||
|
'line',
|
||||||
|
'arc',
|
||||||
|
'arc-3pt',
|
||||||
|
'arc-center',
|
||||||
|
'arc-tangent',
|
||||||
|
'circle',
|
||||||
|
'circle-center',
|
||||||
|
'circle-3pt',
|
||||||
|
'rect',
|
||||||
|
'rect-center'
|
||||||
|
]);
|
||||||
|
const next = allowed.has(tool) ? tool : 'select';
|
||||||
|
if (this.sketchTool === next) {
|
||||||
|
if (next === 'circle' || next === 'circle-center' || next === 'circle-3pt') {
|
||||||
|
this.cancelSketchCircle();
|
||||||
|
this.sketchPointerDown = null;
|
||||||
|
this.sketchDrag = null;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sketchTool = next;
|
||||||
|
if (next !== 'select') {
|
||||||
|
this.stopSketchMirrorMode?.();
|
||||||
|
this.stopSketchCircularPatternMode?.();
|
||||||
|
this.stopSketchGridPatternMode?.();
|
||||||
|
}
|
||||||
|
if (next !== 'line') {
|
||||||
|
this.cancelSketchLine();
|
||||||
|
}
|
||||||
|
if (next !== 'arc' && next !== 'arc-3pt' && next !== 'arc-center' && next !== 'arc-tangent') {
|
||||||
|
this.cancelSketchArc();
|
||||||
|
}
|
||||||
|
if (next !== 'circle' && next !== 'circle-center' && next !== 'circle-3pt') {
|
||||||
|
this.cancelSketchCircle();
|
||||||
|
}
|
||||||
|
if (next !== 'rect' && next !== 'rect-center') {
|
||||||
|
this.cancelSketchRect();
|
||||||
|
}
|
||||||
|
this.sketchRectCenterMode = next === 'rect-center';
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
window.dispatchEvent(new CustomEvent('void-state-change'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSketchTool() {
|
||||||
|
return this.sketchTool || 'select';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSketchLine() {
|
||||||
|
this.sketchLineStart = null;
|
||||||
|
this.sketchLineStartRefId = null;
|
||||||
|
this.sketchLineStartSeq = null;
|
||||||
|
this.sketchLinePreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSketchArc() {
|
||||||
|
this.sketchArcStart = null;
|
||||||
|
this.sketchArcStartRefId = null;
|
||||||
|
this.sketchArcEnd = null;
|
||||||
|
this.sketchArcEndRefId = null;
|
||||||
|
this.sketchArcPreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSketchCircle() {
|
||||||
|
this.sketchCircleCenter = null;
|
||||||
|
this.sketchCircleSecond = null;
|
||||||
|
this.sketchCircleCenterRefId = null;
|
||||||
|
this.sketchCircleSecondRefId = null;
|
||||||
|
this.sketchCircleStartSeq = null;
|
||||||
|
this.sketchArcPreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSketchRect() {
|
||||||
|
this.sketchRectStart = null;
|
||||||
|
this.sketchRectStartRefId = null;
|
||||||
|
this.sketchRectStartSeq = null;
|
||||||
|
this.sketchRectPreview = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSketchSelection() {
|
||||||
|
this.selectedSketchEntities.clear();
|
||||||
|
this.selectedSketchArcCenters?.clear?.();
|
||||||
|
this.selectedSketchConstraints.clear();
|
||||||
|
this.selectedSketchProfiles?.clear?.();
|
||||||
|
this.hoveredSketchProfileKey = null;
|
||||||
|
api.sketchRuntime?.setSelectedProfiles?.([]);
|
||||||
|
api.sketchRuntime?.setHoveredProfile?.(null);
|
||||||
|
this.hoveredSketchEntityId = null;
|
||||||
|
this.hoveredDerivedCandidate = null;
|
||||||
|
this.selectedDerivedSelections?.clear?.();
|
||||||
|
this.selectedSolidFaceKeys?.clear?.();
|
||||||
|
api.solids?.clearFaceSelection?.();
|
||||||
|
this.hoveredSketchConstraintId = null;
|
||||||
|
this.sketchLinePreview = null;
|
||||||
|
this.sketchArcPreview = null;
|
||||||
|
this.sketchRectPreview = null;
|
||||||
|
this.clearSketchMarquee();
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedSketchMirrorAxis(feature) {
|
||||||
|
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||||
|
const selected = this.selectedSketchEntities instanceof Set ? this.selectedSketchEntities : new Set();
|
||||||
|
const lines = entities.filter(entity => entity?.type === 'line' && selected.has(entity.id));
|
||||||
|
return lines.length === 1 ? lines[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSketchMirrorMode() {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature) return false;
|
||||||
|
const axis = this.getSelectedSketchMirrorAxis(feature);
|
||||||
|
if (!axis?.id) return false;
|
||||||
|
this.sketchMirrorMode = true;
|
||||||
|
this.sketchMirrorAxisId = axis.id;
|
||||||
|
this.stopSketchCircularPatternMode?.();
|
||||||
|
this.setSketchTool('select');
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSketchMirrorMode() {
|
||||||
|
if (!this.sketchMirrorMode && !this.sketchMirrorAxisId) return false;
|
||||||
|
this.sketchMirrorMode = false;
|
||||||
|
this.sketchMirrorAxisId = null;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedSketchPatternCenter(feature) {
|
||||||
|
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||||
|
const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity]));
|
||||||
|
const refs = [];
|
||||||
|
for (const id of this.selectedSketchEntities || []) {
|
||||||
|
if (id === SKETCH_VIRTUAL_ORIGIN_ID) {
|
||||||
|
refs.push(SKETCH_VIRTUAL_ORIGIN_ID);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ent = byId.get(id);
|
||||||
|
if (ent?.type === 'point') refs.push(id);
|
||||||
|
}
|
||||||
|
for (const arcId of this.selectedSketchArcCenters || []) {
|
||||||
|
if (typeof arcId === 'string' && arcId) {
|
||||||
|
refs.push(`arc-center:${arcId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unique = [...new Set(refs)];
|
||||||
|
return unique.length === 1 ? unique[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSketchCircularPatternMode() {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature) return false;
|
||||||
|
const centerRef = this.getSelectedSketchPatternCenter(feature);
|
||||||
|
if (!centerRef) return false;
|
||||||
|
this.sketchCircularPatternMode = true;
|
||||||
|
this.sketchCircularPatternCenterRef = centerRef;
|
||||||
|
this.stopSketchMirrorMode?.();
|
||||||
|
this.setSketchTool('select');
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSketchCircularPatternMode() {
|
||||||
|
if (!this.sketchCircularPatternMode && !this.sketchCircularPatternCenterRef) return false;
|
||||||
|
this.sketchCircularPatternMode = false;
|
||||||
|
this.sketchCircularPatternCenterRef = null;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedSketchGridAnchor(feature) {
|
||||||
|
const entities = Array.isArray(feature?.entities) ? feature.entities : [];
|
||||||
|
const byId = new Map(entities.filter(entity => entity?.id).map(entity => [entity.id, entity]));
|
||||||
|
const pointIds = [];
|
||||||
|
for (const id of this.selectedSketchEntities || []) {
|
||||||
|
const ent = byId.get(id);
|
||||||
|
if (ent?.type === 'point') pointIds.push(id);
|
||||||
|
}
|
||||||
|
const unique = [...new Set(pointIds)];
|
||||||
|
return unique.length === 1 ? unique[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSketchGridPatternMode() {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature) return false;
|
||||||
|
const centerPointId = this.getSelectedSketchGridAnchor(feature);
|
||||||
|
if (!centerPointId) return false;
|
||||||
|
this.sketchGridPatternMode = true;
|
||||||
|
this.sketchGridPatternCenterRef = centerPointId;
|
||||||
|
this.stopSketchMirrorMode?.();
|
||||||
|
this.stopSketchCircularPatternMode?.();
|
||||||
|
this.setSketchTool('select');
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSketchGridPatternMode() {
|
||||||
|
if (!this.sketchGridPatternMode && !this.sketchGridPatternCenterRef) return false;
|
||||||
|
this.sketchGridPatternMode = false;
|
||||||
|
this.sketchGridPatternCenterRef = null;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSketchCircularPatternConstraint(constraintId) {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature || !constraintId) return false;
|
||||||
|
const constraints = Array.isArray(feature.constraints) ? feature.constraints : [];
|
||||||
|
const found = constraints.find(c => c?.id === constraintId && c?.type === 'circular_pattern');
|
||||||
|
if (!found) return false;
|
||||||
|
const current = Math.max(2, Number(found?.data?.count || 0) || 3);
|
||||||
|
const input = window.prompt('Pattern copies', String(current));
|
||||||
|
if (input === null) return false;
|
||||||
|
const value = Math.floor(Number(input));
|
||||||
|
if (!Number.isFinite(value) || value < 2 || value > 256) return false;
|
||||||
|
return !!this.updateCircularPatternConstraintCopies?.(constraintId, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function editSketchGridPatternConstraint(constraintId, axis = 'h') {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature || !constraintId) return false;
|
||||||
|
const constraints = Array.isArray(feature.constraints) ? feature.constraints : [];
|
||||||
|
const found = constraints.find(c => c?.id === constraintId && c?.type === 'grid_pattern');
|
||||||
|
if (!found) return false;
|
||||||
|
const key = axis === 'v' ? 'countV' : 'countH';
|
||||||
|
const current = Math.max(1, Number(found?.data?.[key] || 0) || 3);
|
||||||
|
const input = window.prompt(`${axis === 'v' ? 'Vertical' : 'Horizontal'} copies`, String(current));
|
||||||
|
if (input === null) return false;
|
||||||
|
const value = Math.floor(Number(input));
|
||||||
|
if (!Number.isFinite(value) || value < 1 || value > 256) return false;
|
||||||
|
return !!this.updateGridPatternConstraintCopies?.(constraintId, axis, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSketchKeyDown(event) {
|
||||||
|
if (!this.isSketchEditing()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTag = document.activeElement?.tagName;
|
||||||
|
const editingInput = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
|
||||||
|
if (editingInput) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.code === 'Escape') {
|
||||||
|
this.sketchPointerDown = null;
|
||||||
|
this.sketchDrag = null;
|
||||||
|
if (api?.sketchRuntime) {
|
||||||
|
api.sketchRuntime._glyphDrag = null;
|
||||||
|
}
|
||||||
|
const hadMarquee = !!this.sketchMarquee;
|
||||||
|
if (hadMarquee) {
|
||||||
|
this.clearSketchMarquee();
|
||||||
|
}
|
||||||
|
const hadLine = !!this.sketchLineStart;
|
||||||
|
const hadArc = !!this.sketchArcStart || !!this.sketchArcEnd;
|
||||||
|
const hadRect = !!this.sketchRectStart;
|
||||||
|
if (hadLine) {
|
||||||
|
this.cancelSketchLine();
|
||||||
|
}
|
||||||
|
if (hadArc) {
|
||||||
|
this.cancelSketchArc();
|
||||||
|
}
|
||||||
|
if (hadRect) {
|
||||||
|
this.cancelSketchRect();
|
||||||
|
}
|
||||||
|
if (this.getSketchTool() !== 'select') {
|
||||||
|
this.setSketchTool('select');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.stopSketchMirrorMode?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.stopSketchCircularPatternMode?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (this.stopSketchGridPatternMode?.()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return hadLine || hadArc || hadRect || hadMarquee;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleTool = tool => {
|
||||||
|
const curr = this.getSketchTool();
|
||||||
|
this.setSketchTool(curr === tool ? 'select' : tool);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (event.code === 'KeyL' && !event.shiftKey) {
|
||||||
|
return toggleTool('line');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyA' && !event.shiftKey) {
|
||||||
|
return toggleTool('arc-3pt');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyC' && !event.shiftKey) {
|
||||||
|
return toggleTool('circle-center');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyR' && !event.shiftKey) {
|
||||||
|
return toggleTool('rect-center');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyG' && !event.shiftKey) {
|
||||||
|
return toggleTool('rect');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyS' && event.shiftKey) {
|
||||||
|
return toggleTool('point');
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyQ' && !event.shiftKey) {
|
||||||
|
return this.toggleSelectedConstruction();
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyU' && !event.shiftKey) {
|
||||||
|
return this.useHoveredDerivedEdge();
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyH' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('horizontal');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyV' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('vertical');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyL' && event.shiftKey) {
|
||||||
|
this.applySketchConstraint('perpendicular');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyE' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('equal');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyD' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('dimension');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyT' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('tangent');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyI' && !event.shiftKey) {
|
||||||
|
this.applySketchConstraint('coincident');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyM' && event.shiftKey) {
|
||||||
|
this.applySketchConstraint('midpoint');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyJ' && event.shiftKey) {
|
||||||
|
this.applySketchConstraint('fixed');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (event.code === 'KeyM' && !event.shiftKey) {
|
||||||
|
if (this.sketchMirrorMode) {
|
||||||
|
return this.stopSketchMirrorMode?.();
|
||||||
|
}
|
||||||
|
return this.startSketchMirrorMode?.();
|
||||||
|
}
|
||||||
|
if (event.code === 'Delete' || event.code === 'Backspace') {
|
||||||
|
if (this.selectedSketchConstraints?.size) {
|
||||||
|
return this.deleteSelectedSketchConstraints();
|
||||||
|
}
|
||||||
|
return this.deleteSelectedSketchEntities();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSketchConstraint(constraintId, event = {}) {
|
||||||
|
if (!constraintId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const multi = !!(event.ctrlKey || event.metaKey || event.shiftKey);
|
||||||
|
if (!multi) {
|
||||||
|
if (this.selectedSketchConstraints.size === 1 && this.selectedSketchConstraints.has(constraintId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.selectedSketchConstraints.clear();
|
||||||
|
this.selectedSketchConstraints.add(constraintId);
|
||||||
|
} else {
|
||||||
|
if (this.selectedSketchConstraints.has(constraintId)) {
|
||||||
|
this.selectedSketchConstraints.delete(constraintId);
|
||||||
|
} else {
|
||||||
|
this.selectedSketchConstraints.add(constraintId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.hoveredSketchConstraintId = constraintId;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHoveredSketchConstraint(constraintId) {
|
||||||
|
const next = constraintId || null;
|
||||||
|
if (this.hoveredSketchConstraintId === next) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.hoveredSketchConstraintId = next;
|
||||||
|
this.updateSketchInteractionVisuals();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHoveredDerivedEdge() {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
if (!feature) return false;
|
||||||
|
const selectionMap = this.selectedDerivedSelections instanceof Map
|
||||||
|
? this.selectedDerivedSelections
|
||||||
|
: new Map();
|
||||||
|
const selectedEdges = [];
|
||||||
|
const selectedPoints = [];
|
||||||
|
for (const sel of selectionMap.values()) {
|
||||||
|
if (sel?.type === 'edge') selectedEdges.push(sel);
|
||||||
|
if (sel?.type === 'point') selectedPoints.push(sel);
|
||||||
|
}
|
||||||
|
const selectedFaces = Array.from(this.selectedSolidFaceKeys || []);
|
||||||
|
const hovered = this.hoveredDerivedCandidate || null;
|
||||||
|
if (!selectedEdges.length && !selectedPoints.length && !selectedFaces.length) {
|
||||||
|
// `u` should prioritize the actively hovered derived edge candidate.
|
||||||
|
if (hovered?.aLocal && hovered?.bLocal) {
|
||||||
|
if (Array.isArray(hovered?.pathLocalSegments) && hovered.pathLocalSegments.length > 1) {
|
||||||
|
const worldSegs = Array.isArray(hovered?.pathWorldSegments) ? hovered.pathWorldSegments : [];
|
||||||
|
const segKeys = Array.isArray(hovered?.pathSegmentKeys) ? hovered.pathSegmentKeys : [];
|
||||||
|
const segEntityIds = Array.isArray(hovered?.pathSegmentEntityIds) ? hovered.pathSegmentEntityIds : [];
|
||||||
|
for (let i = 0; i < hovered.pathLocalSegments.length; i++) {
|
||||||
|
const seg = hovered.pathLocalSegments[i];
|
||||||
|
const wseg = worldSegs[i] || null;
|
||||||
|
const segKey = String(segKeys[i] || '');
|
||||||
|
const segEntityId = String(segEntityIds[i] || '');
|
||||||
|
const segKeyParts = segKey ? segKey.split(':') : [];
|
||||||
|
const segIndex = segKeyParts.length >= 4 ? Number(segKeyParts[segKeyParts.length - 1]) : NaN;
|
||||||
|
if (!seg?.a || !seg?.b) continue;
|
||||||
|
selectedEdges.push({
|
||||||
|
type: 'edge',
|
||||||
|
aLocal: seg.a,
|
||||||
|
bLocal: seg.b,
|
||||||
|
source: {
|
||||||
|
...(hovered.source || {}),
|
||||||
|
entity: segEntityId
|
||||||
|
? { kind: 'boundary-segment', id: segEntityId }
|
||||||
|
: (hovered?.source?.entity || null),
|
||||||
|
boundary_segment_id: segEntityId || String(hovered?.source?.boundary_segment_id || ''),
|
||||||
|
edge_key: segKey || String(hovered?.source?.edge_key || ''),
|
||||||
|
edge_index: Number.isFinite(segIndex)
|
||||||
|
? segIndex
|
||||||
|
: Number(hovered?.source?.edge_index ?? i),
|
||||||
|
a: wseg?.a || hovered?.source?.a || null,
|
||||||
|
b: wseg?.b || hovered?.source?.b || null,
|
||||||
|
local_a: seg.a,
|
||||||
|
local_b: seg.b
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedEdges.push({
|
||||||
|
type: 'edge',
|
||||||
|
aLocal: hovered.aLocal,
|
||||||
|
bLocal: hovered.bLocal,
|
||||||
|
source: hovered.source || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (this.hoveredSolidFaceKey) {
|
||||||
|
// Face derive is fallback only when no discrete edge is hovered.
|
||||||
|
selectedFaces.push(this.hoveredSolidFaceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
edges: selectedEdges,
|
||||||
|
points: selectedPoints,
|
||||||
|
faces: selectedFaces
|
||||||
|
};
|
||||||
|
const created = this.deriveSelectionsAtomic(feature, payload);
|
||||||
|
if (!created) return false;
|
||||||
|
// Prevent immediate face-boundary re-highlight after `u` when cursor is still nearby.
|
||||||
|
this.hoveredSolidFaceKey = null;
|
||||||
|
api.solids?.setHoveredFace?.(null);
|
||||||
|
this.clearSketchSelection?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHoveredDerivedPoint() {
|
||||||
|
const feature = this.getEditingSketchFeature();
|
||||||
|
const candidate = this.hoveredDerivedCandidate || null;
|
||||||
|
if (!feature || !candidate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const local = candidate?.hoverPoint?.local || candidate?.midLocal || null;
|
||||||
|
if (!local) return false;
|
||||||
|
const created = this.createDerivedSketchPoint(feature, local, {
|
||||||
|
...(candidate.source || {}),
|
||||||
|
local_point: null,
|
||||||
|
point_kind: candidate?.hoverPoint?.kind || 'mid'
|
||||||
|
});
|
||||||
|
if (!created) return false;
|
||||||
|
this.clearSketchSelection?.();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
getEditingSketchFeature,
|
||||||
|
isSketchEditing,
|
||||||
|
setSketchTool,
|
||||||
|
getSketchTool,
|
||||||
|
cancelSketchLine,
|
||||||
|
cancelSketchArc,
|
||||||
|
cancelSketchCircle,
|
||||||
|
cancelSketchRect,
|
||||||
|
clearSketchSelection,
|
||||||
|
getSelectedSketchMirrorAxis,
|
||||||
|
startSketchMirrorMode,
|
||||||
|
stopSketchMirrorMode,
|
||||||
|
getSelectedSketchPatternCenter,
|
||||||
|
startSketchCircularPatternMode,
|
||||||
|
stopSketchCircularPatternMode,
|
||||||
|
getSelectedSketchGridAnchor,
|
||||||
|
startSketchGridPatternMode,
|
||||||
|
stopSketchGridPatternMode,
|
||||||
|
editSketchCircularPatternConstraint,
|
||||||
|
editSketchGridPatternConstraint,
|
||||||
|
handleSketchKeyDown,
|
||||||
|
selectSketchConstraint,
|
||||||
|
setHoveredSketchConstraint,
|
||||||
|
useHoveredDerivedEdge,
|
||||||
|
useHoveredDerivedPoint
|
||||||
|
};
|
||||||
710
src/void/solid/chamfer.js
Normal file
710
src/void/solid/chamfer.js
Normal file
|
|
@ -0,0 +1,710 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { booleanMeshes } from './kernel.js';
|
||||||
|
|
||||||
|
function vec3(x = 0, y = 0, z = 0) {
|
||||||
|
return { x: Number(x) || 0, y: Number(y) || 0, z: Number(z) || 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(a, b) {
|
||||||
|
return vec3(a.x + b.x, a.y + b.y, a.z + b.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sub(a, b) {
|
||||||
|
return vec3(a.x - b.x, a.y - b.y, a.z - b.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mul(a, s) {
|
||||||
|
return vec3(a.x * s, a.y * s, a.z * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dot(a, b) {
|
||||||
|
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(a, b) {
|
||||||
|
return vec3(
|
||||||
|
a.y * b.z - a.z * b.y,
|
||||||
|
a.z * b.x - a.x * b.z,
|
||||||
|
a.x * b.y - a.y * b.x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function length(a) {
|
||||||
|
return Math.hypot(a.x, a.y, a.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(a) {
|
||||||
|
const len = length(a) || 1;
|
||||||
|
return vec3(a.x / len, a.y / len, a.z / len);
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceSq(a, b) {
|
||||||
|
const d = sub(a, b);
|
||||||
|
return dot(d, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function centroidFromPositions(pos) {
|
||||||
|
const count = Math.floor((pos?.length || 0) / 3);
|
||||||
|
if (!count) return vec3(0, 0, 0);
|
||||||
|
let sx = 0, sy = 0, sz = 0;
|
||||||
|
for (let i = 0; i < pos.length; i += 3) {
|
||||||
|
sx += Number(pos[i] || 0);
|
||||||
|
sy += Number(pos[i + 1] || 0);
|
||||||
|
sz += Number(pos[i + 2] || 0);
|
||||||
|
}
|
||||||
|
return vec3(sx / count, sy / count, sz / count);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointFromPositions(pos, vi) {
|
||||||
|
const i = vi * 3;
|
||||||
|
return vec3(pos[i], pos[i + 1], pos[i + 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalForTriangle(pos, i0, i1, i2) {
|
||||||
|
const a = pointFromPositions(pos, i0);
|
||||||
|
const b = pointFromPositions(pos, i1);
|
||||||
|
const c = pointFromPositions(pos, i2);
|
||||||
|
const ab = sub(b, a);
|
||||||
|
const ac = sub(c, a);
|
||||||
|
const n = cross(ab, ac);
|
||||||
|
const len = length(n);
|
||||||
|
return len > 1e-12 ? mul(n, 1 / len) : vec3(0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeKey(a, b) {
|
||||||
|
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMeshAdjacency(mesh) {
|
||||||
|
const pos = mesh?.positions;
|
||||||
|
const idx = mesh?.indices;
|
||||||
|
if (!pos?.length || !idx?.length) return null;
|
||||||
|
const edgeToTris = new Map();
|
||||||
|
const edgeVerts = new Map();
|
||||||
|
const triCount = Math.floor(idx.length / 3);
|
||||||
|
for (let t = 0; t < triCount; t++) {
|
||||||
|
const i0 = idx[t * 3];
|
||||||
|
const i1 = idx[t * 3 + 1];
|
||||||
|
const i2 = idx[t * 3 + 2];
|
||||||
|
const edges = [[i0, i1], [i1, i2], [i2, i0]];
|
||||||
|
for (const [va, vb] of edges) {
|
||||||
|
const k = edgeKey(va, vb);
|
||||||
|
const list = edgeToTris.get(k);
|
||||||
|
if (list) list.push(t);
|
||||||
|
else edgeToTris.set(k, [t]);
|
||||||
|
if (!edgeVerts.has(k)) edgeVerts.set(k, [va, vb]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { positions: pos, indices: idx, edgeToTris, edgeVerts, centroid: centroidFromPositions(pos) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeEndpointScore(a, b, ea, eb) {
|
||||||
|
const d1 = Math.sqrt(distanceSq(a, ea)) + Math.sqrt(distanceSq(b, eb));
|
||||||
|
const d2 = Math.sqrt(distanceSq(a, eb)) + Math.sqrt(distanceSq(b, ea));
|
||||||
|
return Math.min(d1, d2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTriPrismMesh(a0, a1, a2, b0, b1, b2) {
|
||||||
|
const positions = new Float32Array([
|
||||||
|
a0.x, a0.y, a0.z,
|
||||||
|
a1.x, a1.y, a1.z,
|
||||||
|
a2.x, a2.y, a2.z,
|
||||||
|
b0.x, b0.y, b0.z,
|
||||||
|
b1.x, b1.y, b1.z,
|
||||||
|
b2.x, b2.y, b2.z
|
||||||
|
]);
|
||||||
|
const indices = new Uint32Array([
|
||||||
|
0, 2, 1,
|
||||||
|
3, 4, 5,
|
||||||
|
0, 1, 4,
|
||||||
|
0, 4, 3,
|
||||||
|
1, 2, 5,
|
||||||
|
1, 5, 4,
|
||||||
|
2, 0, 3,
|
||||||
|
2, 3, 5
|
||||||
|
]);
|
||||||
|
return { positions, indices };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEdgeRecordByKey(meshInfo, key) {
|
||||||
|
const parts = String(key || '').split(':');
|
||||||
|
if (parts.length !== 2) return null;
|
||||||
|
const va = Number(parts[0]);
|
||||||
|
const vb = Number(parts[1]);
|
||||||
|
if (!Number.isFinite(va) || !Number.isFinite(vb)) return null;
|
||||||
|
const ek = edgeKey(va, vb);
|
||||||
|
const rep = meshInfo?.edgeVerts?.get?.(ek) || null;
|
||||||
|
const tris = meshInfo?.edgeToTris?.get?.(ek) || null;
|
||||||
|
if (!rep || !Array.isArray(tris) || tris.length < 2) return null;
|
||||||
|
return {
|
||||||
|
va: Number(rep[0]),
|
||||||
|
vb: Number(rep[1]),
|
||||||
|
tris
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseTrianglePair(meshInfo, tris, va, vb, strictCrease = false) {
|
||||||
|
const positions = meshInfo.positions;
|
||||||
|
const indices = meshInfo.indices;
|
||||||
|
const creaseDotMax = Math.cos(30 * Math.PI / 180);
|
||||||
|
let pair = null;
|
||||||
|
let pairDot = 1;
|
||||||
|
for (let i = 0; i < tris.length; i++) {
|
||||||
|
for (let j = i + 1; j < tris.length; j++) {
|
||||||
|
const t0 = tris[i];
|
||||||
|
const t1 = tris[j];
|
||||||
|
const t0i0 = indices[t0 * 3];
|
||||||
|
const t0i1 = indices[t0 * 3 + 1];
|
||||||
|
const t0i2 = indices[t0 * 3 + 2];
|
||||||
|
const t1i0 = indices[t1 * 3];
|
||||||
|
const t1i1 = indices[t1 * 3 + 1];
|
||||||
|
const t1i2 = indices[t1 * 3 + 2];
|
||||||
|
const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2);
|
||||||
|
const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2);
|
||||||
|
const d = Math.max(-1, Math.min(1, dot(n1, n2)));
|
||||||
|
if (strictCrease && d > creaseDotMax) continue;
|
||||||
|
if (d < pairDot) {
|
||||||
|
pairDot = d;
|
||||||
|
pair = { t0, t1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCutterFromResolvedEdge(meshInfo, va, vb, tris, distance) {
|
||||||
|
const positions = meshInfo.positions;
|
||||||
|
const indices = meshInfo.indices;
|
||||||
|
const triPair = chooseTrianglePair(meshInfo, tris, va, vb, false);
|
||||||
|
if (!triPair) return null;
|
||||||
|
const t0 = triPair.t0;
|
||||||
|
const t1 = triPair.t1;
|
||||||
|
const t0i0 = indices[t0 * 3];
|
||||||
|
const t0i1 = indices[t0 * 3 + 1];
|
||||||
|
const t0i2 = indices[t0 * 3 + 2];
|
||||||
|
const t1i0 = indices[t1 * 3];
|
||||||
|
const t1i1 = indices[t1 * 3 + 1];
|
||||||
|
const t1i2 = indices[t1 * 3 + 2];
|
||||||
|
const n1 = normalForTriangle(positions, t0i0, t0i1, t0i2);
|
||||||
|
const n2 = normalForTriangle(positions, t1i0, t1i1, t1i2);
|
||||||
|
const a = pointFromPositions(positions, va);
|
||||||
|
const b = pointFromPositions(positions, vb);
|
||||||
|
const edge = sub(b, a);
|
||||||
|
const edgeLen = length(edge);
|
||||||
|
if (edgeLen <= 1e-8) return null;
|
||||||
|
const e = mul(edge, 1 / edgeLen);
|
||||||
|
let u1 = sub(n1, mul(e, dot(n1, e)));
|
||||||
|
let u2 = sub(n2, mul(e, dot(n2, e)));
|
||||||
|
if (length(u1) <= 1e-8 || length(u2) <= 1e-8) return null;
|
||||||
|
u1 = normalize(u1);
|
||||||
|
u2 = normalize(u2);
|
||||||
|
const normalDelta = Math.max(-1, Math.min(1, dot(u1, u2)));
|
||||||
|
if (normalDelta > 0.997) return null;
|
||||||
|
let i1 = mul(u1, -1);
|
||||||
|
let i2 = mul(u2, -1);
|
||||||
|
const mid = mul(add(a, b), 0.5);
|
||||||
|
const toCenter = sub(meshInfo.centroid, mid);
|
||||||
|
if (dot(i1, toCenter) < 0 && dot(i2, toCenter) < 0) {
|
||||||
|
i1 = mul(i1, -1);
|
||||||
|
i2 = mul(i2, -1);
|
||||||
|
}
|
||||||
|
const ext = distance * 1.4;
|
||||||
|
const spineA = add(a, mul(e, -ext));
|
||||||
|
const spineB = add(b, mul(e, ext));
|
||||||
|
let out = mul(add(i1, i2), -1);
|
||||||
|
if (length(out) <= 1e-8) {
|
||||||
|
out = mul(i1, -1);
|
||||||
|
} else {
|
||||||
|
out = normalize(out);
|
||||||
|
}
|
||||||
|
const insideScale = distance * 1.8;
|
||||||
|
const outsideScale = distance * 0.7;
|
||||||
|
const a0 = add(spineA, mul(out, outsideScale));
|
||||||
|
const a1 = add(spineA, mul(i1, insideScale));
|
||||||
|
const a2 = add(spineA, mul(i2, insideScale));
|
||||||
|
const b0 = add(spineB, mul(out, outsideScale));
|
||||||
|
const b1 = add(spineB, mul(i1, insideScale));
|
||||||
|
const b2 = add(spineB, mul(i2, insideScale));
|
||||||
|
const area = length(cross(sub(a1, a0), sub(a2, a0)));
|
||||||
|
if (area <= 1e-8) return null;
|
||||||
|
return {
|
||||||
|
key: edgeKey(va, vb),
|
||||||
|
mesh: makeTriPrismMesh(a0, a1, a2, b0, b1, b2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCutterForMeshEdgeKey(meshInfo, meshEdgeKey, distance) {
|
||||||
|
const rec = getEdgeRecordByKey(meshInfo, meshEdgeKey);
|
||||||
|
if (!rec) return null;
|
||||||
|
return buildCutterFromResolvedEdge(meshInfo, rec.va, rec.vb, rec.tris, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCutterForSegment(meshInfo, aPoint, bPoint, distance) {
|
||||||
|
if (!meshInfo || !aPoint || !bPoint || !(distance > 0)) return null;
|
||||||
|
const positions = meshInfo.positions;
|
||||||
|
let best = null;
|
||||||
|
let bestScore = Infinity;
|
||||||
|
const creaseDotMax = Math.cos(30 * Math.PI / 180);
|
||||||
|
const requestedLen = Math.sqrt(distanceSq(aPoint, bPoint));
|
||||||
|
if (!(requestedLen > 1e-9)) return null;
|
||||||
|
const reqDir = normalize(sub(bPoint, aPoint));
|
||||||
|
const reqMid = mul(add(aPoint, bPoint), 0.5);
|
||||||
|
const findBest = (relaxed = false) => {
|
||||||
|
let localBest = null;
|
||||||
|
let localBestScore = Infinity;
|
||||||
|
for (const [ek, tris] of meshInfo.edgeToTris.entries()) {
|
||||||
|
if (!Array.isArray(tris) || tris.length < 2) continue;
|
||||||
|
const parts = String(ek).split(':');
|
||||||
|
if (parts.length !== 2) continue;
|
||||||
|
const rep = meshInfo.edgeVerts?.get?.(ek) || null;
|
||||||
|
const va = Number(rep?.[0]);
|
||||||
|
const vb = Number(rep?.[1]);
|
||||||
|
if (!Number.isFinite(va) || !Number.isFinite(vb) || va === vb) continue;
|
||||||
|
const ea = pointFromPositions(positions, va);
|
||||||
|
const eb = pointFromPositions(positions, vb);
|
||||||
|
const cand = sub(eb, ea);
|
||||||
|
const candLen = length(cand);
|
||||||
|
if (!(candLen > 1e-9)) continue;
|
||||||
|
const candDir = mul(cand, 1 / candLen);
|
||||||
|
const dirAlign = Math.abs(dot(reqDir, candDir));
|
||||||
|
const candMid = mul(add(ea, eb), 0.5);
|
||||||
|
const midDist = Math.sqrt(distanceSq(reqMid, candMid));
|
||||||
|
if (!relaxed) {
|
||||||
|
if (dirAlign < 0.5) continue;
|
||||||
|
const maxMidDist = Math.max(1.5, requestedLen * 0.8, candLen * 0.8);
|
||||||
|
if (midDist > maxMidDist) continue;
|
||||||
|
}
|
||||||
|
// Find a usable face-pair on this edge.
|
||||||
|
const pair = chooseTrianglePair(meshInfo, tris, va, vb, false);
|
||||||
|
const pairDot = (() => {
|
||||||
|
if (!pair) return 1;
|
||||||
|
const idx = meshInfo.indices;
|
||||||
|
const t0i0 = idx[pair.t0 * 3];
|
||||||
|
const t0i1 = idx[pair.t0 * 3 + 1];
|
||||||
|
const t0i2 = idx[pair.t0 * 3 + 2];
|
||||||
|
const t1i0 = idx[pair.t1 * 3];
|
||||||
|
const t1i1 = idx[pair.t1 * 3 + 1];
|
||||||
|
const t1i2 = idx[pair.t1 * 3 + 2];
|
||||||
|
const n1 = normalForTriangle(meshInfo.positions, t0i0, t0i1, t0i2);
|
||||||
|
const n2 = normalForTriangle(meshInfo.positions, t1i0, t1i1, t1i2);
|
||||||
|
return Math.max(-1, Math.min(1, dot(n1, n2)));
|
||||||
|
})();
|
||||||
|
if (!pair) continue;
|
||||||
|
if (!relaxed && pairDot > creaseDotMax) continue;
|
||||||
|
const score = edgeEndpointScore(aPoint, bPoint, ea, eb) + midDist * 0.5 + (1 - dirAlign) * (relaxed ? 0.1 : 2);
|
||||||
|
if (score < localBestScore) {
|
||||||
|
localBestScore = score;
|
||||||
|
localBest = { va, vb, tris: [pair.t0, pair.t1] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { best: localBest, score: localBestScore };
|
||||||
|
};
|
||||||
|
const strict = findBest(false);
|
||||||
|
if (strict.best) {
|
||||||
|
best = strict.best;
|
||||||
|
bestScore = strict.score;
|
||||||
|
} else {
|
||||||
|
const relaxed = findBest(true);
|
||||||
|
best = relaxed.best;
|
||||||
|
bestScore = relaxed.score;
|
||||||
|
}
|
||||||
|
if (!best) return null;
|
||||||
|
return buildCutterFromResolvedEdge(meshInfo, best.va, best.vb, best.tris, distance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsFromEdgeRef(edgeRef) {
|
||||||
|
if (!edgeRef) return [];
|
||||||
|
const path = Array.isArray(edgeRef.path) ? edgeRef.path : null;
|
||||||
|
if (path?.length >= 2) {
|
||||||
|
let pts = path.map(p => vec3(p.x, p.y, p.z));
|
||||||
|
// Remove duplicated closing point if present.
|
||||||
|
if (pts.length > 2) {
|
||||||
|
const first = pts[0];
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
if (Math.sqrt(distanceSq(first, last)) <= 1e-7) {
|
||||||
|
pts = pts.slice(0, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Decimate very dense loops for cutter robustness/perf.
|
||||||
|
const maxPts = 49; // => at most 49 segments for closed loops
|
||||||
|
if (pts.length > maxPts) {
|
||||||
|
const reduced = [];
|
||||||
|
for (let i = 0; i < maxPts; i++) {
|
||||||
|
const t = i / (maxPts - 1);
|
||||||
|
const idx = Math.round(t * (pts.length - 1));
|
||||||
|
reduced.push(pts[Math.max(0, Math.min(pts.length - 1, idx))]);
|
||||||
|
}
|
||||||
|
pts = reduced;
|
||||||
|
}
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) {
|
||||||
|
const a = pts[i];
|
||||||
|
const b = pts[i + 1];
|
||||||
|
if (!a || !b) continue;
|
||||||
|
out.push([a, b]);
|
||||||
|
}
|
||||||
|
// Close if this came from a loop path.
|
||||||
|
if (pts.length > 2) {
|
||||||
|
out.push([pts[pts.length - 1], pts[0]]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const a = edgeRef?.a;
|
||||||
|
const b = edgeRef?.b;
|
||||||
|
if (a && b) return [[vec3(a.x, a.y, a.z), vec3(b.x, b.y, b.z)]];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatMeshes(meshes = []) {
|
||||||
|
if (!Array.isArray(meshes) || !meshes.length) return null;
|
||||||
|
let posLen = 0;
|
||||||
|
let idxLen = 0;
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
if (!mesh?.positions?.length || !mesh?.indices?.length) continue;
|
||||||
|
posLen += mesh.positions.length;
|
||||||
|
idxLen += mesh.indices.length;
|
||||||
|
}
|
||||||
|
if (!posLen || !idxLen) return null;
|
||||||
|
const positions = new Float32Array(posLen);
|
||||||
|
const indices = new Uint32Array(idxLen);
|
||||||
|
let po = 0;
|
||||||
|
let io = 0;
|
||||||
|
let vBase = 0;
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
if (!mesh?.positions?.length || !mesh?.indices?.length) continue;
|
||||||
|
positions.set(mesh.positions, po);
|
||||||
|
for (let i = 0; i < mesh.indices.length; i++) {
|
||||||
|
indices[io + i] = Number(mesh.indices[i] || 0) + vBase;
|
||||||
|
}
|
||||||
|
po += mesh.positions.length;
|
||||||
|
io += mesh.indices.length;
|
||||||
|
vBase += Math.floor(mesh.positions.length / 3);
|
||||||
|
}
|
||||||
|
return { positions, indices };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleMeshAroundCentroid(mesh, scale = 1.001) {
|
||||||
|
if (!mesh?.positions?.length || !mesh?.indices?.length) return null;
|
||||||
|
const c = centroidFromPositions(mesh.positions);
|
||||||
|
const out = new Float32Array(mesh.positions.length);
|
||||||
|
for (let i = 0; i < mesh.positions.length; i += 3) {
|
||||||
|
const x = Number(mesh.positions[i] || 0);
|
||||||
|
const y = Number(mesh.positions[i + 1] || 0);
|
||||||
|
const z = Number(mesh.positions[i + 2] || 0);
|
||||||
|
out[i] = c.x + (x - c.x) * scale;
|
||||||
|
out[i + 1] = c.y + (y - c.y) * scale;
|
||||||
|
out[i + 2] = c.z + (z - c.z) * scale;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
positions: out,
|
||||||
|
indices: mesh.indices instanceof Uint32Array ? mesh.indices : new Uint32Array(mesh.indices || [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneMesh(mesh) {
|
||||||
|
if (!mesh?.positions?.length || !mesh?.indices?.length) return null;
|
||||||
|
return {
|
||||||
|
positions: mesh.positions instanceof Float32Array ? new Float32Array(mesh.positions) : new Float32Array(mesh.positions || []),
|
||||||
|
indices: mesh.indices instanceof Uint32Array ? new Uint32Array(mesh.indices) : new Uint32Array(mesh.indices || [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, reason = 'no-op') {
|
||||||
|
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
|
||||||
|
const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids)
|
||||||
|
? targetSolid.source.sketch_ids.slice()
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
id: nextId,
|
||||||
|
name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
pass_through: true,
|
||||||
|
reason,
|
||||||
|
sketch_ids: sketchIds
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
pass_through: true,
|
||||||
|
reason
|
||||||
|
},
|
||||||
|
parents: [solidId]
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
tri_count: targetSolid?.mesh?.tri_count || 0,
|
||||||
|
vert_count: targetSolid?.mesh?.vert_count || 0
|
||||||
|
},
|
||||||
|
status: 'manifold_chamfer_passthrough'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBoundarySegmentRef(boundarySegmentId) {
|
||||||
|
const raw = String(boundarySegmentId || '');
|
||||||
|
if (!raw) return null;
|
||||||
|
const parts = raw.split(':');
|
||||||
|
if (raw.startsWith('segment:') && parts.length >= 5) {
|
||||||
|
const segInLoop = Number(parts[parts.length - 1]);
|
||||||
|
const loopIndex = Number(parts[parts.length - 2]);
|
||||||
|
const faceId = Number(parts[parts.length - 3]);
|
||||||
|
const solidId = parts.slice(1, -3).join(':');
|
||||||
|
if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex) || !Number.isFinite(segInLoop)) return null;
|
||||||
|
return { kind: 'segment', id: raw, solidId, faceId, loopIndex, segInLoop };
|
||||||
|
}
|
||||||
|
if (raw.startsWith('boundary:') && parts.length >= 4) {
|
||||||
|
const loopIndex = Number(parts[parts.length - 1]);
|
||||||
|
const faceId = Number(parts[parts.length - 2]);
|
||||||
|
const solidId = parts.slice(1, -2).join(':');
|
||||||
|
if (!solidId || !Number.isFinite(faceId) || !Number.isFinite(loopIndex)) return null;
|
||||||
|
return { kind: 'boundary', id: raw, solidId, faceId, loopIndex };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyChamferFeature(solids, meshCache, feature, makeBodyId, bodySeqRef) {
|
||||||
|
const refs = Array.isArray(feature?.input?.edges) ? feature.input.edges : [];
|
||||||
|
const distance = Math.max(0.0001, Math.abs(Number(feature?.params?.distance ?? 1)));
|
||||||
|
const showCutters = feature?.params?.showCutters === true;
|
||||||
|
if (!refs.length || !(distance > 0)) return false;
|
||||||
|
|
||||||
|
const bySolid = new Map();
|
||||||
|
for (const ref of refs) {
|
||||||
|
let solidId = String(ref?.solidId || ref?.solid_id || '');
|
||||||
|
if (!solidId) {
|
||||||
|
const parsed = parseBoundarySegmentRef(ref?.boundary_segment_id || ref?.entity?.id || ref?.key || null);
|
||||||
|
solidId = String(parsed?.solidId || '');
|
||||||
|
}
|
||||||
|
if (!solidId) continue;
|
||||||
|
const list = bySolid.get(solidId);
|
||||||
|
if (list) list.push(ref);
|
||||||
|
else bySolid.set(solidId, [ref]);
|
||||||
|
}
|
||||||
|
if (!bySolid.size) return false;
|
||||||
|
|
||||||
|
let changed = false;
|
||||||
|
for (const [solidId, solidRefs] of bySolid.entries()) {
|
||||||
|
const targetSolid = solids.find(s => s?.id === solidId) || null;
|
||||||
|
const targetMesh = meshCache.get(solidId);
|
||||||
|
if (!targetSolid || !targetMesh?.positions?.length || !targetMesh?.indices?.length) continue;
|
||||||
|
const adj = buildMeshAdjacency(targetMesh);
|
||||||
|
if (!adj) continue;
|
||||||
|
const tools = [];
|
||||||
|
const usedEdgeKeys = new Set();
|
||||||
|
for (const ref of solidRefs) {
|
||||||
|
const meshEdgeKeys = Array.isArray(ref?.meshEdgeKeys) ? ref.meshEdgeKeys.filter(Boolean) : [];
|
||||||
|
if (meshEdgeKeys.length) {
|
||||||
|
for (const mek of meshEdgeKeys) {
|
||||||
|
const built = buildCutterForMeshEdgeKey(adj, mek, distance);
|
||||||
|
const cutter = built?.mesh || null;
|
||||||
|
const cutterKey = String(built?.key || mek || '');
|
||||||
|
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
|
||||||
|
if (cutter?.positions?.length && cutter?.indices?.length) {
|
||||||
|
if (cutterKey) usedEdgeKeys.add(cutterKey);
|
||||||
|
tools.push(cutter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ref?.meshEdgeKey && !(Array.isArray(ref.path) && ref.path.length >= 2)) {
|
||||||
|
const built = buildCutterForMeshEdgeKey(adj, ref.meshEdgeKey, distance);
|
||||||
|
const cutter = built?.mesh || null;
|
||||||
|
const cutterKey = String(built?.key || '');
|
||||||
|
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
|
||||||
|
if (cutter?.positions?.length && cutter?.indices?.length) {
|
||||||
|
if (cutterKey) usedEdgeKeys.add(cutterKey);
|
||||||
|
tools.push(cutter);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const segs = segmentsFromEdgeRef(ref);
|
||||||
|
for (const [a, b] of segs) {
|
||||||
|
const built = buildCutterForSegment(adj, a, b, distance);
|
||||||
|
const cutter = built?.mesh || null;
|
||||||
|
const cutterKey = String(built?.key || '');
|
||||||
|
if (cutterKey && usedEdgeKeys.has(cutterKey)) continue;
|
||||||
|
if (cutter?.positions?.length && cutter?.indices?.length) {
|
||||||
|
if (cutterKey) usedEdgeKeys.add(cutterKey);
|
||||||
|
tools.push(cutter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!tools.length) {
|
||||||
|
console.warn('void.chamfer.no_cutters', { featureId: feature?.id, solidId, refs: solidRefs.length });
|
||||||
|
const targetIndex = solids.findIndex(s => s?.id === solidId);
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'no_cutters');
|
||||||
|
const copied = cloneMesh(targetMesh);
|
||||||
|
if (copied?.positions?.length && copied?.indices?.length) {
|
||||||
|
solids[targetIndex] = nextSolid;
|
||||||
|
meshCache.delete(solidId);
|
||||||
|
meshCache.set(nextSolid.id, copied);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (showCutters) {
|
||||||
|
const merged = concatMeshes(tools);
|
||||||
|
if (merged?.positions?.length && merged?.indices?.length) {
|
||||||
|
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
|
||||||
|
const nextSolid = {
|
||||||
|
id: nextId,
|
||||||
|
name: `${feature.name || 'Chamfer'}-cutters`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
debug: 'cutters',
|
||||||
|
distance
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
debug: 'cutters'
|
||||||
|
},
|
||||||
|
parents: [solidId]
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
tri_count: (merged.indices.length || 0) / 3,
|
||||||
|
vert_count: (merged.positions.length || 0) / 3
|
||||||
|
},
|
||||||
|
status: 'manifold_chamfer_debug_cutters'
|
||||||
|
};
|
||||||
|
solids.push(nextSolid);
|
||||||
|
meshCache.set(nextId, merged);
|
||||||
|
console.log('void.chamfer.debug_cutters', {
|
||||||
|
featureId: feature?.id,
|
||||||
|
solidId,
|
||||||
|
cutters: tools.length,
|
||||||
|
tri: Math.floor((merged.indices.length || 0) / 3)
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = await booleanMeshes({
|
||||||
|
mode: 'subtract',
|
||||||
|
targets: [targetMesh],
|
||||||
|
tools
|
||||||
|
});
|
||||||
|
if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) {
|
||||||
|
// Fallback: sequential subtract can be more robust than bulk subtract.
|
||||||
|
let current = targetMesh;
|
||||||
|
let applied = 0;
|
||||||
|
for (const tool of tools) {
|
||||||
|
const step = await booleanMeshes({
|
||||||
|
mode: 'subtract',
|
||||||
|
targets: [current],
|
||||||
|
tools: [tool]
|
||||||
|
});
|
||||||
|
if (step?.mesh?.positions?.length && step?.mesh?.indices?.length) {
|
||||||
|
current = step.mesh;
|
||||||
|
applied++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Retry with tiny perturbation to avoid coplanar/not-manifold failures.
|
||||||
|
const grown = scaleMeshAroundCentroid(tool, 1.001);
|
||||||
|
if (grown) {
|
||||||
|
const stepGrown = await booleanMeshes({
|
||||||
|
mode: 'subtract',
|
||||||
|
targets: [current],
|
||||||
|
tools: [grown]
|
||||||
|
});
|
||||||
|
if (stepGrown?.mesh?.positions?.length && stepGrown?.mesh?.indices?.length) {
|
||||||
|
current = stepGrown.mesh;
|
||||||
|
applied++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (applied > 0) {
|
||||||
|
result = { mesh: current };
|
||||||
|
console.warn('void.chamfer.bulk_failed_sequential_used', {
|
||||||
|
featureId: feature?.id,
|
||||||
|
solidId,
|
||||||
|
cutters: tools.length,
|
||||||
|
applied
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result?.mesh?.positions?.length || !result?.mesh?.indices?.length) {
|
||||||
|
console.warn('void.chamfer.boolean_failed', { featureId: feature?.id, solidId, cutters: tools.length });
|
||||||
|
const targetIndex = solids.findIndex(s => s?.id === solidId);
|
||||||
|
if (targetIndex >= 0) {
|
||||||
|
const nextSolid = makePassThroughSolid(feature, targetSolid, solidId, bodySeqRef, makeBodyId, 'boolean_failed');
|
||||||
|
const copied = cloneMesh(targetMesh);
|
||||||
|
if (copied?.positions?.length && copied?.indices?.length) {
|
||||||
|
solids[targetIndex] = nextSolid;
|
||||||
|
meshCache.delete(solidId);
|
||||||
|
meshCache.set(nextSolid.id, copied);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetIndex = solids.findIndex(s => s?.id === solidId);
|
||||||
|
if (targetIndex < 0) continue;
|
||||||
|
const nextId = makeBodyId(feature.id, bodySeqRef.value++);
|
||||||
|
const sketchIds = Array.isArray(targetSolid?.source?.sketch_ids)
|
||||||
|
? targetSolid.source.sketch_ids.slice()
|
||||||
|
: [];
|
||||||
|
const nextSolid = {
|
||||||
|
id: nextId,
|
||||||
|
name: `${feature.name || 'Chamfer'}-${bodySeqRef.value}`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
edges: solidRefs.map(ref => ({
|
||||||
|
key: ref?.key || null,
|
||||||
|
boundary_segment_id: ref?.boundary_segment_id || null,
|
||||||
|
solidId: ref?.solidId || null,
|
||||||
|
edgeIndex: ref?.edgeIndex ?? null
|
||||||
|
})),
|
||||||
|
distance,
|
||||||
|
sketch_ids: sketchIds
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
parent: solidId,
|
||||||
|
distance
|
||||||
|
},
|
||||||
|
parents: [solidId]
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
tri_count: (result.mesh.indices.length || 0) / 3,
|
||||||
|
vert_count: (result.mesh.positions.length || 0) / 3
|
||||||
|
},
|
||||||
|
status: 'manifold_chamfer_ready'
|
||||||
|
};
|
||||||
|
solids.splice(targetIndex, 1, nextSolid);
|
||||||
|
meshCache.delete(solidId);
|
||||||
|
meshCache.set(nextId, result.mesh);
|
||||||
|
console.log('void.chamfer.applied', {
|
||||||
|
featureId: feature?.id,
|
||||||
|
solidId,
|
||||||
|
cutters: tools.length,
|
||||||
|
triIn: Math.floor((targetMesh.indices?.length || 0) / 3),
|
||||||
|
triOut: Math.floor((result.mesh.indices?.length || 0) / 3)
|
||||||
|
});
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
applyChamferFeature
|
||||||
|
};
|
||||||
158
src/void/solid/kernel.js
Normal file
158
src/void/solid/kernel.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import manifold from '../../ext/manifold.js';
|
||||||
|
|
||||||
|
let _instance = null;
|
||||||
|
let _initPromise = null;
|
||||||
|
|
||||||
|
function locateFile(path) {
|
||||||
|
return `../wasm/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureKernel() {
|
||||||
|
if (_instance) return _instance;
|
||||||
|
if (_initPromise) return _initPromise;
|
||||||
|
_initPromise = manifold({ locateFile }).then(inst => {
|
||||||
|
inst.setup();
|
||||||
|
_instance = inst;
|
||||||
|
return _instance;
|
||||||
|
}).catch(error => {
|
||||||
|
console.warn('void.solid.kernel init failed', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return _initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReady() {
|
||||||
|
return !!_instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstance() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extrudePolygons(polygons, height = 1) {
|
||||||
|
const inst = await ensureKernel();
|
||||||
|
if (!inst?.Manifold || !Array.isArray(polygons) || !polygons.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const man = inst.Manifold.extrude(polygons, Number(height) || 1);
|
||||||
|
const mesh = man.getMesh();
|
||||||
|
// Callers are responsible for consuming mesh data and deleting manifold.
|
||||||
|
return { manifold: man, mesh };
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('void.solid.kernel extrude failed', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKernelMesh(inst, meshData) {
|
||||||
|
const positions = meshData?.positions;
|
||||||
|
const indices = meshData?.indices;
|
||||||
|
if (!positions?.length || !indices?.length) return null;
|
||||||
|
const vertCount = Math.floor(positions.length / 3);
|
||||||
|
const props = new Float32Array(vertCount * 3);
|
||||||
|
props.set(positions);
|
||||||
|
return new inst.Mesh({
|
||||||
|
numProp: 3,
|
||||||
|
vertProperties: props,
|
||||||
|
triVerts: Uint32Array.from(indices)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromKernelMesh(mesh) {
|
||||||
|
const numProp = Math.max(3, Number(mesh?.numProp || 3));
|
||||||
|
const verts = mesh?.vertProperties;
|
||||||
|
const triVerts = mesh?.triVerts;
|
||||||
|
if (!verts?.length || !triVerts?.length) return null;
|
||||||
|
const vertCount = Math.floor(verts.length / numProp);
|
||||||
|
const positions = new Float32Array(vertCount * 3);
|
||||||
|
for (let i = 0; i < vertCount; i++) {
|
||||||
|
const src = i * numProp;
|
||||||
|
const dst = i * 3;
|
||||||
|
positions[dst] = Number(verts[src] || 0);
|
||||||
|
positions[dst + 1] = Number(verts[src + 1] || 0);
|
||||||
|
positions[dst + 2] = Number(verts[src + 2] || 0);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
indices: Uint32Array.from(triVerts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function booleanMeshes(input, mode = 'add') {
|
||||||
|
const inst = await ensureKernel();
|
||||||
|
if (!inst?.Manifold) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const options = Array.isArray(input)
|
||||||
|
? { meshes: input, mode }
|
||||||
|
: { ...(input || {}), mode: String((input || {}).mode || mode || 'add') };
|
||||||
|
const op = String(options.mode || 'add');
|
||||||
|
const meshes = Array.isArray(options.meshes) ? options.meshes : [];
|
||||||
|
const targetMeshes = Array.isArray(options.targets) ? options.targets : null;
|
||||||
|
const toolMeshes = Array.isArray(options.tools) ? options.tools : null;
|
||||||
|
const manifolds = [];
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
const toManifold = meshData => {
|
||||||
|
const kernelMesh = toKernelMesh(inst, meshData);
|
||||||
|
return kernelMesh ? new inst.Manifold(kernelMesh) : null;
|
||||||
|
};
|
||||||
|
const combine = (list, kind = 'add') => {
|
||||||
|
if (!Array.isArray(list) || !list.length) return null;
|
||||||
|
if (list.length === 1) return list[0];
|
||||||
|
if (kind === 'intersect') return inst.Manifold.intersection(list);
|
||||||
|
if (kind === 'subtract') return inst.Manifold.difference(list);
|
||||||
|
return inst.Manifold.union(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (op === 'subtract' && targetMeshes && toolMeshes) {
|
||||||
|
const targetMfs = targetMeshes.map(toManifold).filter(Boolean);
|
||||||
|
const toolMfs = toolMeshes.map(toManifold).filter(Boolean);
|
||||||
|
manifolds.push(...targetMfs, ...toolMfs);
|
||||||
|
if (!targetMfs.length || !toolMfs.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetUnion = combine(targetMfs, 'add');
|
||||||
|
const toolUnion = combine(toolMfs, 'add');
|
||||||
|
if (!targetUnion || !toolUnion) return null;
|
||||||
|
if (!targetMfs.includes(targetUnion)) manifolds.push(targetUnion);
|
||||||
|
if (!toolMfs.includes(toolUnion)) manifolds.push(toolUnion);
|
||||||
|
result = inst.Manifold.difference([targetUnion, toolUnion]);
|
||||||
|
} else {
|
||||||
|
for (const meshData of meshes) {
|
||||||
|
const manifold = toManifold(meshData);
|
||||||
|
if (!manifold) continue;
|
||||||
|
manifolds.push(manifold);
|
||||||
|
}
|
||||||
|
if (manifolds.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (op === 'intersect') {
|
||||||
|
result = inst.Manifold.intersection(manifolds);
|
||||||
|
} else {
|
||||||
|
result = inst.Manifold.union(manifolds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mesh = result?.getMesh?.();
|
||||||
|
return mesh ? { mesh: fromKernelMesh(mesh) } : null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('void.solid.kernel boolean failed', error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
for (const manifold of manifolds) {
|
||||||
|
manifold?.delete?.();
|
||||||
|
}
|
||||||
|
result?.delete?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ensureKernel,
|
||||||
|
isReady,
|
||||||
|
getInstance,
|
||||||
|
extrudePolygons,
|
||||||
|
booleanMeshes
|
||||||
|
};
|
||||||
22
src/void/solid/provenance.js
Normal file
22
src/void/solid/provenance.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
|
|
||||||
564
src/void/solid/rebuild.js
Normal file
564
src/void/solid/rebuild.js
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { buildSeedProvenance } from './provenance.js';
|
||||||
|
import { extrudePolygons, booleanMeshes } from './kernel.js';
|
||||||
|
import { ClipperLib } from '../../ext/clip2.esm.js';
|
||||||
|
import { applyChamferFeature } from './chamfer.js';
|
||||||
|
|
||||||
|
const CLIPPER_SCALE = 100000;
|
||||||
|
|
||||||
|
function resolveProfileTargetRef(profileTarget = {}) {
|
||||||
|
const regionId = String(profileTarget?.region_id || '');
|
||||||
|
const match = regionId.match(/^profile:([^:]+):([^:]+)$/);
|
||||||
|
if (!match) return { regionId: null, sketchId: null, profileId: null, key: null };
|
||||||
|
const sketchId = match[1];
|
||||||
|
const profileId = match[2];
|
||||||
|
return { regionId, sketchId, profileId, key: regionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileLoopsFromRuntime(api, profileTarget) {
|
||||||
|
const { sketchId, profileId } = resolveProfileTargetRef(profileTarget);
|
||||||
|
if (!sketchId || !profileId) return null;
|
||||||
|
const rec = api.sketchRuntime?.getRecord?.(sketchId);
|
||||||
|
const view = rec?.entityViews?.get?.(profileId);
|
||||||
|
const loops = view?.object?.userData?.sketchProfileLoops || view?.entity?.loops || null;
|
||||||
|
if (Array.isArray(loops) && loops.length) {
|
||||||
|
const out = loops.filter(loop => Array.isArray(loop) && loop.length >= 3);
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
const loop = view?.object?.userData?.sketchProfileLoop || view?.entity?.loop || null;
|
||||||
|
if (Array.isArray(loop) && loop.length >= 3) return [loop];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileLoopsFromSnapshot(snapshot, profileTarget) {
|
||||||
|
const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget);
|
||||||
|
if (!key || !sketchId || !profileId) return null;
|
||||||
|
const map = snapshot?.profileLoops || {};
|
||||||
|
const loops = map[key];
|
||||||
|
if (!Array.isArray(loops) || !loops.length) return null;
|
||||||
|
return loops
|
||||||
|
.filter(loop => Array.isArray(loop) && loop.length >= 3)
|
||||||
|
.map(loop => loop.map(p => ({ x: Number(p?.x || 0), y: Number(p?.y || 0) })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBodyId(featureId, index) {
|
||||||
|
return `${featureId}:body:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSketchIdsForSolid(solid) {
|
||||||
|
const ids = new Set();
|
||||||
|
const add = value => {
|
||||||
|
if (value) ids.add(value);
|
||||||
|
};
|
||||||
|
add(solid?.source?.profile?.sketchId);
|
||||||
|
for (const sid of solid?.source?.sketch_ids || []) {
|
||||||
|
add(sid);
|
||||||
|
}
|
||||||
|
add(solid?.provenance?.source?.profile?.sketchId);
|
||||||
|
for (const face of solid?.provenance?.faces || []) {
|
||||||
|
add(face?.source?.sketchId);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function basisFromPlaneFrame(frame) {
|
||||||
|
const origin = {
|
||||||
|
x: Number(frame?.origin?.x ?? 0),
|
||||||
|
y: Number(frame?.origin?.y ?? 0),
|
||||||
|
z: Number(frame?.origin?.z ?? 0)
|
||||||
|
};
|
||||||
|
const normalRaw = {
|
||||||
|
x: Number(frame?.normal?.x ?? 0),
|
||||||
|
y: Number(frame?.normal?.y ?? 0),
|
||||||
|
z: Number(frame?.normal?.z ?? 1)
|
||||||
|
};
|
||||||
|
const nxLen = Math.hypot(normalRaw.x, normalRaw.y, normalRaw.z) || 1;
|
||||||
|
const normal = {
|
||||||
|
x: normalRaw.x / nxLen,
|
||||||
|
y: normalRaw.y / nxLen,
|
||||||
|
z: normalRaw.z / nxLen
|
||||||
|
};
|
||||||
|
const xAxisRaw = {
|
||||||
|
x: Number(frame?.x_axis?.x ?? 1),
|
||||||
|
y: Number(frame?.x_axis?.y ?? 0),
|
||||||
|
z: Number(frame?.x_axis?.z ?? 0)
|
||||||
|
};
|
||||||
|
// remove normal component
|
||||||
|
const xDotN = xAxisRaw.x * normal.x + xAxisRaw.y * normal.y + xAxisRaw.z * normal.z;
|
||||||
|
let xAxis = {
|
||||||
|
x: xAxisRaw.x - normal.x * xDotN,
|
||||||
|
y: xAxisRaw.y - normal.y * xDotN,
|
||||||
|
z: xAxisRaw.z - normal.z * xDotN
|
||||||
|
};
|
||||||
|
const xLen = Math.hypot(xAxis.x, xAxis.y, xAxis.z) || 1;
|
||||||
|
xAxis = { x: xAxis.x / xLen, y: xAxis.y / xLen, z: xAxis.z / xLen };
|
||||||
|
// y = n x x
|
||||||
|
const yAxis = {
|
||||||
|
x: normal.y * xAxis.z - normal.z * xAxis.y,
|
||||||
|
y: normal.z * xAxis.x - normal.x * xAxis.z,
|
||||||
|
z: normal.x * xAxis.y - normal.y * xAxis.x
|
||||||
|
};
|
||||||
|
return { origin, xAxis, yAxis, normal };
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformMeshToWorld(mesh, basis, zShift = 0) {
|
||||||
|
const numProp = Math.max(3, Number(mesh?.numProp || 3));
|
||||||
|
const verts = mesh?.vertProperties;
|
||||||
|
const triVerts = mesh?.triVerts;
|
||||||
|
if (!verts?.length || !triVerts?.length) return null;
|
||||||
|
const vertCount = Math.floor(verts.length / numProp);
|
||||||
|
const positions = new Float32Array(vertCount * 3);
|
||||||
|
const { origin, xAxis, yAxis, normal } = basis;
|
||||||
|
for (let i = 0; i < vertCount; i++) {
|
||||||
|
const o = i * numProp;
|
||||||
|
const lx = Number(verts[o] || 0);
|
||||||
|
const ly = Number(verts[o + 1] || 0);
|
||||||
|
const lz = Number(verts[o + 2] || 0) + (Number(zShift) || 0);
|
||||||
|
const wx = origin.x + xAxis.x * lx + yAxis.x * ly + normal.x * lz;
|
||||||
|
const wy = origin.y + xAxis.y * lx + yAxis.y * ly + normal.y * lz;
|
||||||
|
const wz = origin.z + xAxis.z * lx + yAxis.z * ly + normal.z * lz;
|
||||||
|
const p = i * 3;
|
||||||
|
positions[p] = wx;
|
||||||
|
positions[p + 1] = wy;
|
||||||
|
positions[p + 2] = wz;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
indices: Uint32Array.from(triVerts)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function polygonSignedArea(loop) {
|
||||||
|
if (!Array.isArray(loop) || loop.length < 3) return 0;
|
||||||
|
let area2 = 0;
|
||||||
|
for (let i = 0; i < loop.length; i++) {
|
||||||
|
const a = loop[i];
|
||||||
|
const b = loop[(i + 1) % loop.length];
|
||||||
|
area2 += (a.x || 0) * (b.y || 0) - (b.x || 0) * (a.y || 0);
|
||||||
|
}
|
||||||
|
return area2 * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLoopWinding(loop, ccw = true) {
|
||||||
|
if (!Array.isArray(loop) || loop.length < 3) return loop;
|
||||||
|
const isCCW = polygonSignedArea(loop) > 0;
|
||||||
|
if ((ccw && isCCW) || (!ccw && !isCCW)) return loop;
|
||||||
|
return loop.slice().reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toClipperPath(loop) {
|
||||||
|
if (!Array.isArray(loop) || loop.length < 3) return null;
|
||||||
|
const path = [];
|
||||||
|
for (const p of loop) {
|
||||||
|
path.push({
|
||||||
|
X: Math.round((p?.x || 0) * CLIPPER_SCALE),
|
||||||
|
Y: Math.round((p?.y || 0) * CLIPPER_SCALE)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return path.length >= 3 ? path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromClipperPath(path) {
|
||||||
|
if (!Array.isArray(path) || path.length < 3) return null;
|
||||||
|
return path.map(pt => ({
|
||||||
|
x: Number(pt?.X || 0) / CLIPPER_SCALE,
|
||||||
|
y: Number(pt?.Y || 0) / CLIPPER_SCALE
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unionSelectedRegions(profileLoopsList) {
|
||||||
|
if (!Array.isArray(profileLoopsList) || !profileLoopsList.length || !ClipperLib?.Clipper) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const subject = [];
|
||||||
|
for (const loops of profileLoopsList) {
|
||||||
|
if (!Array.isArray(loops)) continue;
|
||||||
|
for (const loop of loops) {
|
||||||
|
const path = toClipperPath(loop);
|
||||||
|
if (path) subject.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!subject.length) return [];
|
||||||
|
const clip = new ClipperLib.Clipper();
|
||||||
|
clip.AddPaths(subject, ClipperLib.PolyType.ptSubject, true);
|
||||||
|
const tree = new ClipperLib.PolyTree();
|
||||||
|
const ok = clip.Execute(
|
||||||
|
ClipperLib.ClipType.ctUnion,
|
||||||
|
tree,
|
||||||
|
ClipperLib.PolyFillType.pftEvenOdd,
|
||||||
|
ClipperLib.PolyFillType.pftEvenOdd
|
||||||
|
);
|
||||||
|
if (!ok) return [];
|
||||||
|
const exPolys = ClipperLib.JS?.PolyTreeToExPolygons
|
||||||
|
? ClipperLib.JS.PolyTreeToExPolygons(tree)
|
||||||
|
: [];
|
||||||
|
const out = [];
|
||||||
|
for (const ex of exPolys || []) {
|
||||||
|
const outer = fromClipperPath(ex?.outer);
|
||||||
|
if (!outer || outer.length < 3) continue;
|
||||||
|
const holes = [];
|
||||||
|
for (const hole of ex?.holes || []) {
|
||||||
|
const loop = fromClipperPath(hole);
|
||||||
|
if (loop && loop.length >= 3) holes.push(loop);
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
outer: ensureLoopWinding(outer, true),
|
||||||
|
holes: holes.map(loop => ensureLoopWinding(loop, false))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInLoop(point, loop) {
|
||||||
|
if (!point || !Array.isArray(loop) || loop.length < 3) return false;
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = loop.length - 1; i < loop.length; j = i++) {
|
||||||
|
const xi = Number(loop[i]?.x || 0);
|
||||||
|
const yi = Number(loop[i]?.y || 0);
|
||||||
|
const xj = Number(loop[j]?.x || 0);
|
||||||
|
const yj = Number(loop[j]?.y || 0);
|
||||||
|
const intersects = ((yi > point.y) !== (yj > point.y))
|
||||||
|
&& (point.x < ((xj - xi) * (point.y - yi)) / ((yj - yi) || 1e-12) + xi);
|
||||||
|
if (intersects) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointInRegion(point, region) {
|
||||||
|
if (!point || !region?.outer) return false;
|
||||||
|
if (!pointInLoop(point, region.outer)) return false;
|
||||||
|
const holes = Array.isArray(region.holes) ? region.holes : [];
|
||||||
|
for (const hole of holes) {
|
||||||
|
if (pointInLoop(point, hole)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loopCentroid(loop) {
|
||||||
|
if (!Array.isArray(loop) || loop.length < 3) return null;
|
||||||
|
let sx = 0;
|
||||||
|
let sy = 0;
|
||||||
|
let n = 0;
|
||||||
|
for (const p of loop) {
|
||||||
|
if (!p) continue;
|
||||||
|
sx += Number(p.x || 0);
|
||||||
|
sy += Number(p.y || 0);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
if (!n) return null;
|
||||||
|
return { x: sx / n, y: sy / n };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildGeneratedSolidsFromSnapshot(snapshot, options = {}) {
|
||||||
|
const builtFeatures = Array.isArray(snapshot?.builtFeatures) ? snapshot.builtFeatures : [];
|
||||||
|
const sketchPlanes = snapshot?.sketchPlanes || {};
|
||||||
|
const solids = [];
|
||||||
|
const meshCache = new Map();
|
||||||
|
let bodySeq = 0;
|
||||||
|
|
||||||
|
for (const feature of builtFeatures) {
|
||||||
|
if (feature?.type === 'extrude') {
|
||||||
|
const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : [];
|
||||||
|
if (!profiles.length) continue;
|
||||||
|
const params = feature?.params || {};
|
||||||
|
const depth = Math.max(0.0001, Math.abs(Number(params.depth ?? params.distance ?? 1)));
|
||||||
|
const symmetric = params.symmetric === true;
|
||||||
|
const direction = params.direction === 'reverse' ? 'reverse' : 'normal';
|
||||||
|
const operation = ['new', 'add', 'subtract'].includes(String(params.operation || 'new'))
|
||||||
|
? String(params.operation || 'new')
|
||||||
|
: 'new';
|
||||||
|
const localZShift = symmetric ? (-depth / 2) : (direction === 'reverse' ? -depth : 0);
|
||||||
|
const createdBodyIds = [];
|
||||||
|
const bySketch = new Map();
|
||||||
|
for (const profileTarget of profiles) {
|
||||||
|
const { sketchId, profileId } = resolveProfileTargetRef(profileTarget);
|
||||||
|
if (!sketchId || !profileId) continue;
|
||||||
|
const profileLoops = profileLoopsFromSnapshot(snapshot, profileTarget);
|
||||||
|
if (!profileLoops?.length) continue;
|
||||||
|
const basis = basisFromPlaneFrame(sketchPlanes?.[sketchId] || {});
|
||||||
|
if (!bySketch.has(sketchId)) {
|
||||||
|
bySketch.set(sketchId, { sketchId, basis, entries: [] });
|
||||||
|
}
|
||||||
|
bySketch.get(sketchId).entries.push({ profileTarget, profileLoops });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sketchPack of bySketch.values()) {
|
||||||
|
const resolvedRegions = unionSelectedRegions(sketchPack.entries.map(e => e.profileLoops));
|
||||||
|
for (const region of resolvedRegions) {
|
||||||
|
const polygons = [
|
||||||
|
region.outer,
|
||||||
|
...(region.holes || [])
|
||||||
|
].map(loop => loop.map(p => [p.x || 0, p.y || 0]));
|
||||||
|
if (!polygons.length) continue;
|
||||||
|
const bodyIndex = bodySeq++;
|
||||||
|
const id = makeBodyId(feature.id, bodyIndex);
|
||||||
|
let primaryTarget = null;
|
||||||
|
const contributingProfileKeys = [];
|
||||||
|
for (const entry of sketchPack.entries) {
|
||||||
|
const { key } = resolveProfileTargetRef(entry?.profileTarget || {});
|
||||||
|
if (!key) continue;
|
||||||
|
let contributes = false;
|
||||||
|
const loops = Array.isArray(entry?.profileLoops) ? entry.profileLoops : [];
|
||||||
|
for (const loop of loops) {
|
||||||
|
const sample = loopCentroid(loop);
|
||||||
|
if (sample && pointInRegion(sample, region)) {
|
||||||
|
contributes = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contributes) {
|
||||||
|
contributingProfileKeys.push(key);
|
||||||
|
if (!primaryTarget) primaryTarget = entry.profileTarget || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!primaryTarget) {
|
||||||
|
primaryTarget = sketchPack.entries[0]?.profileTarget || null;
|
||||||
|
}
|
||||||
|
const primaryRef = resolveProfileTargetRef(primaryTarget || {});
|
||||||
|
if (!contributingProfileKeys.length && primaryRef?.key) {
|
||||||
|
contributingProfileKeys.push(primaryRef.key);
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
id,
|
||||||
|
name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
profile: primaryTarget,
|
||||||
|
profile_keys: contributingProfileKeys
|
||||||
|
},
|
||||||
|
provenance: buildSeedProvenance(feature, primaryTarget, bodyIndex),
|
||||||
|
mesh: null,
|
||||||
|
status: 'pending_manifold'
|
||||||
|
};
|
||||||
|
const result = await extrudePolygons(polygons, depth);
|
||||||
|
if (result?.mesh) {
|
||||||
|
const meshWorld = transformMeshToWorld(result.mesh, sketchPack.basis, localZShift);
|
||||||
|
body.status = 'manifold_mesh_ready';
|
||||||
|
body.mesh = {
|
||||||
|
tri_count: (result.mesh?.triVerts?.length || 0) / 3,
|
||||||
|
vert_count: (result.mesh?.vertProperties?.length || 0) / Math.max(1, result.mesh?.numProp || 3)
|
||||||
|
};
|
||||||
|
body.extrude = { depth, direction, symmetric };
|
||||||
|
if (meshWorld) {
|
||||||
|
meshCache.set(id, meshWorld);
|
||||||
|
createdBodyIds.push(id);
|
||||||
|
}
|
||||||
|
result.manifold?.delete?.();
|
||||||
|
}
|
||||||
|
solids.push(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((operation === 'add' || operation === 'subtract') && createdBodyIds.length) {
|
||||||
|
const targetIds = Array.isArray(feature?.input?.targets)
|
||||||
|
? feature.input.targets.map(id => String(id || '')).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const createdSolids = createdBodyIds.map(id => solids.find(s => s?.id === id)).filter(Boolean);
|
||||||
|
const targetSolids = targetIds.map(id => solids.find(s => s?.id === id)).filter(Boolean);
|
||||||
|
const toolMeshes = createdSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
|
||||||
|
const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
|
||||||
|
let merge = null;
|
||||||
|
if (operation === 'add') {
|
||||||
|
const meshes = [...targetMeshes, ...toolMeshes];
|
||||||
|
if (meshes.length >= 2) merge = await booleanMeshes(meshes, 'add');
|
||||||
|
} else if (operation === 'subtract') {
|
||||||
|
if (targetMeshes.length && toolMeshes.length) {
|
||||||
|
merge = await booleanMeshes({ mode: 'subtract', targets: targetMeshes, tools: toolMeshes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (merge?.mesh?.positions?.length && merge?.mesh?.indices?.length) {
|
||||||
|
const consumed = new Set([...targetSolids.map(s => s.id), ...createdSolids.map(s => s.id)]);
|
||||||
|
const sketchIds = new Set();
|
||||||
|
for (const solid of [...targetSolids, ...createdSolids]) {
|
||||||
|
for (const sid of getSketchIdsForSolid(solid)) {
|
||||||
|
sketchIds.add(sid);
|
||||||
|
}
|
||||||
|
meshCache.delete(solid?.id);
|
||||||
|
}
|
||||||
|
const kept = solids.filter(s => !consumed.has(s?.id));
|
||||||
|
solids.length = 0;
|
||||||
|
solids.push(...kept);
|
||||||
|
const bodyIndex = bodySeq++;
|
||||||
|
const id = makeBodyId(feature.id, bodyIndex);
|
||||||
|
const body = {
|
||||||
|
id,
|
||||||
|
name: `${feature.name || 'Extrude'}-${bodyIndex + 1}`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
operation,
|
||||||
|
targets: targetIds,
|
||||||
|
tools: createdBodyIds,
|
||||||
|
sketch_ids: Array.from(sketchIds)
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
operation,
|
||||||
|
targets: targetIds,
|
||||||
|
tools: createdBodyIds
|
||||||
|
},
|
||||||
|
parents: [...targetIds, ...createdBodyIds]
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
tri_count: (merge.mesh?.indices?.length || 0) / 3,
|
||||||
|
vert_count: (merge.mesh?.positions?.length || 0) / 3
|
||||||
|
},
|
||||||
|
status: 'manifold_extrude_boolean_ready'
|
||||||
|
};
|
||||||
|
meshCache.set(id, merge.mesh);
|
||||||
|
solids.push(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature?.type === 'boolean') {
|
||||||
|
const mode = String(feature?.params?.mode || 'add');
|
||||||
|
const targets = Array.isArray(feature?.input?.targets)
|
||||||
|
? feature.input.targets.map(id => String(id || '')).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const tools = Array.isArray(feature?.input?.tools)
|
||||||
|
? feature.input.tools.map(id => String(id || '')).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const selectedIds = mode === 'subtract'
|
||||||
|
? Array.from(new Set([...targets, ...tools]))
|
||||||
|
: targets.slice();
|
||||||
|
if (!selectedIds.length) continue;
|
||||||
|
const selectedSet = new Set(selectedIds);
|
||||||
|
const targetSolids = targets.map(id => solids.find(s => s?.id === id)).filter(Boolean);
|
||||||
|
const toolSolids = tools.map(id => solids.find(s => s?.id === id)).filter(Boolean);
|
||||||
|
if (mode === 'subtract') {
|
||||||
|
if (!targetSolids.length || !toolSolids.length) continue;
|
||||||
|
} else if (targetSolids.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const targetMeshes = targetSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
|
||||||
|
const toolMeshes = toolSolids.map(s => meshCache.get(s.id)).filter(mesh => mesh?.positions?.length && mesh?.indices?.length);
|
||||||
|
if (mode === 'subtract') {
|
||||||
|
if (!targetMeshes.length || !toolMeshes.length) continue;
|
||||||
|
} else if (targetMeshes.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sketchIds = new Set();
|
||||||
|
for (const solid of [...targetSolids, ...toolSolids]) {
|
||||||
|
for (const sid of getSketchIdsForSolid(solid)) sketchIds.add(sid);
|
||||||
|
}
|
||||||
|
const result = mode === 'subtract'
|
||||||
|
? await booleanMeshes({ mode, targets: targetMeshes, tools: toolMeshes })
|
||||||
|
: await booleanMeshes(targetMeshes, mode);
|
||||||
|
const kept = solids.filter(s => !selectedSet.has(s?.id));
|
||||||
|
for (const target of [...targetSolids, ...toolSolids]) meshCache.delete(target?.id);
|
||||||
|
solids.length = 0;
|
||||||
|
solids.push(...kept);
|
||||||
|
if (result?.mesh?.positions?.length && result?.mesh?.indices?.length) {
|
||||||
|
const bodyIndex = bodySeq++;
|
||||||
|
const id = makeBodyId(feature.id, bodyIndex);
|
||||||
|
const body = {
|
||||||
|
id,
|
||||||
|
name: `${feature.name || 'Boolean'}-${bodyIndex + 1}`,
|
||||||
|
visible: feature.visible !== false,
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
targets,
|
||||||
|
tools,
|
||||||
|
solids: selectedIds,
|
||||||
|
mode,
|
||||||
|
sketch_ids: Array.from(sketchIds)
|
||||||
|
},
|
||||||
|
provenance: {
|
||||||
|
source: {
|
||||||
|
feature_id: feature.id,
|
||||||
|
feature_type: feature.type,
|
||||||
|
targets,
|
||||||
|
tools,
|
||||||
|
solids: selectedIds,
|
||||||
|
mode
|
||||||
|
},
|
||||||
|
parents: selectedIds
|
||||||
|
},
|
||||||
|
mesh: {
|
||||||
|
tri_count: (result.mesh?.indices?.length || 0) / 3,
|
||||||
|
vert_count: (result.mesh?.positions?.length || 0) / 3
|
||||||
|
},
|
||||||
|
status: 'manifold_boolean_ready'
|
||||||
|
};
|
||||||
|
meshCache.set(id, result.mesh);
|
||||||
|
solids.push(body);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (feature?.type === 'chamfer') {
|
||||||
|
const bodySeqRef = { value: bodySeq };
|
||||||
|
const changed = await applyChamferFeature(
|
||||||
|
solids,
|
||||||
|
meshCache,
|
||||||
|
feature,
|
||||||
|
makeBodyId,
|
||||||
|
bodySeqRef
|
||||||
|
);
|
||||||
|
bodySeq = bodySeqRef.value;
|
||||||
|
if (changed) {
|
||||||
|
// keep processing downstream features against updated solids
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { solids, meshCache };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildGeneratedSolids(api, options = {}) {
|
||||||
|
const doc = api.document.current;
|
||||||
|
if (!doc) return { solids: [], meshCache: new Map() };
|
||||||
|
const builtFeatures = api.features.listBuilt();
|
||||||
|
const sketchPlanes = {};
|
||||||
|
for (const feature of (api.features.list() || [])) {
|
||||||
|
if (feature?.type === 'sketch' && feature?.id) {
|
||||||
|
sketchPlanes[feature.id] = feature.plane || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const profileLoops = {};
|
||||||
|
for (const feature of builtFeatures) {
|
||||||
|
if (feature?.type !== 'extrude') continue;
|
||||||
|
const profiles = Array.isArray(feature?.input?.profiles) ? feature.input.profiles : [];
|
||||||
|
for (const profileTarget of profiles) {
|
||||||
|
const { sketchId, profileId, key } = resolveProfileTargetRef(profileTarget);
|
||||||
|
if (!sketchId || !profileId) continue;
|
||||||
|
const loops = profileLoopsFromRuntime(api, profileTarget);
|
||||||
|
if (!loops?.length) continue;
|
||||||
|
profileLoops[key] = loops;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { solids, meshCache } = await rebuildGeneratedSolidsFromSnapshot({
|
||||||
|
builtFeatures,
|
||||||
|
sketchPlanes,
|
||||||
|
profileLoops
|
||||||
|
}, options);
|
||||||
|
|
||||||
|
doc.generated = doc.generated || {};
|
||||||
|
doc.generated.solids = solids;
|
||||||
|
if (options.persist !== false) {
|
||||||
|
await api.document.save({
|
||||||
|
kind: 'micro',
|
||||||
|
opType: 'solid.rebuild',
|
||||||
|
undoable: false,
|
||||||
|
clearRedo: false,
|
||||||
|
payload: {
|
||||||
|
reason: options.reason || 'rebuild',
|
||||||
|
solids: solids.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { solids, meshCache };
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
rebuildGeneratedSolids,
|
||||||
|
rebuildGeneratedSolidsFromSnapshot
|
||||||
|
};
|
||||||
1451
src/void/toolbar.js
Normal file
1451
src/void/toolbar.js
Normal file
File diff suppressed because it is too large
Load diff
82
src/void/tree.js
Normal file
82
src/void/tree.js
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
836
src/void/tree/model.js
Normal file
836
src/void/tree/model.js
Normal file
|
|
@ -0,0 +1,836 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
322
src/void/tree/render.js
Normal file
322
src/void/tree/render.js
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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
|
||||||
|
};
|
||||||
315
src/void/viewcube.js
Normal file
315
src/void/viewcube.js
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- 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 };
|
||||||
59
src/void/worker/solids_worker.js
Normal file
59
src/void/worker/solids_worker.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
|
||||||
|
|
||||||
|
import { ensureKernel } from '../solid/kernel.js';
|
||||||
|
import { rebuildGeneratedSolidsFromSnapshot } from '../solid/rebuild.js';
|
||||||
|
|
||||||
|
let kernelReady = false;
|
||||||
|
|
||||||
|
async function ensureReady() {
|
||||||
|
if (kernelReady) return;
|
||||||
|
await ensureKernel();
|
||||||
|
kernelReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeMeshCache(meshCache) {
|
||||||
|
const meshes = [];
|
||||||
|
const transfer = [];
|
||||||
|
for (const [id, mesh] of meshCache.entries()) {
|
||||||
|
if (!id || !mesh?.positions?.length || !mesh?.indices?.length) continue;
|
||||||
|
const positions = mesh.positions instanceof Float32Array
|
||||||
|
? mesh.positions
|
||||||
|
: new Float32Array(mesh.positions);
|
||||||
|
const indices = mesh.indices instanceof Uint32Array
|
||||||
|
? mesh.indices
|
||||||
|
: new Uint32Array(mesh.indices);
|
||||||
|
meshes.push({ id, positions, indices });
|
||||||
|
transfer.push(positions.buffer, indices.buffer);
|
||||||
|
}
|
||||||
|
return { meshes, transfer };
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const msg = event?.data || {};
|
||||||
|
const id = msg?.id ?? null;
|
||||||
|
const type = msg?.type || '';
|
||||||
|
if (type !== 'rebuild') {
|
||||||
|
self.postMessage({ id, ok: false, error: `unknown message type: ${type}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ensureReady();
|
||||||
|
const result = await rebuildGeneratedSolidsFromSnapshot(msg.snapshot || {}, {
|
||||||
|
reason: msg.reason || 'worker'
|
||||||
|
});
|
||||||
|
const { meshes, transfer } = serializeMeshCache(result?.meshCache || new Map());
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
ok: true,
|
||||||
|
solids: result?.solids || [],
|
||||||
|
meshes
|
||||||
|
}, transfer);
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
ok: false,
|
||||||
|
error: error?.message || String(error || 'unknown worker error')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ self.addEventListener('message', e => {
|
||||||
async function _install(e) {
|
async function _install(e) {
|
||||||
log('install');
|
log('install');
|
||||||
if (e.addRoutes) {
|
if (e.addRoutes) {
|
||||||
for (let pre of ["font","mesh","kiri","lib","wasm"]) {
|
for (let pre of ["font","mesh","kiri","void","lib","wasm"]) {
|
||||||
e.addRoutes({
|
e.addRoutes({
|
||||||
condition: { urlPattern: new URLPattern({ pathname: `/${pre}/.*` }) },
|
condition: { urlPattern: new URLPattern({ pathname: `/${pre}/.*` }) },
|
||||||
source: { cacheName: CACHE_VERSION }
|
source: { cacheName: CACHE_VERSION }
|
||||||
|
|
|
||||||
BIN
web/icon/kirimoto.png
Normal file
BIN
web/icon/kirimoto.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
web/icon/kirimoto2.png
Normal file
BIN
web/icon/kirimoto2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
web/icon/meshtool.png
Normal file
BIN
web/icon/meshtool.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 278 KiB |
BIN
web/icon/voidform.png
Normal file
BIN
web/icon/voidform.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
|
|
@ -17,8 +17,8 @@
|
||||||
<meta http-equiv="origin-trial" content="AvttY0bfMDdg4vBwjn5k4Yv/+OmqjNj4bTRvCgBpP7hkI6base2DxEViSebcOyglERiFV7g0DaVmI+yv79ftDw8AAAB0eyJvcmlnaW4iOiJodHRwczovL2dyaWQuc3BhY2U6NDQzIiwiZmVhdHVyZSI6IlVucmVzdHJpY3RlZFNoYXJlZEFycmF5QnVmZmVyIiwiZXhwaXJ5IjoxNzc5MTQ4ODAwLCJpc1N1YmRvbWFpbiI6dHJ1ZX0=">
|
<meta http-equiv="origin-trial" content="AvttY0bfMDdg4vBwjn5k4Yv/+OmqjNj4bTRvCgBpP7hkI6base2DxEViSebcOyglERiFV7g0DaVmI+yv79ftDw8AAAB0eyJvcmlnaW4iOiJodHRwczovL2dyaWQuc3BhY2U6NDQzIiwiZmVhdHVyZSI6IlVucmVzdHJpY3RlZFNoYXJlZEFycmF5QnVmZmVyIiwiZXhwaXJ5IjoxNzc5MTQ4ODAwLCJpc1N1YmRvbWFpbiI6dHJ1ZX0=">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>Kiri:Moto</title>
|
<title>Kiri:Moto</title>
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="/icon/kirimoto.png">
|
||||||
<link rel="apple-touch-icon" href="favicon-mobile.png">
|
<link rel="apple-touch-icon" href="/icon/kirimoto.png">
|
||||||
<link rel="stylesheet" type="text/css" href="index.css">
|
<link rel="stylesheet" type="text/css" href="index.css">
|
||||||
<link href="manifest.json" rel="manifest">
|
<link href="manifest.json" rel="manifest">
|
||||||
<link href="../font/css/all.min.css" rel="stylesheet">
|
<link href="../font/css/all.min.css" rel="stylesheet">
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"orientation": "landscape-primary",
|
"orientation": "landscape-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "./logo-cube-512.png", "type": "image/png", "sizes": "512x512" },
|
{ "src": "/icon/kirimoto.png", "type": "image/png", "sizes": "512x512" }
|
||||||
{ "src": "./logo-cube-144.png", "type": "image/png", "sizes": "144x144" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://grid.space/mesh/">
|
<meta property="og:url" content="https://grid.space/mesh/">
|
||||||
<title>Mesh:Tool</title>
|
<title>Mesh:Tool</title>
|
||||||
<link rel="icon" href="favicon.ico">
|
<link rel="icon" href="/icon/meshtool.png">
|
||||||
<link rel="stylesheet" type="text/css" href="index.css">
|
<link rel="stylesheet" type="text/css" href="index.css">
|
||||||
<link href="../font/css/all.min.css" rel="stylesheet">
|
<link href="../font/css/all.min.css" rel="stylesheet">
|
||||||
<link href="../fon2/bootstrap-icons.min.css" rel="stylesheet">
|
<link href="../fon2/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
|
|
|
||||||
29
web/void/index.html
Normal file
29
web/void/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Void:Form</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="icon" type="image/png" href="/icon/voidform.png">
|
||||||
|
<script src="../lib/ext/tween.js"></script>
|
||||||
|
<script src="../lib/main/void.js" type="module"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="curtain">
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<div class="text">Loading Void:Form...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="top-bar"></div>
|
||||||
|
<div id="content">
|
||||||
|
<div id="left-panel"></div>
|
||||||
|
<div id="container">
|
||||||
|
<div id="sketch-overlay" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1235
web/void/style.css
Normal file
1235
web/void/style.css
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue