merge experimental void app

This commit is contained in:
Stewart Allen 2026-02-14 19:00:48 -05:00
commit 02fb3cd30d
80 changed files with 30437 additions and 813 deletions

View file

@ -1,4 +1,4 @@
name: 'Docs Fomatting Check'
name: 'Docs Formatting Check'
on:
push:
paths:
@ -12,12 +12,19 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup Node
uses: actions/setup-node@v4
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: check format for docs
run:
bun docs-check
node-version: '20'
- name: Check docs formatting (retry once on transient network failure)
shell: bash
run: |
for attempt in 1 2; do
npx --yes prettier@3.5.3 --config ./conf/prettier.config.js ./docs --check && exit 0
if [ "$attempt" -lt 2 ]; then
echo "Prettier check failed (attempt $attempt). Retrying..."
sleep 5
fi
done
echo "Prettier check failed after retries."
exit 1

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ package-lock.json
src/ext/jszip-esm.js
src/ext/quickjs.js
src/ext/three.js
src/void/solver
tmp
tmp_*/
web/boot/bundle*

30
app.js
View file

@ -134,10 +134,12 @@ function init(mod) {
"/boot" : redir((pre??"") + "/boot/", 301),
"/kiri" : redir((pre??"") + "/kiri/", 301),
"/mesh" : redir((pre??"") + "/mesh/", 301),
"/meta" : redir((pre??"") + "/meta/", 301),
"/void" : redir((pre??"") + "/void/", 301),
"/form" : redir((pre??"") + "/form/", 301),
"/kiri/index.html" : redir((pre??"") + "/kiri/", 301),
"/mesh/index.html" : redir((pre??"") + "/mesh/", 301),
"/meta/index.html" : redir((pre??"") + "/meta/", 301)
"/void/index.html" : redir((pre??"") + "/void/", 301),
"/form/index.html" : redir((pre??"") + "/form/", 301)
}));
mod.add(handleVersion);
mod.add(fixedmap("/api/", api));
@ -153,12 +155,15 @@ function init(mod) {
mod.static("/lib/", "alt");
mod.static("/lib/", "src");
mod.static("/obj/", "web/obj");
mod.static("/font/", "web/font");
mod.static("/boot/", "web/boot");
mod.static("/fon2/", "web/fon2");
mod.static("/font/", "web/font");
mod.static("/form/", "web/void");
mod.static("/icon/", "web/icon");
mod.static("/kiri/", "web/kiri");
mod.static("/mesh/", "web/mesh");
mod.static("/moto/", "web/moto");
mod.static("/kiri/", "web/kiri");
mod.static("/boot/", "web/boot");
mod.static("/void/", "web/void");
// module loader
function load_modules(root, force) {
@ -186,10 +191,10 @@ function init(mod) {
});
}
// load development and 3rd party modules
// load development and app modules (onshape, thingiverse)
load_modules('mod');
// load optional local modules
// load optional local modules (bambu)
load_modules('mods');
// run load functions injected by modules
@ -329,8 +334,10 @@ function initModule(mod, file, dir) {
const path = mod.dir + '/' + dir + '/' + file;
try {
const body = fs.readFileSync(path);
if (debug && !single) logger.log({ inject: code, file, opt });
if (opt.first) {
if (debug && !single) {
logger.log({ inject: code, file, opt });
}
if (opt.first && append[code]) {
append[code] = body.toString() + '\n' + append[code];
} else {
append[code] += body.toString() + '\n';
@ -369,8 +376,11 @@ function handleSetup(req, res, next) {
}
const productionMap = {
'/lib/mesh/work.js' : '/lib/pack/mesh-work.js',
'/lib/main/void.js' : '/lib/pack/void-main.js',
'/lib/main/planegcs.wasm' : '/lib/void/solver/planegcs_dist/planegcs.wasm',
'/lib/worker/solids_worker.js' : '/lib/pack/void-work-solid.js',
'/lib/main/mesh.js' : '/lib/pack/mesh-main.js',
'/lib/mesh/work.js' : '/lib/pack/mesh-work.js',
'/lib/main/kiri.js' : '/lib/pack/kiri-main.js',
'/lib/kiri/run/engine.js' : '/lib/pack/kiri-eng.js',
'/lib/kiri/run/minion.js' : '/lib/pack/kiri-pool.js',

View file

@ -16,7 +16,8 @@
{ "src": "src/pack/kiri-pool.js", "dst": "lib/kiri/run/minion.js" },
{ "src": "src/gpu/raster-worker.js", "dst": "lib/gpu/raster-worker.js" },
{ "src": "src/ext/tween.js", "dst": "lib/ext/tween.js" },
{ "src": "src/moto/license.js", "dst": "lib/moto/license.js" }
{ "src": "src/moto/license.js", "dst": "lib/moto/license.js" },
{ "src": "src/void/solver/planegcs_dist/planegcs.wasm", "dst": "lib/main/planegcs.wasm" }
],
"excludes": [
"src/pack",

View file

@ -9,6 +9,9 @@ const isProd = mode === 'prod';
console.log(`Building in ${mode} mode...`);
const VOID_OUTFILE = 'src/pack/void-main.js';
const VOID_EXTRAS = [ ];
const MESH_OUTFILE = 'src/pack/mesh-main.js';
const MESH_EXTRAS = [ ];
@ -100,6 +103,20 @@ async function buildApp() {
// Concatenate kiri devices
generateDevices();
// Bundle void main app
await build(Object.assign({}, rec, {
entryPoints: [ 'src/main/void.js' ],
outfile: VOID_OUTFILE,
}));
appendExtraModules(VOID_EXTRAS, VOID_OUTFILE, isProd);
// Bundle void worker
await build(Object.assign({}, rec, {
entryPoints: [ 'src/void/worker/solids_worker.js' ],
outfile: 'src/pack/void-work-solid.js',
}));
// Bundle mesh main app
await build(Object.assign({}, rec, {
entryPoints: [ 'src/main/mesh.js' ],

View file

@ -7,6 +7,7 @@ import { LineMaterial } from '../node_modules/three/examples/jsm/lines/LineMater
import { LineGeometry } from '../node_modules/three/examples/jsm/lines/LineGeometry.js';
import { LineSegments2 } from '../node_modules/three/examples/jsm/lines/LineSegments2.js';
import { LineSegmentsGeometry } from '../node_modules/three/examples/jsm/lines/LineSegmentsGeometry.js';
import { TrackballControls } from '../node_modules/three/examples/jsm/controls/TrackballControls.js';
import * as MeshBVHLib from '../node_modules/three-mesh-bvh/build/index.module.js';
@ -19,5 +20,6 @@ export {
LineGeometry,
LineSegments2,
LineSegmentsGeometry,
TrackballControls,
MeshBVHLib
};
};

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View file

@ -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/

View file

@ -41,6 +41,7 @@
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
"@gridspace/net-level-client": "^0.2.3",
"@gridspace/raster-path": "^1.1.1",
"@salusoft89/planegcs": "^1.1.7",
"@tracespace/parser": "^5.0.0-next.0",
"@tweenjs/tween.js": "^16.6.0",
"aedes": "^0.51.3",
@ -103,6 +104,7 @@
"clean": "rm -rf alt build data dist src/pack src.old tmp web.old .bcache",
"dev": "npm run pack-dev && gs-app-server --debug --single",
"docs-build": "docusaurus build --config conf/docusaurus.config.js",
"docs-clean": "prettier --config ./conf/prettier.config.js ./docs --write",
"docs-check": "prettier --config ./conf/prettier.config.js ./docs --check",
"docs-dev": "docusaurus start --config conf/docusaurus.config.js",
"docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004",
@ -121,7 +123,8 @@
"start-ddb": "npm run prebuild && electron . --devel --debugg",
"start-dev": "npm run prebuild prod && electron . --devel",
"start": "npm run prebuild && electron .",
"webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs",
"webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs && npm run webpack-pgcs",
"webpack-pgcs": "mkdir -p src/void/solver/planegcs_dist src/void/solver/sketch && cp node_modules/@salusoft89/planegcs/dist/index.js src/void/solver/planegcs.js && cp -R node_modules/@salusoft89/planegcs/dist/planegcs_dist/. src/void/solver/planegcs_dist/ && cp -R node_modules/@salusoft89/planegcs/dist/sketch/. src/void/solver/sketch/",
"webpack-qjs": "npx webpack --config bin/webpack-quickjs-esm.js",
"webpack-src": "node bin/esbuild.config.mjs",
"webpack-three": "npx webpack --config bin/webpack-three-esm.js",

View file

@ -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

View file

@ -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`);
});
}
});
};

View file

@ -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});
});
});

View file

@ -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 ---
```

View file

@ -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;
// };
});

View file

@ -13,14 +13,26 @@ const { Vector3, computeFaceNormal } = THREE;
* @returns {Uint8Array} binary STL data
*/
export function encode(recs, header = '') {
// Calculate total vertices
let vtot = 0;
for (let rec of recs) {
vtot += (rec.varr.length / 3);
// Calculate total triangles with strict validation.
let triCount = 0;
for (let rec of recs || []) {
const varr = rec?.varr;
const len = Number(varr?.length || 0);
if (!Number.isFinite(len) || len <= 0) continue;
if (len % 9 !== 0) {
// Ignore malformed record instead of corrupting output sizing.
continue;
}
triCount += Math.floor(len / 9);
}
const byteLen = 84 + triCount * 50;
if (!Number.isFinite(byteLen) || byteLen <= 84 || byteLen > 0x7fffffff) {
throw new RangeError(`invalid STL byte length: ${byteLen}`);
}
// Create STL buffer: 80 byte header + 4 byte count + (50 bytes per triangle)
let stl = new Uint8Array(80 + 4 + vtot/3 * 50);
let stl = new Uint8Array(byteLen);
let dat = new DataView(stl.buffer);
let pos = 84;
@ -32,11 +44,12 @@ export function encode(recs, header = '') {
}
// Write triangle count at byte 80
dat.setInt32(80, vtot/3, true);
dat.setUint32(80, triCount, true);
// Write triangles
for (let rec of recs) {
let { varr } = rec;
if (!varr || (varr.length % 9) !== 0) continue;
for (let i = 0, l = varr.length; i < l;) {
// Read three vertices
let p0 = new Vector3(varr[i++], varr[i++], varr[i++]);
@ -72,6 +85,97 @@ export function encode(recs, header = '') {
return stl;
}
/**
* Encode STL as chunked Blob to avoid large contiguous TypedArray allocation.
* Useful when JS heap is fragmented or under pressure.
* @param {Array} recs
* @param {String} header
* @param {Number} trisPerChunk
* @returns {Blob}
*/
export function encodeBlob(recs, header = '', trisPerChunk = 4096) {
let triCount = 0;
for (let rec of recs || []) {
const len = Number(rec?.varr?.length || 0);
if (!Number.isFinite(len) || len <= 0 || (len % 9) !== 0) continue;
triCount += Math.floor(len / 9);
}
const parts = [];
const head = new Uint8Array(84);
const headView = new DataView(head.buffer);
if (header) {
header.substring(0, 80).split('').forEach((c, i) => {
headView.setUint8(i, c.charCodeAt(0));
});
}
headView.setUint32(80, triCount, true);
parts.push(head);
const maxTris = Math.max(1, Math.floor(Number(trisPerChunk) || 4096));
const p0 = new Vector3();
const p1 = new Vector3();
const p2 = new Vector3();
for (let rec of recs || []) {
const varr = rec?.varr;
if (!varr || (varr.length % 9) !== 0) continue;
let i = 0;
while (i < varr.length) {
const tris = Math.min(maxTris, Math.floor((varr.length - i) / 9));
const buf = new Uint8Array(tris * 50);
const dat = new DataView(buf.buffer);
let pos = 0;
for (let t = 0; t < tris; t++) {
p0.set(varr[i++], varr[i++], varr[i++]);
p1.set(varr[i++], varr[i++], varr[i++]);
p2.set(varr[i++], varr[i++], varr[i++]);
const norm = computeFaceNormal(p0, p1, p2);
dat.setFloat32(pos + 0, norm.x, true);
dat.setFloat32(pos + 4, norm.y, true);
dat.setFloat32(pos + 8, norm.z, true);
dat.setFloat32(pos + 12, p0.x, true);
dat.setFloat32(pos + 16, p0.y, true);
dat.setFloat32(pos + 20, p0.z, true);
dat.setFloat32(pos + 24, p1.x, true);
dat.setFloat32(pos + 28, p1.y, true);
dat.setFloat32(pos + 32, p1.z, true);
dat.setFloat32(pos + 36, p2.x, true);
dat.setFloat32(pos + 40, p2.y, true);
dat.setFloat32(pos + 44, p2.z, true);
dat.setUint16(pos + 48, 0, true);
pos += 50;
}
parts.push(buf);
}
}
return new Blob(parts, { type: 'application/sla' });
}
export function encodeASCII(recs, name = 'solid') {
const out = [`solid ${String(name || 'solid').replace(/\s+/g, '_')}`];
const p0 = new Vector3();
const p1 = new Vector3();
const p2 = new Vector3();
for (const rec of recs || []) {
const varr = rec?.varr;
if (!varr || (varr.length % 9) !== 0) continue;
for (let i = 0; i < varr.length;) {
p0.set(varr[i++], varr[i++], varr[i++]);
p1.set(varr[i++], varr[i++], varr[i++]);
p2.set(varr[i++], varr[i++], varr[i++]);
const n = computeFaceNormal(p0, p1, p2);
out.push(`facet normal ${n.x} ${n.y} ${n.z}`);
out.push(' outer loop');
out.push(` vertex ${p0.x} ${p0.y} ${p0.z}`);
out.push(` vertex ${p1.x} ${p1.y} ${p1.z}`);
out.push(` vertex ${p2.x} ${p2.y} ${p2.z}`);
out.push(' endloop');
out.push('endfacet');
}
}
out.push(`endsolid ${String(name || 'solid').replace(/\s+/g, '_')}`);
return out.join('\n');
}
export class STL {
constructor() {
this.vertices = null;

331
src/main/void.js Normal file
View 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();
}

View file

@ -1,4 +1,4 @@
const root = await navigator.storage.getDirectory();
const root = await navigator.storage?.getDirectory();
function resolvePath(path) {
if (!path) {

View file

@ -85,6 +85,13 @@ class Orbit extends EventDispatcher {
PAN: MOUSE.MIDDLE
};
// Onshape
this.mouseVoid = {
ORBIT: MOUSE.RIGHT,
// ZOOM: MOUSE.LEFT,
PAN: MOUSE.MIDDLE
};
this.mouseButtons = this.mouseDefault;
this.setMouse = function(bindings) {
@ -116,6 +123,7 @@ class Orbit extends EventDispatcher {
pan = new Vector3(),
lastPosition = new Vector3(),
lastQuaternion = new Quaternion(),
lastZoom = object.zoom !== undefined ? object.zoom : 1,
// so camera.up is the orbit axis
quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0)),
quatInverse = quat.clone().invert(),
@ -325,11 +333,13 @@ class Orbit extends EventDispatcher {
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (lastPosition.distanceToSquared(this.object.position) > EPS
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS) {
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS
|| Math.abs(lastZoom - this.object.zoom) > EPS) {
this.dispatchEvent(changeEvent);
lastPosition.copy(this.object.position);
lastQuaternion.copy(this.object.quaternion);
lastZoom = this.object.zoom;
if (notify) notify(position, true);
} else {
if (notify) notify(position, false);
@ -681,7 +691,23 @@ class Orbit extends EventDispatcher {
this.onMouseUp = onMouseUp;
domEl.addEventListener('contextmenu', function (event) { event.preventDefault() }, false);
this.dispose = function() {
domEl.removeEventListener('contextmenu', onContextMenu, false);
domEl.removeEventListener('mousedown', onMouseDown, false);
domEl.removeEventListener('wheel', onMouseWheel, false);
domEl.removeEventListener('mousewheel', onMouseWheel, false);
domEl.removeEventListener('DOMMouseScroll', onMouseWheel, false);
domEl.removeEventListener('touchstart', touchstart, false);
domEl.removeEventListener('touchend', touchend, false);
domEl.removeEventListener('touchmove', touchmove, false);
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
window.removeEventListener('keydown', onKeyDown, false);
};
function onContextMenu(event) { event.preventDefault() }
domEl.addEventListener('contextmenu', onContextMenu, false);
domEl.addEventListener('mousedown', onMouseDown, false);
domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox)
domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari

File diff suppressed because it is too large Load diff

377
src/moto/trackball.js Normal file
View 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
View 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
View file

@ -0,0 +1,616 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { space } from '../../moto/space.js';
function createDocumentApi(getApi, cfg) {
const {
DOC_SCHEMA_VERSION,
ADMIN_CURRENT_DOC_KEY,
ADMIN_CURRENT_REV_KEY,
UNDOABLE_OP_TYPES,
idFactory,
revString
} = cfg;
return {
current: null,
isHydrating: false,
_runtimeSaveTimer: null,
_runtimeSavePending: null,
_datumHandlers: new Map(),
_datumRootHandler: null,
_originHandler: null,
_runtimeFlushBound: false,
_redoStack: [],
_atomicEdit: null,
create() {
const api = getApi();
const doc = {
id: idFactory(),
schema_version: DOC_SCHEMA_VERSION,
name: 'Untitled',
created_at: Date.now(),
modified_at: Date.now(),
version: { major: 0, micro: 0 },
head_rev: null,
features: [],
tree: {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
},
timeline: {
index: null
},
generated: {
solids: []
},
geometry_store: api.geometryStore?.defaultState?.() || null,
scene: {
datum: api.datum.defaultState(),
origin: api.origin.defaultState()
}
};
this.current = doc;
this._redoStack = [];
return doc;
},
normalizeName(name) {
const clean = String(name || '').trim();
return clean || 'Untitled';
},
migrate(doc) {
const api = getApi();
if (!doc) return doc;
let changed = false;
if (doc.schema_version === undefined) {
doc.schema_version = DOC_SCHEMA_VERSION;
changed = true;
}
if (!doc.created_at && doc.created) {
doc.created_at = doc.created;
changed = true;
}
if (!doc.modified_at && doc.modified) {
doc.modified_at = doc.modified;
changed = true;
}
if (!doc.version) {
doc.version = { major: 0, micro: 0 };
changed = true;
}
if (doc.head_rev === undefined) {
doc.head_rev = null;
changed = true;
}
if (!Array.isArray(doc.features)) {
doc.features = [];
changed = true;
}
if (!doc.tree || !Array.isArray(doc.tree.folders)) {
doc.tree = {
folders: [
{ id: 'features', name: 'Features', collapsed: false }
]
};
changed = true;
}
if (!doc.timeline || typeof doc.timeline !== 'object') {
doc.timeline = { index: null };
changed = true;
}
if (doc.timeline.index !== null && !Number.isFinite(doc.timeline.index)) {
doc.timeline.index = null;
changed = true;
}
if (!doc.generated || typeof doc.generated !== 'object') {
doc.generated = { solids: [] };
changed = true;
}
if (!Array.isArray(doc.generated.solids)) {
doc.generated.solids = [];
changed = true;
}
if (!doc.geometry_store) {
doc.geometry_store = api.geometryStore?.defaultState?.() || null;
changed = true;
}
if (api.geometryStore?.normalize) {
const normalized = api.geometryStore.normalize(doc.geometry_store);
if (JSON.stringify(normalized) !== JSON.stringify(doc.geometry_store)) {
doc.geometry_store = normalized;
changed = true;
}
}
if (!doc.scene) {
doc.scene = {};
changed = true;
}
if (!doc.scene.datum) {
doc.scene.datum = api.datum.defaultState();
changed = true;
}
if (!doc.scene.origin) {
doc.scene.origin = api.origin.defaultState();
changed = true;
}
return { doc, changed };
},
nextRevision(kind = 'micro') {
const current = this.current?.version || { major: 0, micro: 0 };
if (kind === 'major') {
return { major: current.major + 1, micro: 0 };
}
return { major: current.major, micro: current.micro + 1 };
},
revisionKey(docId, rev) {
return `${docId}:${revString(rev)}`;
},
toSnapshot() {
if (!this.current) return null;
this.current.scene = this.captureRuntimeState();
return JSON.parse(JSON.stringify(this.current));
},
captureRuntimeState() {
const api = getApi();
return {
datum: api.datum.toJSON(),
origin: api.origin.toJSON()
};
},
hydrateRuntimeState(doc) {
const api = getApi();
if (!doc) return;
api.geometryStore?.hydrate?.(doc);
const scene = doc.scene || {};
const datumState = scene.datum || api.datum.defaultState();
const originState = scene.origin || api.origin.defaultState();
this.isHydrating = true;
try {
api.origin.applyJSON(originState, false);
api.datum.applyJSON(datumState);
} finally {
this.isHydrating = false;
}
api.datum.updateLabels(api.overlay);
api.sketchRuntime?.sync();
space.update();
},
bindRuntimeObservers() {
const api = getApi();
if (!api.datum || !api.datum.getPlanes) return;
for (const [id, rec] of this._datumHandlers) {
rec.plane.offChange(rec.handler);
}
this._datumHandlers.clear();
if (this._datumRootHandler) {
api.datum.offChange(this._datumRootHandler);
this._datumRootHandler = null;
}
if (this._originHandler) {
api.origin.offChange(this._originHandler);
this._originHandler = null;
}
for (const plane of api.datum.getPlanes()) {
const handler = () => {
this.scheduleRuntimeSave('datum.update', { plane_id: plane.id });
};
this._datumHandlers.set(plane.id, { plane, handler });
plane.onChange(handler);
}
this._datumRootHandler = () => {
this.scheduleRuntimeSave('datum.root.update', { scope: 'datum' });
};
api.datum.onChange(this._datumRootHandler);
this._originHandler = () => {
this.scheduleRuntimeSave('origin.update', { scope: 'origin' });
};
api.origin.onChange(this._originHandler);
if (!this._runtimeFlushBound) {
this._runtimeFlushBound = true;
const flush = () => this.flushRuntimeSave();
window.addEventListener('pagehide', flush);
window.addEventListener('beforeunload', flush);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
flush();
}
});
}
},
scheduleRuntimeSave(opType = 'runtime.update', payload = null) {
if (this.isHydrating || !this.current) {
return;
}
this._runtimeSavePending = { opType, payload };
clearTimeout(this._runtimeSaveTimer);
this._runtimeSaveTimer = setTimeout(() => {
this.flushRuntimeSave();
}, 200);
},
flushRuntimeSave() {
if (this.isHydrating || !this.current || !this._runtimeSavePending) {
return Promise.resolve();
}
clearTimeout(this._runtimeSaveTimer);
const pending = this._runtimeSavePending;
this._runtimeSavePending = null;
return this.save({
kind: 'micro',
opType: pending.opType,
payload: pending.payload
});
},
load(id) {
const api = getApi();
return api.db.documents.get(id).then(doc => {
if (doc) {
const migrated = this.migrate(doc);
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
this.current.name = this.normalizeName(this.current.name);
this._redoStack = [];
this.hydrateRuntimeState(this.current);
if (migrated.changed) {
return api.db.documents.put(this.current.id, this.current).then(() => this.current);
}
return this.current;
}
return null;
});
},
save(options = {}) {
const api = getApi();
if (this.current) {
const now = Date.now();
const kind = options.kind || 'micro';
const opType = options.opType || (kind === 'major' ? 'snapshot' : 'delta');
const undoable = options.undoable !== undefined ? !!options.undoable :
(kind === 'major' || UNDOABLE_OP_TYPES.has(opType));
const next = this.nextRevision(kind);
const revId = this.revisionKey(this.current.id, next);
this.current.modified_at = now;
this.current.name = this.normalizeName(this.current.name);
if (!undoable) {
const currentRev = this.current.head_rev || null;
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, currentRev)
]);
}
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: this.current.head_rev || null,
schema_version: DOC_SCHEMA_VERSION,
op_type: opType,
payload: options.payload || null,
snapshot: this.toSnapshot(),
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = revision.snapshot.scene;
if (undoable && options.clearRedo !== false) {
this._redoStack = [];
}
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]);
}
return Promise.resolve();
},
createAndSelect() {
this.create();
this.hydrateRuntimeState(this.current);
return this.save({
kind: 'major',
opType: 'snapshot',
payload: { reason: 'seed' }
}).then(() => this.current);
},
list() {
const api = getApi();
return api.db.documents.iterate().then(entries => {
return entries
.map(({ value }) => value)
.filter(Boolean)
.map(doc => {
const migrated = this.migrate(doc);
migrated.doc.name = this.normalizeName(migrated.doc.name);
return migrated.doc;
})
.sort((a, b) => (b.modified_at || 0) - (a.modified_at || 0));
});
},
select(id) {
const api = getApi();
return this.load(id).then(doc => {
if (!doc) {
return null;
}
this._redoStack = [];
return Promise.all([
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
});
},
open(id) {
return this.select(id);
},
rename(name) {
if (!this.current) return Promise.resolve(null);
const nextName = this.normalizeName(name);
if (nextName === this.current.name) {
return Promise.resolve(this.current);
}
const previous = this.current.name;
this.current.name = nextName;
return this.save({
kind: 'micro',
opType: 'doc.rename',
undoable: false,
payload: {
previous,
next: nextName
}
}).then(() => this.current);
},
getRevision(revId) {
const api = getApi();
if (!revId) return Promise.resolve(null);
return api.db.versions.get(revId);
},
applyRevision(revision) {
const api = getApi();
if (!revision || !revision.snapshot) {
return Promise.resolve(null);
}
const previousAtomicEdit = this._atomicEdit ? { ...this._atomicEdit } : null;
const previousDocId = this.current?.id || null;
const preserved = this.current ? {
name: this.current.name,
tree: JSON.parse(JSON.stringify(this.current.tree || { folders: [] }))
} : null;
const migrated = this.migrate(JSON.parse(JSON.stringify(revision.snapshot)));
this.current = migrated.doc;
api.geometryStore?.attachToDocument?.(this.current);
if (previousAtomicEdit && previousDocId && previousDocId === this.current?.id) {
const featureId = previousAtomicEdit.feature_id || null;
const hasFeature = featureId && Array.isArray(this.current?.features)
? this.current.features.some(feature => feature?.id === featureId)
: false;
this._atomicEdit = hasFeature ? previousAtomicEdit : null;
} else {
this._atomicEdit = null;
}
if (preserved) {
this.current.name = this.normalizeName(preserved.name);
this.current.tree = preserved.tree;
}
this.current.version = revision.rev || this.current.version;
this.current.head_rev = revision.rev_id || this.revisionKey(this.current.id, this.current.version);
this.current.modified_at = revision.created_at || this.current.modified_at || Date.now();
this.hydrateRuntimeState(this.current);
api.solids?.scheduleRebuild?.('document.hydrate');
return Promise.all([
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, this.current.head_rev || null)
]).then(() => this.current);
},
canRedo() {
return this._redoStack.length > 0;
},
undo() {
const head = this.current?.head_rev;
if (!head) return Promise.resolve(false);
return this.getRevision(head).then(revision => {
const parent = revision?.parent_rev;
if (!parent) return false;
return this.getRevision(parent).then(parentRevision => {
if (!parentRevision) return false;
this._redoStack.push(head);
return this.applyRevision(parentRevision).then(() => true);
});
});
},
redo() {
if (!this._redoStack.length) return Promise.resolve(false);
const nextRev = this._redoStack.pop();
return this.getRevision(nextRev).then(revision => {
if (!revision) return false;
if (revision.parent_rev && revision.parent_rev !== this.current?.head_rev) {
this._redoStack = [];
return false;
}
return this.applyRevision(revision).then(() => true);
});
},
delete(id) {
const api = getApi();
if (!id) return Promise.resolve(false);
const isCurrent = this.current?.id === id;
const lower = `${id}:`;
const upper = `${id}:\uffff`;
return api.db.versions.iterate({ lower, upper }).then(entries => {
const deletes = entries.map(({ key }) => api.db.versions.remove(key));
deletes.push(api.db.documents.remove(id));
return Promise.all(deletes);
}).then(() => {
if (!isCurrent) {
return true;
}
this._redoStack = [];
return this.list().then(docs => {
const next = docs.find(doc => doc.id !== id);
if (next) {
return this.select(next.id).then(() => true);
}
return this.createAndSelect().then(() => true);
});
});
},
restoreOrCreate() {
const api = getApi();
return api.db.admin.get(ADMIN_CURRENT_DOC_KEY).then(docId => {
if (!docId) {
return this.createAndSelect();
}
return this.select(docId).then(doc => {
if (doc) {
return this.current;
}
return this.list().then(docs => {
if (docs.length) {
return this.select(docs[0].id).then(() => this.current);
}
return this.createAndSelect();
});
});
});
},
isAtomicEditActive() {
return !!this._atomicEdit;
},
getAtomicEditFeatureId() {
return this._atomicEdit?.feature_id || null;
},
beginAtomicEdit(meta = {}) {
this._atomicEdit = {
feature_id: meta.feature_id || null,
feature_type: meta.feature_type || null,
start_rev: this.current?.head_rev || null
};
},
endAtomicEdit(options = {}) {
const api = getApi();
const session = this._atomicEdit;
this._atomicEdit = null;
if (!session) {
return Promise.resolve(false);
}
if (options.commit !== true) {
return Promise.resolve(false);
}
const startRev = session.start_rev || null;
const currentHead = this.current?.head_rev || null;
if (startRev === currentHead) {
return Promise.resolve(false);
}
if (!this.current) return Promise.resolve(false);
const now = Date.now();
const next = this.nextRevision('micro');
const revId = this.revisionKey(this.current.id, next);
const snapshot = this.toSnapshot();
const revision = {
doc_id: this.current.id,
rev: next,
rev_id: revId,
parent_rev: startRev,
schema_version: DOC_SCHEMA_VERSION,
op_type: options.opType || 'feature.atomic.edit',
payload: options.payload || {
feature_id: session.feature_id,
feature_type: session.feature_type
},
snapshot,
created_at: now
};
this.current.version = next;
this.current.head_rev = revId;
this.current.scene = snapshot.scene;
this.current.modified_at = now;
this._redoStack = [];
return Promise.all([
api.db.versions.put(revId, revision),
api.db.documents.put(this.current.id, this.current),
api.db.admin.put(ADMIN_CURRENT_DOC_KEY, this.current.id),
api.db.admin.put(ADMIN_CURRENT_REV_KEY, revId)
]).then(() => true);
},
getTimelineCount() {
const features = Array.isArray(this.current?.features) ? this.current.features : [];
if (!features.length) return 0;
const rawIndex = this.current?.timeline?.index;
if (rawIndex === null || rawIndex === undefined) {
return features.length;
}
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(rawIndex)));
return index + 1;
},
setTimelineCount(count) {
const api = getApi();
if (!this.current) return Promise.resolve(false);
const features = Array.isArray(this.current.features) ? this.current.features : [];
const maxCount = features.length;
const nextCount = Math.max(0, Math.min(maxCount, Math.floor(Number(count) || 0)));
const prevCount = this.getTimelineCount();
if (nextCount === prevCount) {
return Promise.resolve(false);
}
this.current.timeline = this.current.timeline || { index: null };
this.current.timeline.index = nextCount >= maxCount ? null : (nextCount - 1);
return this.save({
kind: 'micro',
opType: 'timeline.set',
payload: { previous: prevCount, next: nextCount }
}).then(() => {
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('timeline.set');
return true;
});
}
};
}
export { createDocumentApi };

238
src/void/api/features.js Normal file
View file

@ -0,0 +1,238 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createFeaturesApi(getApi) {
function getEffectiveTimelineCount(doc, editFeatureId = null) {
const features = Array.isArray(doc?.features) ? doc.features : [];
if (!features.length) return 0;
const raw = doc?.timeline?.index;
let count;
if (raw === null || raw === undefined) {
count = features.length;
} else {
const index = Math.max(-1, Math.min(features.length - 1, Math.floor(raw)));
count = index + 1;
}
if (editFeatureId) {
const editIndex = features.findIndex(feature => feature?.id === editFeatureId);
if (editIndex >= 0) {
count = Math.min(count, editIndex + 1);
}
}
return count;
}
return {
list() {
const api = getApi();
const doc = api.document.current;
return doc ? doc.features : [];
},
listBuilt() {
const api = getApi();
const doc = api.document.current;
if (!doc) return [];
const features = Array.isArray(doc.features) ? doc.features : [];
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
return features.filter((feature, index) => index < timelineCount && feature?.suppressed !== true);
},
isIndexBuilt(index) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return false;
const editFeatureId = api.document?.getAtomicEditFeatureId?.() || null;
const timelineCount = getEffectiveTimelineCount(doc, editFeatureId);
return index >= 0 && index < timelineCount;
},
isBuilt(featureId) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return false;
const index = doc.features.findIndex(f => f?.id === featureId);
if (index < 0) return false;
return this.isIndexBuilt(index);
},
add(feature) {
const api = getApi();
const doc = api.document.current;
if (doc) {
if (feature?.visible === undefined) {
feature.visible = true;
}
if (feature?.suppressed === undefined) {
feature.suppressed = false;
}
doc.features = Array.isArray(doc.features) ? doc.features : [];
const rawTimeline = doc.timeline?.index;
const hasTimelineMarker = rawTimeline !== null && rawTimeline !== undefined && Number.isFinite(rawTimeline);
const timelineIndex = hasTimelineMarker
? Math.max(-1, Math.min(doc.features.length - 1, Math.floor(rawTimeline)))
: (doc.features.length - 1);
const insertIndex = hasTimelineMarker ? (timelineIndex + 1) : doc.features.length;
doc.features.splice(insertIndex, 0, feature);
api.document.save({
kind: 'micro',
opType: 'feature.add',
payload: {
type: feature?.type || 'unknown',
id: feature?.id || null
}
});
if (hasTimelineMarker) {
// Keep marker at the newly inserted feature so downstream remains disabled.
doc.timeline.index = insertIndex;
}
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.add');
}
},
remove(feature) {
const api = getApi();
const doc = api.document.current;
if (doc) {
const index = doc.features.indexOf(feature);
if (index >= 0) {
doc.features.splice(index, 1);
if (doc.timeline && doc.timeline.index !== null && doc.timeline.index !== undefined) {
const timelineIndex = Math.floor(doc.timeline.index);
if (timelineIndex >= doc.features.length) {
doc.timeline.index = doc.features.length ? doc.features.length - 1 : null;
}
}
api.document.save({
kind: 'micro',
opType: 'feature.remove',
payload: {
type: feature?.type || 'unknown',
id: feature?.id || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.remove');
}
}
},
findById(id) {
const api = getApi();
const doc = api.document.current;
if (!doc || !Array.isArray(doc.features)) return null;
return doc.features.find(f => f?.id === id) || null;
},
update(featureId, mutator, options = {}) {
const api = getApi();
const doc = api.document.current;
if (!doc || !featureId) return null;
const feature = this.findById(featureId);
if (!feature) return null;
if (typeof mutator === 'function') {
mutator(feature);
} else if (mutator && typeof mutator === 'object') {
Object.assign(feature, mutator);
}
api.document.save({
kind: 'micro',
opType: options.opType || 'feature.update',
payload: {
id: feature.id,
type: feature.type || 'unknown',
changes: options.payload || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.update');
return feature;
},
mutateTransient(featureId, mutator) {
const api = getApi();
const feature = this.findById(featureId);
if (!feature) return null;
if (typeof mutator === 'function') {
mutator(feature);
} else if (mutator && typeof mutator === 'object') {
Object.assign(feature, mutator);
}
api.sketchRuntime?.sync();
return feature;
},
commit(featureId, options = {}) {
const api = getApi();
const feature = this.findById(featureId);
if (!feature) return null;
api.document.save({
kind: 'micro',
opType: options.opType || 'feature.update',
payload: {
id: feature.id,
type: feature.type || 'unknown',
changes: options.payload || null
}
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.commit');
return feature;
},
rename(featureId, name) {
const nextName = String(name || '').trim();
if (!nextName) return null;
return this.update(featureId, feature => {
feature.name = nextName;
}, {
opType: 'feature.rename',
payload: { name: nextName }
});
},
setVisible(featureId, visible) {
return this.update(featureId, feature => {
feature.visible = !!visible;
}, {
opType: 'feature.update',
payload: { field: 'visible', value: !!visible }
});
},
setSuppressed(featureId, suppressed) {
return this.update(featureId, feature => {
feature.suppressed = !!suppressed;
}, {
opType: 'feature.suppress',
payload: { field: 'suppressed', value: !!suppressed }
});
},
move(featureId, toIndex) {
const api = getApi();
const doc = api.document.current;
if (!doc || !featureId || !Array.isArray(doc.features)) return false;
const fromIndex = doc.features.findIndex(f => f?.id === featureId);
if (fromIndex < 0) return false;
const clamped = Math.max(0, Math.min(doc.features.length - 1, Math.floor(Number(toIndex))));
if (clamped === fromIndex) return false;
const [feature] = doc.features.splice(fromIndex, 1);
doc.features.splice(clamped, 0, feature);
api.document.save({
kind: 'micro',
opType: 'feature.move',
payload: { id: featureId, from: fromIndex, to: clamped }
});
api.sketchRuntime?.sync();
api.solids?.scheduleRebuild?.('feature.move');
return true;
}
};
}
export { createFeaturesApi };

View file

@ -0,0 +1,98 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createGeometryStoreApi(getApi) {
return {
schemaVersion: 1,
state: null,
defaultState() {
return {
schema_version: this.schemaVersion,
surfaces: [],
boundaries: [],
segments: [],
points: [],
regions: [],
topology: {
surface_to_segments: {},
segment_to_surfaces: {}
},
meta: {
feature_count: 0,
generated_at: 0
}
};
},
normalize(raw) {
const base = this.defaultState();
const out = raw && typeof raw === 'object'
? { ...base, ...raw }
: base;
out.schema_version = Number(out.schema_version) || this.schemaVersion;
out.surfaces = Array.isArray(out.surfaces) ? out.surfaces : [];
out.boundaries = Array.isArray(out.boundaries) ? out.boundaries : [];
out.segments = Array.isArray(out.segments) ? out.segments : [];
out.points = Array.isArray(out.points) ? out.points : [];
out.regions = Array.isArray(out.regions) ? out.regions : [];
out.topology = out.topology && typeof out.topology === 'object'
? out.topology
: base.topology;
out.meta = out.meta && typeof out.meta === 'object'
? out.meta
: base.meta;
out.meta.feature_count = Number(out.meta.feature_count) || 0;
out.meta.generated_at = Number(out.meta.generated_at) || 0;
return out;
},
hydrate(docLike) {
const raw = docLike?.geometry_store || null;
this.state = this.normalize(raw);
return this.state;
},
attachToDocument(doc) {
if (!doc || typeof doc !== 'object') return null;
const state = this.normalize(doc.geometry_store);
doc.geometry_store = state;
this.state = state;
return state;
},
snapshot() {
const state = this.state || this.defaultState();
return JSON.parse(JSON.stringify(state));
},
seedFromDocument(doc) {
const api = getApi();
const state = this.attachToDocument(doc) || this.defaultState();
const features = Array.isArray(api.document?.current?.features)
? api.document.current.features
: [];
state.meta = state.meta || {};
state.meta.feature_count = features.length;
state.meta.generated_at = Date.now();
return state;
},
applySolidSnapshot(doc, snapshot = {}) {
const state = this.attachToDocument(doc) || this.defaultState();
state.surfaces = Array.isArray(snapshot.surfaces) ? snapshot.surfaces : [];
state.boundaries = Array.isArray(snapshot.boundaries) ? snapshot.boundaries : [];
state.segments = Array.isArray(snapshot.segments) ? snapshot.segments : [];
state.points = Array.isArray(snapshot.points) ? snapshot.points : [];
state.regions = Array.isArray(snapshot.regions) ? snapshot.regions : [];
state.topology = snapshot.topology && typeof snapshot.topology === 'object'
? snapshot.topology
: state.topology;
state.meta = state.meta || {};
state.meta.feature_count = Number(snapshot?.meta?.feature_count) || state.meta.feature_count || 0;
state.meta.generated_at = Date.now();
return state;
}
};
}
export { createGeometryStoreApi };

79
src/void/api/origin.js Normal file
View file

@ -0,0 +1,79 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function createOriginApi(getApi) {
return {
state: { x: 0, y: 0, z: 0, show: true },
changeHandlers: new Set(),
defaultState() {
return { x: 0, y: 0, z: 0, show: true };
},
toJSON() {
const { x, y, z, show } = this.state;
return { x, y, z, show };
},
applyJSON(data = {}, notify = true) {
const next = {
x: data.x ?? 0,
y: data.y ?? 0,
z: data.z ?? 0,
show: data.show !== undefined ? !!data.show : true
};
this.state = next;
this.syncOverlayPoint();
if (notify) {
this.notifyChange();
}
return this.state;
},
isVisible() {
return !!this.state.show;
},
setVisible(visible) {
const next = !!visible;
if (this.state.show === next) {
return this.state;
}
this.applyJSON({ ...this.state, show: next }, true);
return this.state;
},
toggleVisible() {
return this.setVisible(!this.isVisible());
},
onChange(handler) {
if (typeof handler === 'function') {
this.changeHandlers.add(handler);
}
return this;
},
offChange(handler) {
this.changeHandlers.delete(handler);
return this;
},
notifyChange() {
for (const handler of this.changeHandlers) {
handler(this.state);
}
},
syncOverlayPoint() {
const api = getApi();
const item = api.overlay?.elements?.get('origin-point');
if (item?.el) {
item.opts = item.opts || {};
item.opts.hidden = !this.state.show;
item.el.style.display = this.state.show ? '' : 'none';
}
}
};
}
export { createOriginApi };

2651
src/void/api/solids.js Normal file

File diff suppressed because it is too large Load diff

382
src/void/datum.js Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

137
src/void/interact/points.js Normal file
View 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
};

View 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
};

View 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
};

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

36
src/void/sketch/api.js Normal file
View 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 };

View 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
};

View 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 };

View 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
};

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

112
src/void/sketch/curve.js Normal file
View 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

File diff suppressed because it is too large Load diff

484
src/void/sketch/index.js Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

918
src/void/sketch/runtime.js Normal file
View 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 };

View 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
};

View 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
};

View 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
};

File diff suppressed because it is too large Load diff

543
src/void/sketch/tools.js Normal file
View 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
View 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
View 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
};

View 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
View 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

File diff suppressed because it is too large Load diff

82
src/void/tree.js Normal file
View 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
View 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
View 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
View 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 };

View 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')
});
}
};

View file

@ -43,7 +43,7 @@ self.addEventListener('message', e => {
async function _install(e) {
log('install');
if (e.addRoutes) {
for (let pre of ["font","mesh","kiri","lib","wasm"]) {
for (let pre of ["font","mesh","kiri","void","lib","wasm"]) {
e.addRoutes({
condition: { urlPattern: new URLPattern({ pathname: `/${pre}/.*` }) },
source: { cacheName: CACHE_VERSION }

BIN
web/icon/kirimoto.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
web/icon/kirimoto2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

BIN
web/icon/meshtool.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

BIN
web/icon/voidform.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View file

@ -17,8 +17,8 @@
<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">
<title>Kiri:Moto</title>
<link rel="icon" href="favicon.ico">
<link rel="apple-touch-icon" href="favicon-mobile.png">
<link rel="icon" href="/icon/kirimoto.png">
<link rel="apple-touch-icon" href="/icon/kirimoto.png">
<link rel="stylesheet" type="text/css" href="index.css">
<link href="manifest.json" rel="manifest">
<link href="../font/css/all.min.css" rel="stylesheet">

View file

@ -9,7 +9,6 @@
"background_color": "#ffffff",
"orientation": "landscape-primary",
"icons": [
{ "src": "./logo-cube-512.png", "type": "image/png", "sizes": "512x512" },
{ "src": "./logo-cube-144.png", "type": "image/png", "sizes": "144x144" }
{ "src": "/icon/kirimoto.png", "type": "image/png", "sizes": "512x512" }
]
}

View file

@ -11,7 +11,7 @@
<meta property="og:type" content="website">
<meta property="og:url" content="https://grid.space/mesh/">
<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 href="../font/css/all.min.css" rel="stylesheet">
<link href="../fon2/bootstrap-icons.min.css" rel="stylesheet">

29
web/void/index.html Normal file
View 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

File diff suppressed because it is too large Load diff