Merge branch 'rel-next'

This commit is contained in:
Stewart Allen 2026-04-08 10:50:55 -04:00
commit c2eca07ed2
170 changed files with 36860 additions and 2409 deletions

View file

@ -11,7 +11,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout code

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*

View file

@ -14,6 +14,23 @@ const devel = process.argv.slice(2).map(v => v.replaceAll('-', '')).indexOf('dev
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = true;
// Enable GPU/graphics acceleration for Linux (addresses WebGL issues)
// Equivalent to Chrome flags that fixed the rendering in Chrome browser
if (process.platform === 'linux') {
// CRITICAL: Override GPU blocklist - allows hardware acceleration on blocked GPUs
app.commandLine.appendSwitch('ignore-gpu-blocklist');
// Enable WebGL draft extensions (improves WebGL compatibility)
app.commandLine.appendSwitch('enable-webgl-draft-extensions');
// Force GPU acceleration for 2D/3D rendering
app.commandLine.appendSwitch('enable-gpu-rasterization');
// Optional: Try Vulkan if available (Chromium auto-falls back to OpenGL if not)
// Uncomment if you need Vulkan specifically, but most systems work without it:
app.commandLine.appendSwitch('enable-features', 'Vulkan');
}
server({
port: 5309,
apps: basDir,
@ -61,12 +78,15 @@ function createWindow() {
if (url.endsWith('/mesh') || url.endsWith('/mesh/')) {
return;
}
if (url.endsWith('/void') || url.endsWith('/void/')) {
return;
}
event.preventDefault();
shell.openExternal(url);
});
webContents.on('did-finish-load', () => {
mainWindow.webContents.executeJavaScript(`{ let x = document.getElementById('app-quit'); if (x) { x.onclick = () => window.close() } }; null;`);
// console.log('did finish load');
});
if (devel) {

57
app.js
View file

@ -26,6 +26,7 @@ const mods = {};
const load = [];
const api = {};
let lastTouchTime = {};
let forceUseCache = false;
let serviceWorker = true;
let crossOrigin = false;
@ -134,10 +135,12 @@ function init(mod) {
"/boot" : redir((pre??"") + "/boot/", 301),
"/kiri" : redir((pre??"") + "/kiri/", 301),
"/mesh" : redir((pre??"") + "/mesh/", 301),
"/meta" : redir((pre??"") + "/meta/", 301),
"/void" : redir((pre??"") + "/void/", 301),
"/form" : redir((pre??"") + "/form/", 301),
"/kiri/index.html" : redir((pre??"") + "/kiri/", 301),
"/mesh/index.html" : redir((pre??"") + "/mesh/", 301),
"/meta/index.html" : redir((pre??"") + "/meta/", 301)
"/void/index.html" : redir((pre??"") + "/void/", 301),
"/form/index.html" : redir((pre??"") + "/form/", 301)
}));
mod.add(handleVersion);
mod.add(fixedmap("/api/", api));
@ -153,12 +156,15 @@ function init(mod) {
mod.static("/lib/", "alt");
mod.static("/lib/", "src");
mod.static("/obj/", "web/obj");
mod.static("/font/", "web/font");
mod.static("/boot/", "web/boot");
mod.static("/fon2/", "web/fon2");
mod.static("/font/", "web/font");
mod.static("/form/", "web/void");
mod.static("/icon/", "web/icon");
mod.static("/kiri/", "web/kiri");
mod.static("/mesh/", "web/mesh");
mod.static("/moto/", "web/moto");
mod.static("/kiri/", "web/kiri");
mod.static("/boot/", "web/boot");
mod.static("/void/", "web/void");
// module loader
function load_modules(root, force) {
@ -186,10 +192,10 @@ function init(mod) {
});
}
// load development and 3rd party modules
// load development and app modules (onshape, thingiverse)
load_modules('mod');
// load optional local modules
// load optional local modules (bambu)
load_modules('mods');
// run load functions injected by modules
@ -208,9 +214,19 @@ function init(mod) {
}
}
// create alt artifacts with module extensions
// synthesize new main when applicable
createArtifacts();
}
// create alt artifacts with module extensions
function createArtifacts() {
if (dryrun || !isElectron) {
if (debug) {
setTimeout(createArtifacts, 1000);
}
if (Object.keys(lastTouchTime).length === 0) {
logger.log('creating artifacts', Object.keys(append));
}
for (let [ key, val ] of Object.entries(append)) {
// append mains
let src = `${dir}/src/main/${key}.js`;
@ -218,6 +234,14 @@ function init(mod) {
logger.log('missing', src);
continue;
}
let ltt = fs.statSync(src).mtimeMs;
if (lastTouchTime[src] === ltt) {
continue;
} else if (debug) {
logger.log('changed', src);
}
lastTouchTime[src] = ltt;
// console.log({ src, ltt });
fs.mkdirSync(`${dir}/alt/main`, { recursive: true });
let body = fs.readFileSync(src);
fs.writeFileSync(`${dir}/alt/main/${key}.js`, body + val);
@ -234,7 +258,7 @@ function init(mod) {
} else {
logger.log('skipping artifacts');
}
};
}
// either add module assets to path or require(init.js)
function loadModule(mod, dir) {
@ -257,7 +281,7 @@ function initModule(mod, file, dir) {
logger.log({ module: file, dir });
require_fresh(file)({
// express functions added here show up at "/api/" url root
api: api,
api,
adm: {
setver(ver) { oversion = ver },
crossOrigin(bool) { crossOrigin = bool }
@ -329,8 +353,10 @@ function initModule(mod, file, dir) {
const path = mod.dir + '/' + dir + '/' + file;
try {
const body = fs.readFileSync(path);
if (debug && !single) logger.log({ inject: code, file, opt });
if (opt.first) {
if (debug && !single) {
logger.log({ inject: code, file, opt });
}
if (opt.first && append[code]) {
append[code] = body.toString() + '\n' + append[code];
} else {
append[code] += body.toString() + '\n';
@ -369,8 +395,11 @@ function handleSetup(req, res, next) {
}
const productionMap = {
'/lib/mesh/work.js' : '/lib/pack/mesh-work.js',
'/lib/main/void.js' : '/lib/pack/void-main.js',
'/lib/main/planegcs.wasm' : '/lib/void/solver/planegcs_dist/planegcs.wasm',
'/lib/worker/solids_worker.js' : '/lib/pack/void-work-solid.js',
'/lib/main/mesh.js' : '/lib/pack/mesh-main.js',
'/lib/mesh/work.js' : '/lib/pack/mesh-work.js',
'/lib/main/kiri.js' : '/lib/pack/kiri-main.js',
'/lib/kiri/run/engine.js' : '/lib/pack/kiri-eng.js',
'/lib/kiri/run/minion.js' : '/lib/pack/kiri-pool.js',
@ -487,7 +516,7 @@ function ifModifiedDate(req) {
function addCorsHeaders(req, res) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Headers', 'X-Moto-Ajax, Content-Type');
res.setHeader('Access-Control-Allow-Headers', 'X-Api-Key, X-Host, X-Moto-Ajax, Content-Type');
res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] || '*');
if (req.headers['access-control-request-private-network'] === 'true') {
res.setHeader('Access-Control-Allow-Private-Network', 'true');

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 = [ ];
@ -29,7 +32,7 @@ async function appendExtraModules(extras, outfile, minify = false) {
const result = await transform(code, {
minify: true,
loader: 'js',
target: 'es2020',
target: 'es2022',
});
return result.code;
})
@ -92,7 +95,7 @@ const rec = {
minify: isProd, // false for dev, true for prod
platform: 'browser',
sourcemap: false,
target: 'es2020',
target: 'es2022',
};
async function buildApp() {
@ -100,6 +103,20 @@ async function buildApp() {
// Concatenate kiri devices
generateDevices();
// Bundle void main app
await build(Object.assign({}, rec, {
entryPoints: [ 'src/main/void.js' ],
outfile: VOID_OUTFILE,
}));
appendExtraModules(VOID_EXTRAS, VOID_OUTFILE, isProd);
// Bundle void worker
await build(Object.assign({}, rec, {
entryPoints: [ 'src/void/worker/solids_worker.js' ],
outfile: 'src/pack/void-work-solid.js',
}));
// Bundle mesh main app
await build(Object.assign({}, rec, {
entryPoints: [ 'src/main/mesh.js' ],

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,17 +1,19 @@
src/ext/gerber.js,../../node_modules/@tracespace/parser/umd/parser.js
src/ext/manifold.js,../../node_modules/manifold-3d/manifold.js
src/ext/base64.js,../../node_modules/base64-js/base64js.min.js
src/ext/earcut.js,../../node_modules/earcut/src/earcut.js
src/ext/tween.js,../../node_modules/@tweenjs/tween.js/src/Tween.js
src/ext/jszip.js,../../node_modules/jszip/dist/jszip.js
src/wasm/manifold.wasm,../../node_modules/manifold-3d/manifold.wasm
src/kiri/lang-en.js,../../web/kiri/lang/en.js
web/fon2,../node_modules/bootstrap-icons/font/
web/kiri/lang/pl.js,pl-pl.js
web/kiri/lang/pt-pt.js,pt.js
web/kiri/lang/da-dk.js,da.js
web/kiri/lang/en-us.js,en.js
web/kiri/lang/fr-fr.js,fr.js
web/kiri/lang/de.js,de-de.js
web/kiri/lang/es.js,es-es.js
web/font,../node_modules/@fortawesome/fontawesome-free/
src//gpu/raster.js,../../node_modules/@gridspace/raster-path/build/raster-path.js
src//gpu/raster-worker.js,../../node_modules/@gridspace/raster-path/build/raster-worker.js
src//ext/gerber.js,../../node_modules/@tracespace/parser/umd/parser.js
src//ext/manifold.js,../../node_modules/manifold-3d/manifold.js
src//ext/base64.js,../../node_modules/base64-js/base64js.min.js
src//ext/earcut.js,../../node_modules/earcut/src/earcut.js
src//ext/tween.js,../../node_modules/@tweenjs/tween.js/src/Tween.js
src//ext/jszip.js,../../node_modules/jszip/dist/jszip.js
src//wasm/manifold.wasm,../../node_modules/manifold-3d/manifold.wasm
src//kiri/app/lang-en.js,../../../web/kiri/lang/en.js
web//fon2,../node_modules/bootstrap-icons/font/
web//kiri/lang/pl.js,pl-pl.js
web//kiri/lang/pt-pt.js,pt.js
web//kiri/lang/da-dk.js,da.js
web//kiri/lang/en-us.js,en.js
web//kiri/lang/fr-fr.js,fr.js
web//kiri/lang/de.js,de-de.js
web//kiri/lang/es.js,es-es.js
web//font,../node_modules/@fortawesome/fontawesome-free/

1 src/ext/gerber.js src//gpu/raster.js ../../node_modules/@tracespace/parser/umd/parser.js ../../node_modules/@gridspace/raster-path/build/raster-path.js
2 src/ext/manifold.js src//gpu/raster-worker.js ../../node_modules/manifold-3d/manifold.js ../../node_modules/@gridspace/raster-path/build/raster-worker.js
3 src/ext/base64.js src//ext/gerber.js ../../node_modules/base64-js/base64js.min.js ../../node_modules/@tracespace/parser/umd/parser.js
4 src/ext/earcut.js src//ext/manifold.js ../../node_modules/earcut/src/earcut.js ../../node_modules/manifold-3d/manifold.js
5 src/ext/tween.js src//ext/base64.js ../../node_modules/@tweenjs/tween.js/src/Tween.js ../../node_modules/base64-js/base64js.min.js
6 src/ext/jszip.js src//ext/earcut.js ../../node_modules/jszip/dist/jszip.js ../../node_modules/earcut/src/earcut.js
7 src/wasm/manifold.wasm src//ext/tween.js ../../node_modules/manifold-3d/manifold.wasm ../../node_modules/@tweenjs/tween.js/src/Tween.js
8 src/kiri/lang-en.js src//ext/jszip.js ../../web/kiri/lang/en.js ../../node_modules/jszip/dist/jszip.js
9 web/fon2 src//wasm/manifold.wasm ../node_modules/bootstrap-icons/font/ ../../node_modules/manifold-3d/manifold.wasm
10 web/kiri/lang/pl.js src//kiri/app/lang-en.js pl-pl.js ../../../web/kiri/lang/en.js
11 web/kiri/lang/pt-pt.js web//fon2 pt.js ../node_modules/bootstrap-icons/font/
12 web/kiri/lang/da-dk.js web//kiri/lang/pl.js da.js pl-pl.js
13 web/kiri/lang/en-us.js web//kiri/lang/pt-pt.js en.js pt.js
14 web/kiri/lang/fr-fr.js web//kiri/lang/da-dk.js fr.js da.js
15 web/kiri/lang/de.js web//kiri/lang/en-us.js de-de.js en.js
16 web/kiri/lang/es.js web//kiri/lang/fr-fr.js es-es.js fr.js
17 web/font web//kiri/lang/de.js ../node_modules/@fortawesome/fontawesome-free/ de-de.js
18 web//kiri/lang/es.js es-es.js
19 web//font ../node_modules/@fortawesome/fontawesome-free/

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)

49
docs/future-palette.md Normal file
View file

@ -0,0 +1,49 @@
# Shared Palette Audit (Kiri / Mesh / Void)
## Goal
Define a shared semantic color system in `web/moto/palette.css` and migrate each app incrementally with low risk.
## Phase 1 Delivered
- Added shared file: `web/moto/palette.css`
- Added semantic tokens for `light` and `dark` themes:
- `--color-bg`, `--color-surface`, `--color-text`, `--color-border`, `--color-accent`, etc
- Added compatibility aliases (bridge variables) for Mesh/Void existing CSS vars.
- Wired palette stylesheet into:
- `web/kiri/index.html`
- `web/mesh/index.html`
- `web/void/index.html`
- Added root attributes:
- Kiri: `data-app="kiri"` and early `data-theme="light|dark"` set in head script
- Mesh: `data-app="mesh" data-theme="dark"`
- Void: `data-app="void" data-theme="dark"`
## Naming Recommendation (semantic first)
- Surfaces:
- `--color-bg`, `--color-bg-elev`, `--color-bg-subtle`
- `--color-surface`, `--color-surface-2`
- Content:
- `--color-text`, `--color-text-muted`
- `--color-border`, `--color-border-strong`
- Interaction:
- `--color-accent`, `--color-accent-hover`, `--color-focus`
- `--color-selection`, `--color-selection-hover`
- Status:
- `--color-success`, `--color-warning`, `--color-danger`
## Step-wise Migration Plan
1. Convert top-level containers/menus/panels in each app to semantic tokens only.
2. Convert controls/interactions (hover/focus/selected) to semantic tokens.
3. Convert specialty overlays (grids, badges, debug panes) last.
4. Remove legacy per-app aliases once selectors are migrated.
## Validation Checklist Per Step
- Kiri light unchanged
- Kiri dark unchanged
- Mesh dark unchanged
- Void dark unchanged
- Contrast and hover/focus states still readable

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/

View file

@ -7,7 +7,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Layer Navigation
| Key | Action | Notes |
| --- | ------ | ----- |
| --------------- | ------------------------ | ------------------------------------ |
| ` (backtick) | Show layer 0 | First layer |
| 1-9 | Show layers at 10-90% | In 10% increments |
| 0 | Show all layers (100%) | Show maximum layer |
@ -19,7 +19,7 @@ description: Keyboard Shortcuts and Mouse Controls
## View Navigation
| Key | Action | Notes |
| --- | ------ | ----- |
| --- | -------------------- | ------------------------------------- |
| h | Home View | Default 45° view |
| t | Top View | Top-down view |
| f | Front View | Direct front side-view |
@ -33,14 +33,14 @@ description: Keyboard Shortcuts and Mouse Controls
## File Operations
| Key | Action | Notes |
| --- | ------ | ----- |
| --- | ------------------- | ------------------------ |
| i | File Import Dialog | Import 3D models |
| r | Recent Files Dialog | Open recently used files |
## Workflow Actions
| Key | Action | Notes |
| --- | ------ | ----- |
| ----- | ------------- | ------------------------------------------------------------ |
| s / S | Slice Object | Process workspace objects |
| p / P | Preview Paths | Route planning (hidden in SLA mode) |
| g | CNC Animation | Enter milling animation mode (CNC only) |
@ -50,7 +50,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Object Manipulation
| Key | Action | Notes |
| --- | ------ | ----- |
| ---------------------- | --------------------- | ------------------------------------ |
| d | Duplicate Selected | Create copy of selected objects |
| m | Mirror Selected | Mirror vertices of selected objects |
| O | Manual Rotation Input | Enter rotation values manually |
@ -61,7 +61,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Rotation & Movement
| Key | Action | Notes |
| --- | ------ | ----- |
| --------------- | ---------- | ------------------------------------ |
| [arrow] | Rotate 90° | Arrow keys rotate on respective axes |
| [shift + arrow] | Rotate 5° | Fine rotation control |
| [alt + arrow] | Move 5mm | Move object in X/Y plane |
@ -69,7 +69,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Rendering Modes
| Key | Action | Notes |
| --- | ------ | ----- |
| -------------- | ---------------------- | ---------------------------------------- |
| w | Toggle Ghost Rendering | Cycle between solid and semi-transparent |
| W | Toggle Wireframe | Cycle between solid and wireframe |
| Ctrl+W / Cmd+W | Toggle Edge Rendering | Show/hide model edges |
@ -77,7 +77,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Settings & Dialogs
| Key | Action | Notes |
| --- | ------ | ----- |
| --- | ------------------- | -------------------------------- |
| e | Device Dialog | Select and customize devices |
| o | Tool Dialog | CNC mode only |
| q | Preferences Dialog | Change application behaviors |
@ -88,7 +88,7 @@ description: Keyboard Shortcuts and Mouse Controls
## Workspace Management
| Key | Action | Notes |
| --- | ------ | ----- |
| ------ | --------------------- | ------------------------------------------ |
| Ctrl+S | Save Settings | Save current settings |
| Cmd+S | Save Workspace | Save workspace state (macOS) |
| Cmd+L | Restore Workspace | Restore saved workspace (macOS) |
@ -99,7 +99,7 @@ description: Keyboard Shortcuts and Mouse Controls
### General Navigation
| Input | Action | Notes |
| ----- | ------ | ----- |
| --------------- | ----------- | ------------------------------- |
| [left + drag] | Rotate View | Orbit camera around focus point |
| [right + drag] | Pan View | Move camera focus |
| [meta + drag] | Pan View | Alternative pan (macOS) |
@ -109,14 +109,14 @@ description: Keyboard Shortcuts and Mouse Controls
### Object Interaction
| Input | Action | Notes |
| ----- | ------ | ----- |
| -------------- | -------- | ----------------------------------- |
| [ctrl + click] | Lay Flat | Rotate clicked face toward platform |
| [meta + click] | Lay Flat | Alternative (macOS) |
### FDM Support Mode
| Input | Action | Notes |
| ----- | ------ | ----- |
| ---------------------------- | --------------------- | ---------------------------- |
| [left click] | Toggle Support Column | Add or remove support column |
| [ctrl + drag] / [cmd + drag] | Erase Columns | Remove support columns |
| [alt + drag] / [opt + drag] | Draw Columns | Add support columns |

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

View file

@ -0,0 +1,226 @@
# Void Chamfer V2 Plan (Geometric Offset, Not Boolean Cutters)
## Goal
Replace the current chamfer implementation based on cutter solids + boolean difference with a deterministic geometric chamfer pipeline that operates directly on mesh topology and face offsets.
## Why Change
1. Current path is boolean-driven (`solid/chamfer.js`) and depends on synthetic cutter prisms.
2. Boolean chamfer is fragile near:
- short edges
- dense/curved topology
- multi-edge corner interactions
- near-coplanar/low-angle neighborhoods
3. Provenance and boundary tracking are harder when chamfer is represented as a subtract operation, rather than explicit edge-face reconstruction.
4. Debugging and deterministic replay are harder with cutter generation and manifold fallback behavior.
## Current-State Findings
1. Chamfer currently:
- resolves selected edges
- builds cutter meshes from adjacent triangle normals
- performs boolean difference
- writes resulting body as manifold output
2. Signals in current code indicate boolean-centric lifecycle:
- `manifold_chamfer_passthrough`
- `manifold_chamfer_ready`
- cutter debug/failure logs
3. Edge references are already fairly good:
- chamfer refs use canonical boundary/segment identities
- this is strong input for a topology-based rebuild
## Target Architecture
Chamfer becomes a topology/geometry transform, not a subtractive solid operation.
1. Input:
- selected sharp edges (from stable boundary segment refs)
- chamfer distance (and later optional asymmetric distances)
2. Core operation:
- for each selected edge, offset its two incident face planes by chamfer distance
- intersect offset planes with local wedge to compute chamfer strip geometry
- trim neighboring faces and insert chamfer face(s)
3. Corner resolution:
- solve multi-edge vertex neighborhoods explicitly
- produce watertight corner patches without global booleans
4. Output:
- rebuilt manifold mesh + updated provenance/boundary mappings
- explicit chamfer faces with stable IDs (not anonymous boolean remnants)
## Data Model / Provenance Updates
1. Extend chamfer result metadata:
- `source_edge_segment_ids[]`
- `generated_face_ids[]`
- `status: geometric_chamfer_ready`
2. GeometryStore integration:
- chamfer faces emit boundaries/segments directly
- chamfer faces carry source edge lineage
3. Preserve compatibility:
- existing docs still readable
- optional fallback to legacy boolean chamfer behind feature flag
## Algorithm Plan
## Stage A: Topology Extraction
1. Build edge->incident-face adjacency from input mesh.
2. Identify valid chamfer candidates:
- manifold edges with exactly two incident faces
- non-smooth crease threshold gating
3. Group selected edges into connected chamfer regions.
## Stage B: Per-Edge Offset Construction
1. For each selected edge:
- compute incident face normals
- construct two offset face planes
- compute chamfer line as plane-plane intersection in local neighborhood
2. Create edge strip endpoints using neighboring trim constraints.
## Stage C: Face Trimming + Insertion
1. Trim original incident faces against chamfer boundary lines.
2. Insert chamfer quad/tri strip faces.
3. Maintain winding and local normal consistency.
## Stage D: Vertex Corner Solver
1. At each selected vertex:
- collect incoming chamfer strips
- solve intersection polygon in tangent frame
- triangulate corner patch deterministically
2. Handle edge cases:
- 2-edge corner
- n-edge star corner
- near-parallel incident faces
## Stage E: Rebuild + Mapping
1. Rebuild indexed mesh with new vertices/faces.
2. Recompute boundary segments and canonical edge refs.
3. Emit provenance mappings:
- old edge ref -> new chamfer face/segments
- unchanged faces preserve IDs where possible
## Execution Phases
## Phase 1: Infrastructure + Feature Flag
1. Add `chamfer_mode` toggle:
- `legacy_boolean` (default initially)
- `geometric_offset` (new path)
2. Build shared adjacency/topology helpers.
Exit criteria:
1. New path can run no-op safely and fall back cleanly.
## Phase 2: Single-Edge Geometric Chamfer
1. Implement robust one-edge chamfer on simple prism/cube cases.
2. Add deterministic unit fixtures.
Exit criteria:
1. Single selected edge produces expected geometry with no booleans.
## Phase 3: Multi-Edge Same-Face + Parallel Chains
1. Handle multiple selected edges on same body.
2. Ensure trim interactions are stable and watertight.
Exit criteria:
1. Common user workflows work without mesh cracks.
## Phase 4: Corner Solver
1. Implement n-edge corner patches.
2. Add tolerance policy and degeneracy handling.
Exit criteria:
1. Complex corners no longer require boolean fallback.
## Phase 5: Provenance + GeometryStore Wiring
1. Emit chamfer-derived boundaries/patch IDs with lineage.
2. Update hover/select mapping for chamfer outputs.
Exit criteria:
1. Chamfer boundaries are first-class and traceable.
## Phase 6: Default Cutover
1. Make geometric mode default.
2. Keep legacy boolean fallback for one release window.
3. Remove legacy path after stability window.
## Testing Plan
## Unit
1. Edge adjacency correctness.
2. Plane offset/intersection math.
3. Corner patch triangulation determinism.
4. Degenerate geometry tolerance behavior.
## Integration
1. Cube single-edge chamfer.
2. Multiple connected edges.
3. Concave/convex mixed selections.
4. Timeline edits upstream/downstream with stable refs.
5. Interaction with boolean-added bodies.
## Regression
1. No face holes/non-manifold edges after chamfer.
2. No ID churn for unaffected faces.
3. Boundary segment refs remain selectable post-chamfer.
## Risks and Mitigations
1. Risk: corner solver complexity.
- Mitigation: staged rollout with strict fixtures before cutover.
2. Risk: precision instability on small geometry.
- Mitigation: unified epsilon policy + local frame math.
3. Risk: behavior divergence from existing chamfer expectations.
- Mitigation: side-by-side mode comparison tooling and temp dual-run validator.
## Implementation Touchpoints
1. `src/void/solid/chamfer.js`
- split into legacy boolean and new geometric engine.
2. `src/void/solid/rebuild.js`
- route chamfer feature to mode-specific executor.
3. `src/void/api/solids.js`
- preserve/refit canonical edge mappings after geometric chamfer.
- expose chamfer lineage for debug overlays.
4. `src/void/api/geometry_store.js`
- ensure chamfer outputs emit boundary/segment/provenance records consistently.
## Immediate Next Step
Implement Phase 1 + Phase 2 in parallel:
1. Add mode flag and new engine scaffolding.
2. Land deterministic single-edge geometric chamfer on planar solids.

View file

@ -0,0 +1,150 @@
# Void Constraints V2 Plan (Planegcs-First, Fallback as Safety Net)
## Goal
Move sketch solving to a clean planegcs-first architecture, remove dual-solver behavioral drift, and improve drag/tangent stability.
## Current Findings
1. Constraint solving is currently dual-mode by default:
- `enforceWithPlanegcs()` runs, then a fallback settle pass is still applied.
- This can reintroduce different motion/priority behavior after planegcs already converged.
2. Drag interactions frequently force fallback:
- During drag, `useFallback: tangentDriven || !pointDrag` is used in pointer drag paths.
- This bypasses planegcs exactly where stable incremental behavior matters most.
3. Tangent constraints are not mapped into planegcs in the current mapper:
- `toPlanegcsConstraint()` covers many constraints but not sketch `tangent`.
- Tangency currently depends on fallback heuristics (`constraints_tangent.js`).
4. The planegcs wrapper already supports temporary constraints:
- Constraint objects with `temporary: true` are supported by the wrapper path.
- This enables proper drag-driving constraints with lower priority solving semantics.
5. `angle_via_point` is available in the solver bindings:
- Suitable for robust endpoint tangency encoding (angle = 0 at shared endpoint).
- This aligns with FreeCAD guidance for improved stability vs direct tangent formulations in corner cases.
## Root Cause Summary
1. Two solvers are actively shaping geometry during interaction.
2. Drag logic has explicit fallback preference in key paths.
3. Tangency is solved outside planegcs, creating inconsistent convergence and corner-case instability.
## Target Architecture
1. Planegcs is the primary and default solver for all live interaction and final settle.
2. Fallback solver is retained only as failure recovery.
3. Drag uses temporary constraints in planegcs:
- Temporary point-to-point/point-to-line style guidance constraints to cursor/ghost references.
- No permanent topology mutation from drag constraints.
4. Tangency uses planegcs-native representation:
- Shared-endpoint tangent: `angle_via_point` with angle = 0.
- Non-shared cases: use direct tangent primitives where stable (`tangent_la`, `tangent_aa`, etc.), with endpoint-angle fallback where needed.
## Phased Execution
## Phase 1: Instrumentation and Guardrails
1. Add solver telemetry per enforce call:
- planegcs used, fallback used, solve status, elapsed time.
2. Add debug toggle to display active temporary constraints during drag.
3. Add deterministic logs for tangent constraint path selection.
Exit Criteria:
1. We can observe when and why fallback is invoked.
## Phase 2: Temporary Drag Constraints
1. Add drag-time temporary constraints (`temporary: true`) in planegcs solve graph.
2. Remove drag-path forced fallback defaults.
3. Keep fallback only if planegcs solve fails or returns non-converged status.
Exit Criteria:
1. Drag no longer “snaps back” from dual-pass disagreement.
2. Solver path during normal drag is planegcs-only.
## Phase 3: Tangent Migration
1. Implement tangent mapping in `toPlanegcsConstraint()`:
- line-arc, arc-arc, line-circle as available.
2. For shared-endpoint tangent pairs, map to `angle_via_point` (angle=0).
3. Keep old tangent fallback path behind a temporary feature flag for rollback.
Exit Criteria:
1. Tangent drag corner cases no longer require tangent-specific fallback aggressiveness.
2. Shared-endpoint tangent cases are stable under repeated edits/drag.
## Phase 4: Remove Default Dual Settle
1. Remove unconditional fallback settle after successful planegcs solve.
2. Fallback runs only on explicit planegcs failure paths.
3. Keep compatibility switch (`constraints_v2_force_fallback`) for emergency rollback.
Exit Criteria:
1. Single primary solver behavior in normal operation.
2. Fewer constraint jitter/regressions from solver disagreement.
## Phase 5: Cleanup
1. Simplify pointer drag enforcement call sites.
2. Remove tangent-specific fallback tuning knobs that become obsolete.
3. Document canonical constraint mapping table and temporary-constraint rules.
Exit Criteria:
1. Constraint code paths are materially simpler and easier to reason about.
## Proposed Code Touchpoints
1. `src/void/sketch/constraints.js`
- Add temporary constraint plumbing and tangent mapping in `toPlanegcsConstraint()`.
- Remove unconditional fallback settle on success.
2. `src/void/sketch/pointer.js`
- Replace drag-time fallback preference with planegcs temporary constraints.
3. `src/void/sketch/constraints_actions.js`
- Ensure apply/edit flows use planegcs-first, fallback-on-failure behavior.
4. `src/void/sketch/constraints_tangent.js`
- Transition from primary solver role to compatibility fallback only.
5. `src/void/solver/sketch/gcs_wrapper.js`
- Confirm temporary constraint lifecycle handling and cleanup.
## Risks and Mitigations
1. Risk: Regression in legacy sketches tuned around fallback behavior.
- Mitigation: feature flag + staged rollout + telemetry.
2. Risk: Performance regressions during drag with added temporary constraints.
- Mitigation: limit temporary constraint count to active dragged subset; cap iterations.
3. Risk: Incorrect tangent mapping for mixed entity types.
- Mitigation: explicit mapping matrix tests per constraint subtype.
## Validation Plan
1. Unit tests:
- Tangent mapping (shared endpoint and non-shared).
- Temporary constraint injection/removal lifecycle.
- Planegcs success path without fallback pass.
2. Interaction tests:
- Drag with dimensions, coincident, perpendicular, and tangent combos.
- Repeated drag/release cycles without geometric drift.
- Circular/grid/polygon pattern interactions under drag.
3. Regression scenarios:
- Known tangent corner cases.
- Previously flaky dual-solver “snap back” sketches.
## Recommendation
Start with Phase 2 (temporary drag constraints + fallback-on-failure only for drag) before full tangent migration. This yields immediate UX improvement and reduces dual-solver interference while keeping rollback safety.

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.

View file

@ -0,0 +1,234 @@
# Void Face Provenance Plan (Boundary-First, Split Faces)
## Goal
Track which portions of resulting solids come from which sketch extrude regions, including after union/subtract, while keeping storage compact and spline-ready.
## Execution Status (2026-03-02)
Completed:
1. Plan authored and staged into phased implementation.
2. Debug visualization toggles added to preferences and wired to solids runtime:
3. Boundary loop rendering from GeometryStore.
4. Segment rendering from GeometryStore.
5. Segment/surface/region ID labels (overlay text).
6. Fixed world/local debug overlay transform bug:
7. GeometryStore points are world-space; line geometry parented under solids root must convert world -> root local because `space.WORLD` is rotated -90deg on X.
8. Manifold relation passthrough wired through kernel/worker/rebuild (`runIndex`, `runOriginalID`, `faceID`, and source-run solid mapping).
9. GeometryStore now emits provenance-partitioned `surface_patches` from per-face triangle run attribution (not just per-face-loop seeds).
10. Topology now records `patch_to_tris` and `tri_to_patch` during snapshot build for downstream hover/selection cutover.
11. Debug boundary rendering now prefers patch boundaries when present, so visualization aligns with sketch-derived/provenance splits.
In progress:
1. Cut over hover/selection resolvers from face-loop heuristics to patch-first entities (`surface_patch_id` canonical path).
2. Improve partition quality from triangle boundary approximation to robust boundary arrangement where needed.
Next up:
1. Add explicit `surface_patch_id` in hit/canonical selection entities.
2. Bind extrude-profile hover/select directly to patch/source-region maps.
3. Add regression fixtures for boolean unions/subtracts with mixed curved + planar outputs.
## Decisions
1. Primary provenance is `boundary/region/surface-patch`, not raw triangle ownership.
2. Triangle ownership is derived runtime index only (`tri -> surface_patch_id`) and can be rebuilt.
3. Multi-source output faces must be split into multiple bounded surface patches so each patch has one canonical source region.
4. Line/arc support ships first; segment model must support future spline kinds without schema redesign.
## Scope
In scope:
1. Extrude + boolean provenance tracking through rebuild pipeline.
2. Face splitting by source-region boundaries.
3. GeometryStore schema extension for stable patch-level IDs and source refs.
4. Runtime mapping from picks (`face/edge`) to canonical patch and source region.
Out of scope (initial pass):
1. Native spline feature authoring.
2. Long-lived persisted triangle provenance tables.
3. Non-planar sketch-on-surface expansion beyond current behavior.
## Data Model Changes
Add/extend document `geometry_store` entities:
1. `segments[]`
2. `kind: line | arc | spline`
3. `geom`: kind-specific payload
4. `sampled_polyline` (optional cache for hit testing/partitioning)
5. `boundaries[]`
6. Ordered `segment_ids`
7. `closed`, orientation, optional parent/child nesting
8. `regions[]`
9. `outer_boundary_id`
10. `hole_boundary_ids[]`
11. `source`: canonical source ref (`profile:<sketch>:<profile>`)
12. New `surface_patches[]`
13. `id`
14. `surface_id` (geometric carrier face)
15. `boundary_ids[]` (outer + holes)
16. `source_region_id` (single canonical owner)
17. `source_feature_id` (extrude feature)
18. `solid_id`
19. `status` (`direct`, `boolean-derived`, `rebound`)
20. New runtime-only `topology.patch_tri_index`
21. Maps mesh triangles to `surface_patch_id` for selection/render acceleration.
## Kernel Boundary Changes (Manifold)
Current kernel adapter only round-trips positions/indices; relation metadata is dropped.
Planned update:
1. Preserve Manifold mesh relation fields where available (`runOriginalID`, `faceID`, related run metadata).
2. Carry relation metadata through `extrudePolygons()` and `booleanMeshes()`.
3. Emit relation-aware intermediate records to rebuild stage (not directly persisted).
This enables deterministic attribution from boolean output back to input generated solids/regions before patch splitting.
## Provenance Build Pipeline
### Stage A: Sketch Region Capture
1. Keep current closed-loop profile extraction for line/arc.
2. Emit canonical `region_id = profile:<sketch>:<profile>`.
3. Record region boundaries using generic segment schema (`line|arc` now, `spline` later).
### Stage B: Extrude Seed Patches
1. Extrude each selected sketch region.
2. Seed cap/side patch candidates with direct source region refs.
3. Preserve manifold relation fields in intermediate mesh record.
### Stage C: Boolean Attribution
1. Perform add/subtract/intersect with relation-carrying meshes.
2. Build attribution map from output primitives/runs to source seed patches.
3. Mark ambiguous/mixed carrier surfaces for partitioning.
### Stage D: Face Partitioning (Split Multi-Source Faces)
1. For each mixed carrier surface, project contributing source boundaries to surface-local space.
2. Build planar arrangement, split into disjoint bounded cells.
3. Assign each cell a single `source_region_id` by relation majority + geometric tie-break.
4. Emit one `surface_patch` per bounded cell.
### Stage E: Runtime Topology Index
1. Build `tri -> surface_patch_id` map from patch partition output.
2. Use map for selection hit resolution and hover highlighting.
3. Rebuild index each solids rebuild; do not persist large triangle maps.
## Selection/Interaction Integration
1. Face pick resolves to `surface_patch_id` first, then `source_region_id`.
2. Edge/boundary pick resolves to `boundary_id`/`segment_id` that belongs to a patch.
3. Extrude-profile hover/highlight uses `source_region_id -> surface_patch[]` mapping.
4. Remove fallback heuristics that infer provenance only from coarse `source.profile_keys`.
## Storage and Performance
1. Persist compact canonical graph (`segments/boundaries/regions/surface_patches`).
2. Keep triangle-level maps runtime-only to avoid doc bloat and instability across remeshes.
3. Cache partition signatures per carrier surface to avoid full repartition when unchanged.
## Migration Plan
### Phase 1: Schema and Adapters
1. Add `surface_patches` schema and runtime index container.
2. Introduce generic segment schema (`kind + geom`) with current line/arc emitters.
3. Add compatibility normalizer for older docs (missing `surface_patches`).
### Phase 2: Kernel Metadata Plumbing
1. Extend solid kernel adapter to preserve manifold relation metadata.
2. Pass relation metadata through worker/main rebuild paths.
### Phase 3: Patch Builder
1. Implement mixed-face detection.
2. Implement local-space boundary arrangement and patch emission.
3. Add deterministic patch IDs/signatures.
### Phase 4: Resolver Cutover
1. Switch face selection from coarse face groups to `surface_patch` entities.
2. Update properties/tree hover mapping to patch/source-region links.
### Phase 5: Cleanup
1. Remove coarse provenance fallbacks once parity is validated.
2. Keep compatibility reader for older docs without `surface_patches`.
## Validation Plan
Unit tests:
1. Region extraction determinism (line/arc).
2. Mixed-face partitioning into disjoint bounded patches.
3. Single-owner assignment per patch.
4. Deterministic patch IDs under stable input.
Integration tests:
1. Two extrudes unioned: top face splits by source boundary and highlights per profile.
2. Subtract operation: surviving walls/caps retain correct source region refs.
3. Edit upstream sketch profile: downstream patch mapping updates without manual repair.
4. Rebuild in worker vs main thread yields identical patch/source mapping.
Regression guardrails:
1. No persisted triangle tables in doc snapshots.
2. No schema changes required to add spline segment kind later.
3. Selection never reports mixed-source face entities.
4. Any debug/runtime geometry under solids root must explicitly convert GeometryStore world coordinates to root-local coordinates.
5. Boundary debug rendering must use GeometryStore (`boundaries` / `surface_patches`) as source of truth, not reconstructed sketch-plane loops.
## Implementation Checklist
Phase 1:
1. Done: update `src/void/api/geometry_store.js` schema to include `surface_patches` and runtime topology patch map container.
2. Done: extend `buildGeometryStoreSnapshot()` in `src/void/api/solids.js` to emit seeded `surface_patches` per face-loop with source-region candidate fields.
3. In progress: keep current face-key canonical mapping intact while adding optional patch ID fields.
Phase 2:
1. Update `src/void/solid/kernel.js` mesh conversion to preserve manifold relation fields:
2. Input pass-through where provided (`runOriginalID`, `runIndex`, `faceID`, etc.).
3. Output pass-through into rebuild intermediate structures.
4. Add worker payload support for relation arrays when present.
Phase 3:
1. Implement mixed-source detection in `src/void/solid/rebuild.js`.
2. Add per-surface local partition pass and emit patch boundaries.
3. Assign one canonical `source_region_id` per patch.
Phase 4:
1. Extend selection resolver and solids hit mapping to prefer patch IDs over raw face IDs.
2. Add properties/tree hover mapping from extrude profile -> patch IDs.
3. Remove coarse fallback once parity checks pass.
Phase 5:
1. Add deterministic integration tests for union/subtract split-face provenance.
2. Remove temporary migration branches and finalize docs.
## Acceptance Criteria
1. Every selectable resulting face area maps to exactly one `source_region_id`.
2. Multi-source carrier faces are visibly and topologically split at source boundaries.
3. Extrude profile hover/select maps accurately to resulting solid patches after booleans.
4. GeometryStore stays compact and stable; triangle mapping is derived at runtime.

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.

0
mods/proxy/.debug Normal file
View file

0
mods/proxy/.electron Normal file
View file

55
mods/proxy/init.js Normal file
View file

@ -0,0 +1,55 @@
module.exports = async (server) => {
const { api, env, handler, path, util } = server;
server.inject("kiri", "main.js");
if (!(env.debug || env.electron)) {
util.log('not a valid context for proxy');
return;
}
path.full({
"/printer/print/start": proxy_post,
"/server/files/upload": proxy_post,
"/api/files/local": proxy_post,
});
};
function proxy_post(req, res, next) {
handler.addCORS(req, res);
if (req.method === 'POST') {
let { url, headers } = req;
let chunks = [];
let host = headers['x-host'];
let apik = headers['x-api-key'] ?? '';
let cont = headers['content-type'] ?? 'application/binary';
req
.on('data', data => chunks.push(data) )
.on('end', () => {
req.app.post = Buffer.concat(chunks);
if (host) {
fetch(host + url, {
method: 'POST',
headers: {
'Content-Type': cont,
'X-Api-Key': apik
},
body: req.app.post
}).then(result => {
if (result.ok) {
res.writeHead(200, 'OK');
} else {
res.writeHead(500, 'Failed to proxy');
console.log({ result });
}
res.end();
});
} else {
console.log('drop proxy due to lack of host');
}
});
} else {
next();
}
}

3
mods/proxy/main.js Normal file
View file

@ -0,0 +1,3 @@
self.kiri.load(api => {
api.feature.proxy = true;
}, 'Proxy');

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

@ -1,6 +1,6 @@
{
"name": "grid-apps",
"version": "4.6.0",
"version": "4.7.0",
"description": "grid.space 3d slicing & modeling tools",
"author": "Stewart Allen <sa@grid.space>",
"license": "MIT",
@ -41,6 +41,7 @@
"@gridspace/basic-ftp": "github:gridspace/basic-ftp#v5.0.5-gridspace",
"@gridspace/net-level-client": "^0.2.3",
"@gridspace/raster-path": "^1.1.1",
"@salusoft89/planegcs": "^1.1.7",
"@tracespace/parser": "^5.0.0-next.0",
"@tweenjs/tween.js": "^16.6.0",
"aedes": "^0.51.3",
@ -77,7 +78,7 @@
"cross-env": "^10.1.0",
"docusaurus-lunr-search": "^3.6.0",
"dotenv": "latest",
"electron": "^35.0.1",
"electron": "^40.0.0-beta.6",
"electron-builder": "^24.9.1",
"esbuild": "^0.25.5",
"fs-extra": "^11.2.0",
@ -103,6 +104,7 @@
"clean": "rm -rf alt build data dist src/pack src.old tmp web.old .bcache",
"dev": "npm run pack-dev && gs-app-server --debug --single",
"docs-build": "docusaurus build --config conf/docusaurus.config.js",
"docs-clean": "prettier --config ./conf/prettier.config.js ./docs --write",
"docs-check": "prettier --config ./conf/prettier.config.js ./docs --check",
"docs-dev": "docusaurus start --config conf/docusaurus.config.js",
"docs-serve": "docusaurus serve --config conf/docusaurus.config.js --port 4004",
@ -121,7 +123,8 @@
"start-ddb": "npm run prebuild && electron . --devel --debugg",
"start-dev": "npm run prebuild prod && electron . --devel",
"start": "npm run prebuild && electron .",
"webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs",
"webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs && npm run webpack-pgcs",
"webpack-pgcs": "mkdir -p src/void/solver/planegcs_dist src/void/solver/sketch && cp node_modules/@salusoft89/planegcs/dist/index.js src/void/solver/planegcs.js && cp -R node_modules/@salusoft89/planegcs/dist/planegcs_dist/. src/void/solver/planegcs_dist/ && cp -R node_modules/@salusoft89/planegcs/dist/sketch/. src/void/solver/sketch/",
"webpack-qjs": "npx webpack --config bin/webpack-quickjs-esm.js",
"webpack-src": "node bin/esbuild.config.mjs",
"webpack-three": "npx webpack --config bin/webpack-three-esm.js",

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

@ -121,7 +121,7 @@ export function poly2polyEmit(array, startPoint, emitter, opt = {}) {
continue;
}
let area = poly.open ? 1 : poly.area();
poly.forEachPoint(function (point, index) {
poly.forEachPoint((point, index) => {
dist = opt.weight ?
startPoint.distTo3D(point) * area * area :
startPoint.distTo2D(point);

View file

@ -74,10 +74,23 @@ const POLYS = {
union,
unionFaces,
xor,
verify
};
export { POLYS };
export function verify(polys) {
polys.forEach(p => {
if (!p.open && p.length < 3) console.trace('SHORT', p);
if (!p.open && p.area() < 0.001) console.trace('SMALL', p);
p.points.forEach(p => {
if (isNaN(p.x) || isNaN(p.y) ||isNaN(p.z)) {
console.trace('NaN', p);
}
});
});
}
export function outer(polys) {
for (let p of polys) {
p.inner = undefined;
@ -136,7 +149,7 @@ export function fromClipperNode(tnode, z) {
export function fromClipperTree(tnode, z, tops, parent, minarea) {
let poly,
polys = tops || [],
min = numOrDefault(minarea, 0.1);
min = minarea ?? 0.1;
for (let child of tnode.m_Childs) {
poly = fromClipperNode(child, z);
@ -150,7 +163,7 @@ export function fromClipperTree(tnode, z, tops, parent, minarea) {
polys.push(poly);
}
if (child.m_Childs) {
fromClipperTree(child, z, polys, parent ? null : poly, minarea);
fromClipperTree(child, z, polys, parent ? null : poly, min);
}
}
@ -641,13 +654,15 @@ export function xor(set, z) {
* @param {Polygon[]} setB mask set
* @returns {Polygon[]}
*/
export function trimTo(setA, setB) {
export function trimTo(setA, setB, opt = {}) {
// handle null/empty slices
if (setA === setB || setA === null || setB === null) return null;
if (setA === setB || setA === null || setB === null) {
return null;
}
let out = [], tmp;
util.doCombinations(setA, setB, {}, function(a, b) {
if (tmp = a.mask(b)) {
util.doCombinations(setA, setB, {}, (a, b) => {
if (tmp = a.mask(b, opt.nullEq, opt.minArea)) {
out.appendAll(tmp);
}
});

View file

@ -41,6 +41,7 @@ export async function slice(points, options = {}) {
zSum = 0.0, // sanity check that points enclose non-zere volume
buckets = [], // banded/grouped faces to speed up slice/search
overlapMax = options.overlap || 0.75,
bucketMin = options.bucketMin ?? 1,
bucketMax = options.bucketMax || 100,
onupdate = options.onupdate || function() {},
sliceFn = dval(options.slicer, sliceZ),
@ -138,7 +139,7 @@ export async function slice(points, options = {}) {
let zSpan = zMax - zMin;
let zSpanAvg = zSum / points.length;
let bucketCount = options.bucket !== false ?
Math.min(bucketMax, Math.max(1, Math.floor(zSpan / zSpanAvg))) : 1;
Math.min(bucketMax, Math.max(bucketMin, Math.floor(zSpan / zSpanAvg))) : 1;
zScale = 1 / (zMax / bucketCount);
@ -211,7 +212,6 @@ export async function slice(points, options = {}) {
let count = 0;
let opt = { ...options, zMin, zMax, zIndexes };
let ps = [];
for (let i = 0, l = buckets.length; i < l; i++) {
let bucket = buckets[i];
let { points, slices } = bucket;

View file

@ -40,12 +40,14 @@ import { visuals } from './visuals.js';
import { widgets } from './widgets.js';
import { workspace } from './workspace.js';
import { OPFS } from '../../moto/opfs.js';
// environment setup
let LOC = self.location,
EVENT = broker,
SETUP = utils.parseOpt(LOC.search.substring(1)),
FILES = openFiles(new Index(SETUP.d ? SETUP.d[0] : 'kiri')),
LOCAL = self.debug && !SETUP.remote,
LOCAL = (LOC.host.startsWith('localhost') || self.debug) && !SETUP.remote,
SECURE = isSecure(LOC.protocol);
// todo: fix in widget.js b/c front-end and back-end do not share api
@ -163,6 +165,7 @@ export const api = {
alerts(clr) { alerts.update(clr) },
bind(t,m,o) { return EVENT.bind(t,m,o) },
emit(t,m,o) { return EVENT.publish(t,m,o) },
emitDefer(t,m,d) { setTimeout(() => EVENT.publish(t,m), d ?? 100) },
import() { api.ui.load.click() },
listeners(topic) { return EVENT.targets(topic) },
on(t,l) { EVENT.on(t,l); return api.event },
@ -227,6 +230,7 @@ export const api = {
onkey(fn) {
api.feature.on_key2.push(fn);
},
opfs: OPFS,
platform,
process: processModule,
sdb: dataLocal,
@ -234,6 +238,12 @@ export const api = {
settings,
show: {
alert() { return alerts.show(...arguments) },
busy(msg) {
if (msg === false || msg === null || msg === 0 || msg === '') {
return visuals.set_progress(0);
}
return visuals.set_progress(-1, typeof msg === 'string' ? msg : undefined);
},
controls() { console.trace('deprecated') },
devices: showDevices,
import() { api.ui.import.style.display = '' },

View file

@ -341,11 +341,11 @@ export const conf = {
sliceSupportAngle: 50,
sliceSupportDensity: 0.1,
sliceSupportExtra: 0,
sliceSupportGap: 1,
sliceSupportGap: true,
sliceSupportNozzle: 0,
sliceSupportOffset: 1.0,
sliceSupportOutline: true,
sliceSupportSpan: 5,
// sliceSupportSpan: 5,
sliceSupportType: "disabled",
sliceSupportTree: false,
sliceTopLayers: 3,
@ -604,9 +604,11 @@ export const conf = {
camToolInit: true,
camTraceDogbone: false,
camTraceDown: 0,
camTraceIgnore: false,
camTraceLines: false,
camTraceMerge: false,
camTraceOffOver: 0,
camTraceOffZ: 0,
camTraceOffset: "none",
camTraceOver: 0.5,
camTracePlunge: 200,

View file

@ -82,6 +82,8 @@ function updateProcessList() {
load.onclick = (ev) => {
api.conf.load(undefined, sk);
updateProcessList();
// update pulldowns
api.devices.refresh();
modal.hide();
}
load.appendChild(DOC.createTextNode(sk));

View file

@ -172,9 +172,6 @@ function updateSettings(opt = {}) {
same = false;
}
}
$('mode-device').innerText = device.deviceName;
$('mode-profile').innerText = `${cproc[mode]}${same ? '' : ' *'}`;
}
function updateSettingsFromFields(setrec, uirec = api.ui, changes) {
@ -279,6 +276,7 @@ function updateFieldsFromSettings(setrec, uirec = api.ui, opt = {}) {
let opt = document.createElement('option');
opt.appendChild(document.createTextNode(el.name));
opt.setAttribute('value', ev);
if (id === '#') opt.setAttribute('disabled', true);
uie.appendChild(opt);
});
if (chosen) {
@ -526,7 +524,7 @@ function settingsExport(opts = {}) {
const shot = opts.work || opts.screen ? space.screenshot() : undefined;
const work = opts.work ? codec.encode(widgets,{_json_:true}) : undefined;
const view = opts.work ? space.view.save() : undefined;
const setn = Object.clone(settings);
const setn = Object.clone(opts.engine ?? settings);
// stuff in legacy annotations for re-import
for (let w of widgets) {
setn.widget[w.id] = w.anno;
@ -539,7 +537,7 @@ function settingsExport(opts = {}) {
note: note,
work: work,
view: view,
moto: moto.id,
moto: self.moto?.id,
init: local.getItem('kiri-init'),
time: Date.now()
};
@ -559,6 +557,7 @@ function settingsImport(data, ask) {
}
if (api.const.LOCAL) console.log('import', data);
let isSettings = (data.settings && data.time);
let isProcess = (data.process && data.time && data.mode && data.name);
let isDevice = (data.device && data.time);

View file

@ -301,9 +301,7 @@ function setDeviceCode(code, devicename) {
*/
function renderDevices(devices) {
let selected = api.device.get() || devices[0],
features = api.feature,
devs = setconf.get().devices,
dfilter = typeof(features.device_filter) === 'function' ? features.device_filter : undefined;
devs = setconf.get().devices;
for (let local in devs) {
if (!(devs.hasOwnProperty(local) && devs[local])) {
@ -367,6 +365,31 @@ function renderDevices(devices) {
api.device.export(exp, selected, { event, record });
};
updateDeviceSelector(devices, api.ui.modeDevice, selected);
updateDeviceSelector(devices, $('dev-list'), selected);
selectDevice(selected);
// update related settings list
let curr = settings.get();
let slist = Object.keys(curr.sproc[settings.mode()]);
let cproc = settings.proc().processName;
h.bind(api.ui.modeProfile, slist.map(profile => {
return h.option({
_: profile,
selected: profile === cproc ? 1 : undefined,
onclick() { console.log({ select: profile })}
});
}));
api.ui.modeProfile.onchange = () => {
api.conf.load(undefined, api.ui.modeProfile.value);
};
}
function updateDeviceSelector(devices, selector, selected) {
let features = api.feature;
let dfilter = typeof(features.device_filter) === 'function' ? features.device_filter : undefined;
let dedup = {};
let list_cdev = [];
let list_mdev = [];
@ -388,7 +411,7 @@ function renderDevices(devices) {
}
});
let dev_list = $('dev-list');
let dev_list = selector;
h.bind(dev_list, [
h.option({ _: '-- My Devices --', disabled: true }),
...list_mdev,
@ -401,7 +424,6 @@ function renderDevices(devices) {
const seldev = dev_list.options[dev_list.selectedIndex];
selectDevice(seldev.innerText);
api.platform.layout();
updateDeviceList();
}
selectDevice(selected);
}

View file

@ -247,13 +247,16 @@ function exportGCodeDialog(gcode, sections, info, names) {
ajax = new XMLHttpRequest(),
host = octo_host.value.toLowerCase(),
apik = octo_apik.value,
type = octo_type.value;
type = octo_type.value,
targetLocal = !api.util.isSecure(host),
siteSecure = api.const.SECURE,
proxy = targetLocal && api.feature.proxy ? host : undefined;
if (host.indexOf("http") !== 0) {
api.show.alert("host missing protocol (http:// or https://)");
return;
}
if (api.const.SECURE && !api.util.isSecure(host)) {
if (siteSecure && targetLocal && !proxy) {
api.show.alert("host must begin with 'https' on a secure site");
return;
}
@ -262,6 +265,11 @@ function exportGCodeDialog(gcode, sections, info, names) {
localSet('octo-apik', apik.trim());
localSet('octo-type', type.trim());
if (proxy) {
console.log('proxying request to', host);
host = 'http://127.0.0.1:5309';
}
filename = $('print-filename').value + "." + fileext;
form.append("file", getBlob(), filename);
ajax.onreadystatechange = function() {
@ -297,6 +305,9 @@ function exportGCodeDialog(gcode, sections, info, names) {
} else {
ajax.open("POST", host + "/api/files/local");
}
if (proxy) {
ajax.setRequestHeader("X-Host", proxy);
}
if (apik) {
ajax.setRequestHeader("X-Api-Key", apik);
}

View file

@ -22,36 +22,58 @@ function loadImageDialog(image, name, force) {
}
});
}
const opt = {pre: [
"<div class='f-col a-center'>",
" <h3>Image Conversion</h3>",
" <p class='t-just' style='width:300px;line-height:1.5em'>",
" This will create a 3D model from a 2D PNG image. Photos must",
" be blurred to be usable. Values from 0=off to 50=high are suggested.",
" Higher values incur more processing time.",
" </p>",
" <div class='f-row t-right'><table>",
" <tr><th>blur value</th><td><input id='png-blur' value='0' size='3'></td>",
" <th>&nbsp;invert image</th><td><input id='png-inv' type='checkbox'></td></tr>",
" <tr><th>base size</th><td><input id='png-base' value='0' size='3'></td>",
" <th>&nbsp;invert alpha</th><td><input id='alpha-inv' type='checkbox'></td></tr>",
" <tr><th>border size</th><td><input id='png-border' value='0' size='3'></td>",
" <th></th><td></td></tr>",
" </table></div>",
"</div>"
]};
api.uc.confirm(undefined, {convert:true, cancel:false}, undefined, opt).then((ok) => {
if (ok) {
const rnd = Date.now().toString(36);
const host = $('mod-any');
host.innerHTML = [
`<div class="image-convert-dialog f-col a-center">`,
` <h3 class="image-convert-title">Image Conversion</h3>`,
` <p class="image-convert-copy t-just">`,
` This will create a 3D model from a 2D PNG image. Photos must`,
` be blurred to be usable. Values from 0=off to 50=high are suggested.`,
` Higher values incur more processing time.`,
` </p>`,
` <div class="f-row t-right image-convert-fields"><table>`,
` <tr><th>blur value</th><td><input id="png-blur-${rnd}" value="0" size="3"></td>`,
` <th>invert image</th><td><input id="png-inv-${rnd}" type="checkbox"></td></tr>`,
` <tr><th>base size</th><td><input id="png-base-${rnd}" value="0" size="3"></td>`,
` <th>invert alpha</th><td><input id="alpha-inv-${rnd}" type="checkbox"></td></tr>`,
` <tr><th>border size</th><td><input id="png-border-${rnd}" value="0" size="3"></td>`,
` <th></th><td></td></tr>`,
` </table></div>`,
` <div class="f-row j-end image-convert-actions">`,
` <button id="img-convert-ok-${rnd}">convert</button>`,
` <button id="img-convert-cancel-${rnd}">cancel</button>`,
` </div>`,
`</div>`
].join('');
const blur = $(`png-blur-${rnd}`);
const base = $(`png-base-${rnd}`);
const border = $(`png-border-${rnd}`);
const invImage = $(`png-inv-${rnd}`);
const invAlpha = $(`alpha-inv-${rnd}`);
const okBtn = $(`img-convert-ok-${rnd}`);
const cancelBtn = $(`img-convert-cancel-${rnd}`);
okBtn.onclick = () => {
api.modal.hide();
setTimeout(() => {
loadImage(image, {
file: name,
blur: parseInt($('png-blur').value) || 0,
base: parseInt($('png-base').value) || 0,
border: parseInt($('png-border').value) || 0,
inv_image: $('png-inv').checked,
inv_alpha: $('alpha-inv').checked
});
}
blur: parseInt(blur.value) || 0,
base: parseInt(base.value) || 0,
border: parseInt(border.value) || 0,
inv_image: invImage.checked,
inv_alpha: invAlpha.checked
});
},50);
};
cancelBtn.onclick = () => api.modal.hide();
blur.onkeypress = (ev) => {
if (ev.key === 'Enter' || ev.charCode === 13) okBtn.click();
};
api.modal.show('any');
setTimeout(() => blur.focus(), 0);
}
/**
@ -62,10 +84,10 @@ function loadImageDialog(image, name, force) {
*/
function loadImage(image, opt = {}) {
const info = Object.assign({settings: settings.get(), png:image}, opt);
api.client.image2mesh(info, progress => {
api.show.progress(progress, "converting");
api.client.image2mesh(info, () => {
api.show.busy('converting');
}, vertices => {
api.show.progress(0);
api.show.busy(false);
const widget = newWidget().loadVertices(vertices);
widget.meta.file = opt.file;
platform.add(widget);

View file

@ -105,8 +105,10 @@ export function bind() {
range: $('slider-center'),
},
loading: $('progress').style,
progress: $('progbar').style,
loading: $('progress-overlay')?.style,
progressOverlay: $('progress-overlay'),
progress: $('progress-ring'),
progressPct: $('progress-pct'),
prostatus: $('progtxt'),
selection: $('selection'),

View file

@ -0,0 +1,23 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { menubar } from './menu.js';
import { api } from '../api.js';
const surfaces = {
build(actions = {}) {
const stop = (fn) => (ev) => {
ev?.stopPropagation?.();
return fn?.(ev);
};
menubar.build({
...actions,
'view-arrange': stop(() => api.platform.layout()),
'act-slice': stop(() => api.function.slice()),
'act-preview': stop(() => api.function.print()),
'act-animate': stop(() => api.function.animate()),
'act-export': stop(() => api.function.export())
});
}
};
export { surfaces };

View file

@ -87,9 +87,6 @@ function checkSeed(then) {
return false;
}
// upon restore, seed presets
api.event.emit('preset', api.conf.dbo());
// api.event.on("set.threaded", bool => setThreaded(bool));
export function onBooleanClick(el) {
@ -142,6 +139,11 @@ function onResize() {
} else {
ui.modalBox.classList.remove('mh85');
}
if (WIN.innerWidth < 800) {
$('app').classList.add('slideshow');
} else {
api.prefs.updateDrawer();
}
api.view.update_slider();
}
@ -174,6 +176,7 @@ export function init_input() {
event.on('resize', onResize);
// configure moto.space
space.view.setFitPadding({ perspective: 0.8 });
space.sky.showGrid(false);
space.sky.setColor(controller.dark ? 0 : 0xffffff);
space.setAntiAlias(controller.antiAlias);
@ -211,6 +214,10 @@ export function init_input() {
// api augmentation with local functions
api.device.export = settingsOps.export_device;
let driven = true,
hideable = true,
separator = true;
Object.assign(ui, {
tracker: tracker,
container: container,
@ -320,6 +327,10 @@ export function init_input() {
prefadd: uc.checkpoint($('prefs-add')),
_____: newGroup('Machine Profile', $('all-devpro'), { driven, hideable, separator, group: "devpro" }),
modeDevice: newSelect('machine', {title: 'device', class: "tiny"}, "_"),
modeProfile: newSelect('profile', {title: 'profile', class: "tiny"}, "_"),
/** FDM Settings */
...menuFDM(),
@ -349,6 +360,8 @@ export function init_input() {
// override old style settings two-button menu
ui.settingsSave.onclick = () => {
settingsOps.settings_save(undefined, ui.settingsName.value);
// update pulldowns
api.devices.refresh();
};
// initialize and expose modal to API

267
src/kiri/app/init/menu.js Normal file
View file

@ -0,0 +1,267 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { $, h } from '../../../moto/webui.js';
import { api } from '../api.js';
const { div, span, label, input, button, i, hr } = h;
function icon(cls) {
return i({ class: cls });
}
function on(actions, id) {
return actions && typeof actions[id] === 'function' ? { onclick: actions[id] } : {};
}
function tr(key, fallback) {
return api.language?.current?.[key] || fallback || key;
}
function menuItem(actions, { id, lk, xlk, text, title, iconClass, className, children, onclick }) {
const attr = {
...(id ? { id } : {}),
...(title ? { title } : {}),
...(className ? { class: className } : {}),
...(onclick ? { onclick } : {}),
...on(actions, id)
};
const lblAttr = {
...(lk ? { lk } : {}),
...(xlk ? { xlk } : {})
};
const resolved = lk ? tr(lk, text) : xlk ? tr(xlk, text) : text;
return div(attr, [
resolved !== undefined ? label({ ...lblAttr, _: resolved }) : undefined,
iconClass ? span([icon(iconClass)]) : undefined,
children
].filter(v => v !== undefined));
}
function dropMenu(actions, side, items) {
return div({ class: `top-menu-drop top-menu-${side}` }, [
div({ class: 'content' }, items)
]);
}
function topMenu(actions, { text, lk, iconClass, side = 'left', right = false, items }) {
const resolved = lk ? tr(lk, text) : text;
return span({ class: right ? 'menu-right' : undefined }, [
resolved !== undefined ? label({ ...(lk ? { lk } : {}), _: resolved }) : undefined,
iconClass ? icon(iconClass) : undefined,
dropMenu(actions, side, items)
]);
}
function rotatePanel(actions) {
return div({ id: 'panel-rotate', class: 'selection-panel hide' }, [
div({ id: 'panel-rotate-head', class: 'selection-panel-head' }, [
div({ class: 'selection-panel-head-title' }, [
i({ class: 'fas fa-rotate-right' }),
label({ _: 'Rotate' })
]),
button({ id: 'panel-rotate-close', class: 'selection-panel-close', title: 'close' }, [
i({ class: 'fas fa-times' })
])
]),
div({ id: 'ft-rotate', class: 'grid selection-panel-body' }, [
div({ id: 'rot_x_lt', ...on(actions, 'rot_x_lt') }, icon('fas fa-chevron-left')),
label({ _: 'X' }),
div({ id: 'rot_x_gt', ...on(actions, 'rot_x_gt') }, icon('fas fa-chevron-right')),
input({ id: 'rot_x', class: 'value center', size: '6', value: '90' }),
div({ id: 'rot_y_lt', ...on(actions, 'rot_y_lt') }, icon('fas fa-chevron-left')),
label({ _: 'Y' }),
div({ id: 'rot_y_gt', ...on(actions, 'rot_y_gt') }, icon('fas fa-chevron-right')),
input({ id: 'rot_y', class: 'value center', size: '6', value: '90' }),
div({ id: 'rot_z_lt', ...on(actions, 'rot_z_lt') }, icon('fas fa-chevron-left')),
label({ _: 'Z' }),
div({ id: 'rot_z_gt', ...on(actions, 'rot_z_gt') }, icon('fas fa-chevron-right')),
input({ id: 'rot_z', class: 'value center', size: '6', value: '90' }),
div({ class: 'buttons f-row' }, [
button({ id: 'unrotate', class: 'grow', lk: 'reset', _: 'reset', ...on(actions, 'unrotate') })
])
])
]);
}
function scalePanel(actions) {
return div({ id: 'panel-scale', class: 'selection-panel hide' }, [
div({ id: 'panel-scale-head', class: 'selection-panel-head' }, [
div({ class: 'selection-panel-head-title' }, [
i({ class: 'fas fa-expand' }),
label({ _: 'Scale / Size' })
]),
button({ id: 'panel-scale-close', class: 'selection-panel-close', title: 'close' }, [
i({ class: 'fas fa-times' })
])
]),
div({ id: 'ft-scale', class: 'grid selection-panel-body' }, [
div([label({ _: 'X' }), input({ id: 'lock_x', type: 'checkbox', _checked: true })]),
div([label({ _: 'Y' }), input({ id: 'lock_y', type: 'checkbox', _checked: true })]),
div([label({ _: 'Z' }), input({ id: 'lock_z', type: 'checkbox', _checked: true })]),
label({ id: 'lab-axis', lk: 'axis', _: 'axis', ...on(actions, 'lab-axis') }),
input({ id: 'size_x', size: '8', class: 'value' }),
input({ id: 'size_y', size: '8', class: 'value' }),
input({ id: 'size_z', size: '8', class: 'value' }),
label({ id: 'lab-size', lk: 'size', _: 'size' }),
input({ id: 'scale_x', size: '8', class: 'value', value: '1' }),
input({ id: 'scale_y', size: '8', class: 'value', value: '1' }),
input({ id: 'scale_z', size: '8', class: 'value', value: '1' }),
label({ id: 'lab-scale', lk: 'scale', _: 'scale', ...on(actions, 'lab-scale') }),
div({ class: 'buttons f-row' }, [
button({ id: 'scale-reset', class: 'grow j-center', _: 'reset', ...on(actions, 'scale-reset') })
])
])
]);
}
function content(actions) {
return [
div({ class: 'menubar-appname el-app-hide', _: 'Kiri:Moto' }),
div({ class: 'menubar-separator el-app-hide' }),
div({ class: 'f-row top-menu grow' }, [
topMenu(actions, {
text: 'files', lk: 'fe_menu', items: [
menuItem(actions, { id: 'file-new', lk: 'new', text: 'new', iconClass: 'fas fa-file' }),
hr(),
menuItem(actions, { id: 'file-recent', lk: 'recent', text: 'recent', iconClass: 'fas fa-list' }),
menuItem(actions, {
id: 'file-import', lk: 'import', text: 'import', iconClass: 'fas fa-file-upload', children:
input({ id: 'load-file', type: 'file', name: 'loadme', style: 'display:none', accept: '.km,.kmz,.stl,.obj,.svg,.dxf,.png,.jpg,.jpeg,.gcode,.nc' })
}),
hr(),
menuItem(actions, { id: 'mesh-export-obj', lk: 'export-obj', text: 'save as OBJ', iconClass: 'fas fa-dice-d20' }),
menuItem(actions, { id: 'mesh-export-stl', lk: 'export-stl', text: 'save as STL', iconClass: 'fas fa-dice-d20' }),
hr(),
menuItem(actions, { id: 'app-export', lk: 'rc_xpws', text: 'export work', iconClass: 'fas fa-download' }),
hr({ class: "app-hide" }),
menuItem(actions, { id: 'app-quit', lk: 'quit', text: 'quit', className: 'hide', onclick() { window.close() } })
]
}),
topMenu(actions, {
text: 'edit', lk: 'ed_menu', items: [
menuItem(actions, { id: 'context-layflat', lk: 'rc_lafl', text: 'face down', iconClass: 'fas fa-angle-double-down' }),
menuItem(actions, { id: 'context-lefty', lk: 'face_left', text: 'face left', iconClass: 'fas fa-angle-double-left' }),
hr(),
menuItem(actions, { id: 'context-mirror', lk: 'rc_mirr', text: 'mirror', iconClass: 'fas fa-arrows-left-right-to-line' }),
menuItem(actions, { id: 'context-duplicate', lk: 'rc_dupl', text: 'duplicate', iconClass: 'fas fa-copy' }),
hr(),
menuItem(actions, { id: 'context-rotate-panel', text: 'rotate', iconClass: 'fas fa-rotate-right' }),
menuItem(actions, { id: 'context-scale-panel', text: 'scale / size', iconClass: 'fas fa-expand' }),
hr(),
menuItem(actions, { id: 'mesh-merge', lk: 'rc_merg', text: 'merge meshes' }),
menuItem(actions, { id: 'mesh-split', lk: 'rc_splt', text: 'isolate meshes' }),
]
}),
topMenu(actions, {
text: 'view', lk: 'vu_menu', items: [
menuItem(actions, { id: 'context-setfocus', lk: 'rc_focs', text: 'focal point', iconClass: 'fas fa-eye' }),
hr(),
menuItem(actions, { id: 'view-fit', lk: 'contents', text: 'contents', iconClass: 'fas fa-arrows-to-circle' }),
menuItem(actions, { id: 'view-home', lk: 'home', text: 'home', iconClass: 'fas fa-home' }),
menuItem(actions, { id: 'view-top', lk: 'top', text: 'top', iconClass: 'fas fa-square' }),
hr(),
menuItem(actions, { id: 'app-xpnd', lk: 'fullscreen', text: 'fullscreen', iconClass: 'fas fa-maximize' })
]
}),
topMenu(actions, {
text: 'render', lk: 're_menu', items: [
menuItem(actions, { id: 'render-solid', lk: 'solid', text: 'solid', iconClass: 'fas fa-square' }),
menuItem(actions, { id: 'render-wire', lk: 'wire', text: 'wireframe', iconClass: 'fas fa-border-all' }),
menuItem(actions, { id: 'render-ghost', lk: 'ghost', text: 'transparent', iconClass: 'fas fa-border-none' }),
hr(),
menuItem(actions, { id: 'render-edges', lk: 're_edgs', text: 'toggle edges', iconClass: 'fa-regular fa-square' })
]
}),
div({ class: 'f-row top-menu' }, [
span({ id: 'tool-nozzle' }, [
label({ lk: 'tool', _: tr('tool', 'tool') }),
div({ id: 'ft-nozzle', class: 'f-col pop' })
])
]),
div({ class: 'grow' }),
topMenu(actions, {
text: 'info', lk: 'info', side: 'right', right: true, items: [
menuItem(actions, { id: 'app-help', lk: 'help', text: 'help' }),
menuItem(actions, { id: 'app-don8', lk: 'donate', text: 'donate' }),
]
}),
topMenu(actions, {
text: 'mode', lk: 'mo_menu', side: 'right', right: true, items: [
menuItem(actions, { id: 'mode-fdm', text: 'FDM', title: '3D Additive Printing Processes', iconClass: 'fas fa-layer-group' }),
menuItem(actions, { id: 'mode-cam', text: 'CNC', title: 'CNC Mills and Subtractive Processes', iconClass: 'fas fa-bore-hole' }),
menuItem(actions, { id: 'mode-sla', text: 'SLA', title: 'mSLA Resin Printing', iconClass: 'fas fa-cube' }),
hr(),
menuItem(actions, { id: 'mode-laser', text: 'Laser', title: 'Laser Cutting and Engraving', iconClass: 'fas fa-bolt' }),
menuItem(actions, { id: 'mode-wjet', text: 'Water', title: 'WaterJet Cutting', iconClass: 'fas fa-location-pin' }),
menuItem(actions, { id: 'mode-wedm', text: 'Wire', title: 'Wire EDM Cutting', iconClass: 'fas fa-ellipsis-vertical' }),
menuItem(actions, { id: 'mode-drag', text: 'Drag', title: 'Drag Knife Cutting', iconClass: 'fas fa-caret-left' })
]
}),
topMenu(actions, {
text: 'setup', lk: 'su_menu', side: 'right', right: true, items: [
menuItem(actions, { id: 'set-device', lk: 'machines', text: 'machines', iconClass: 'fas fa-cube' }),
menuItem(actions, { id: 'set-profs', lk: 'profs', text: 'profiles', iconClass: 'fas fa-sliders-h' }),
menuItem(actions, { id: 'set-tools', lk: 'tools', text: 'tools', iconClass: 'fas fa-tools' }),
menuItem(actions, { id: 'set-prefs', lk: 'prefs', text: 'prefs', iconClass: 'fa-solid fa-square-check' }),
hr({ class: "el-app-hide" }),
menuItem(actions, { id: 'install', lk: 'install', text: 'install' }),
menuItem(actions, { id: 'uninstall', lk: 'uninstall', text: 'uninstall', className: 'hide' })
]
}),
topMenu(actions, {
iconClass: 'fas fa-language', side: 'right', right: true, items: [
menuItem(actions, { children: label({ id: 'lset-zh', _: '简体中文' }) }),
menuItem(actions, { children: label({ id: 'lset-da', _: 'dansk' }) }),
menuItem(actions, { children: label({ id: 'lset-de', _: 'deutsch' }) }),
menuItem(actions, { children: label({ id: 'lset-en', _: 'english' }) }),
menuItem(actions, { children: label({ id: 'lset-es', _: 'español' }) }),
menuItem(actions, { children: label({ id: 'lset-fr', _: 'français' }) }),
menuItem(actions, { children: label({ id: 'lset-pl', class: 'nocap', _: 'polski' }) }),
menuItem(actions, { children: label({ id: 'lset-pt', _: 'português' }) })
]
})
]),
rotatePanel(actions),
scalePanel(actions)
];
}
function modeTools(actions) {
const t = (key, fallback) => tr(key, fallback);
return [
span({ id: 'view-arrange', ...on(actions, 'view-arrange') }, [
span([icon('fas fa-shapes')]),
span({ lk: 'arrange', _: t('arrange', 'arrange') })
]),
span({ id: 'act-slice', ...on(actions, 'act-slice') }, [
span([icon('fas fa-bars')]),
label({ id: 'label-slice', title: 'generate layer slices', lk: 'slice', _: t('slice', 'slice') })
]),
span({ id: 'act-preview', ...on(actions, 'act-preview') }, [
span([icon('fas fa-layer-group')]),
label({ id: 'label-preview', title: 'show routing and paths', lk: 'preview', _: t('preview', 'preview') })
]),
span({ id: 'act-animate', ...on(actions, 'act-animate') }, [
span([icon('fas fa-film')]),
label({ id: 'label-animate', title: 'render routing on a mesh', lk: 'animate', _: t('animate', 'animate') })
]),
span({ id: 'act-export', ...on(actions, 'act-export') }, [
span([icon('fas fa-file-download')]),
label({ id: 'label-export', title: 'generate gcode', lk: 'export', _: t('export', 'export') })
])
];
}
export const menubar = {
build(actions = {}) {
const menubarNode = $('menubar');
const modeToolsNode = $('mode-tools');
if (!menubarNode) {
return;
}
h.bind(menubarNode, content(actions));
if (modeToolsNode) {
h.bind(modeToolsNode, modeTools(actions));
}
}
};

View file

@ -120,7 +120,9 @@ export async function init_sync() {
setup_keybd_nav();
// show topline separator when iframed
try { if (WIN.self !== WIN.top) $('top-sep').style.display = 'flex' } catch (e) { console.log(e) }
if (WIN.self !== WIN.top) {
$('menubar').classList.add('top');
}
// warn users they are running a beta release
if (beta && beta > 0 && sdb.kiri_beta != beta) {
@ -143,6 +145,9 @@ export async function init_sync() {
history.replaceState({}, '', wlp.substring(0,kio + 6));
}
// upon restore, seed presets
api.event.emitDefer('preset', api.conf.get());
// lift curtain
$('curtain').style.display = 'none';
}
@ -215,22 +220,126 @@ function ui_sync() {
}
function setup_keybd_nav() {
const panelPosKeys = {
'panel-rotate': 'win.roate',
'panel-scale': 'win.scale'
};
function parsePanelPos(key) {
if (!key) return null;
const raw = api.local.get(key);
if (!raw) return null;
if (typeof raw === 'object' && raw.left !== undefined && raw.top !== undefined) {
return raw;
}
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw);
if (parsed && parsed.left !== undefined && parsed.top !== undefined) {
return parsed;
}
} catch (e) {
// ignore malformed stored values
}
}
return null;
}
function placePanel(panel, pos) {
if (!panel || !pos) return;
panel.style.left = `${Math.round(pos.left)}px`;
panel.style.top = `${Math.round(pos.top)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
function placePanelDefault(panel) {
if (!panel) return;
const rect = panel.getBoundingClientRect();
const width = rect.width || 260;
const modeToolsRect = $('mode-tools')?.getBoundingClientRect();
const baseTop = modeToolsRect ? (modeToolsRect.bottom + 10) : 92;
const minPad = 8;
const maxLeft = Math.max(minPad, window.innerWidth - width - minPad);
const left = Math.min(maxLeft, Math.max(minPad, (window.innerWidth - width) / 2));
placePanel(panel, { left, top: baseTop });
}
function showSelectionPanel(pid) {
const panel = $(pid);
if (!panel) return;
panel.classList.remove('hide');
const key = panelPosKeys[pid];
const saved = parsePanelPos(key);
if (saved) {
placePanel(panel, saved);
} else {
placePanelDefault(panel);
}
}
function hideSelectionPanel(pid) {
const panel = $(pid);
if (!panel) return;
panel.classList.add('hide');
}
function toggleSelectionPanel(pid) {
const el = $(pid);
if (!el) return;
if (el.classList.contains('hide')) {
showSelectionPanel(pid);
} else {
hideSelectionPanel(pid);
}
}
function makePanelDraggable(panelId, handleId, storageKey) {
const panel = $(panelId);
const handle = $(handleId);
if (!panel || !handle) return;
let sx = 0, sy = 0, px = 0, py = 0, dragging = false;
handle.onmousedown = (ev) => {
if (ev.button !== 0) return;
dragging = true;
sx = ev.clientX;
sy = ev.clientY;
const rect = panel.getBoundingClientRect();
px = rect.left;
py = rect.top;
ev.preventDefault();
ev.stopPropagation();
};
document.addEventListener('mousemove', (ev) => {
if (!dragging) return;
const nx = px + (ev.clientX - sx);
const ny = py + (ev.clientY - sy);
panel.style.left = `${Math.round(nx)}px`;
panel.style.top = `${Math.round(ny)}px`;
panel.style.right = 'auto';
panel.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (dragging && storageKey) {
const rect = panel.getBoundingClientRect();
api.local.set(storageKey, JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top)
}));
}
dragging = false;
});
}
// bind interface action elements
ui.acct.help.onclick = (ev) => { ev.stopPropagation(); api.help.show() };
ui.acct.don8.onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') };
ui.acct.mesh.onclick = (ev) => { ev.stopPropagation(); WIN.location = "/mesh" };
ui.acct.export.onclick = (ev) => { ev.stopPropagation(); settingsOps.export_profile() };
ui.acct.export.title = LANG.acct_xpo;
ui.func.slice.onclick = (ev) => { ev.stopPropagation(); api.function.slice() };
ui.func.preview.onclick = (ev) => { ev.stopPropagation(); api.function.print() };
ui.func.animate.onclick = (ev) => { ev.stopPropagation(); api.function.animate() };
ui.func.export.onclick = (ev) => { ev.stopPropagation(); api.function.export() };
// prevent modal input from propagating to parents
ui.modalBox.onclick = (ev) => { ev.stopPropagation() };
$('export-support-a').onclick = (ev) => { ev.stopPropagation(); api.modal.show('don8') };
$('mode-device').onclick = api.show.devices;
$('mode-profile').onclick = settingsOps.settings_load;
$('mode-fdm').onclick = () => api.mode.set('FDM');
$('mode-cam').onclick = () => api.mode.set('CAM');
$('mode-sla').onclick = () => api.mode.set('SLA');
@ -245,13 +354,9 @@ function setup_keybd_nav() {
$('file-new').onclick = (ev) => { ev.stopPropagation(); settingsOps.new_workspace() };
$('file-recent').onclick = () => { api.modal.show('files') };
$('file-import').onclick = (ev) => { api.event.import(ev); };
$('view-arrange').onclick = api.platform.layout;
$('view-top').onclick = space.view.top;
$('view-home').onclick = space.view.home;
$('view-front').onclick = space.view.front;
$('view-back').onclick = space.view.back;
$('view-left').onclick = space.view.left;
$('view-right').onclick = space.view.right;
$('unrotate').onclick = () => {
api.widgets.for(w => w.unrotate());
selection.update_info();
@ -274,7 +379,10 @@ function setup_keybd_nav() {
$('rot_z_gt').onclick = () => { selection.rotate(0,0,-d * $('rot_z').value) };
// rendering options
$('render-edges').onclick = () => { api.view.set_edges({ toggle: true }); api.conf.save() };
$('render-edges').onclick = () => {
api.view.set_edges({ toggle: true });
api.conf.save()
};
$('render-ghost').onclick = () => {
const opacity = api.view.is_arrange() ? 0.4 : 0.25;
api.view.set_wireframe(false);
@ -285,7 +393,7 @@ function setup_keybd_nav() {
};
$('render-wire').onclick = () => {
api.view.set_wireframe(true, 0, api.space.is_dark() ? 0.25 : 0.5);
api.visuals.set_opacity(1.0);
api.visuals.set_opacity(0.25);
api.conf.save();
};
$('render-solid').onclick = () => {
@ -301,15 +409,24 @@ function setup_keybd_nav() {
$('mesh-split').onclick = selection.isolateBodies;
$('context-duplicate').onclick = selection.duplicate;
$('context-mirror').onclick = selection.mirror;
$('context-rotate-panel').onclick = () => toggleSelectionPanel('panel-rotate');
$('context-scale-panel').onclick = () => toggleSelectionPanel('panel-scale');
$('panel-rotate-close').onmousedown = (ev) => { ev.stopPropagation(); };
$('panel-scale-close').onmousedown = (ev) => { ev.stopPropagation(); };
$('panel-rotate-close').onclick = (ev) => { ev.stopPropagation(); hideSelectionPanel('panel-rotate'); };
$('panel-scale-close').onclick = (ev) => { ev.stopPropagation(); hideSelectionPanel('panel-scale'); };
$('context-layflat').onclick = view_tools.startLayFlat;
$('context-lefty').onclick = view_tools.startLeftAlign;
$('context-setfocus').onclick = () => {
view_tools.startFocus(ev => api.space.set_focus(undefined, ev.object.point));
};
$('context-contents').onclick = api.const.SPACE.view.fit;
// $('context-contents').onclick = api.const.SPACE.view.fit;
$('view-fit').onclick = api.const.SPACE.view.fit;
$('wassup').onmouseover = () => { $('suppopp').classList.remove('hide') };
makePanelDraggable('panel-rotate', 'panel-rotate-head', 'win.roate');
makePanelDraggable('panel-scale', 'panel-scale-head', 'win.scale');
// enable modal hiding
$('mod-x').onclick = api.modal.hide;
@ -319,6 +436,13 @@ function setup_keybd_nav() {
sdb.gdpr = Date.now();
};
// add app name hover info
$('app-info').innerText = version;
// fix file input on iOS
try {
if (/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) {
$('load-file').removeAttribute('accept');
}
} catch (e) {
console.log('iOS remediation fail', e);
}
}

View file

@ -889,7 +889,7 @@ function newSelect(label, options = {}, source) {
} else {
row.setAttribute("source", source || "tools");
}
row.setAttribute("class", "var-row");
row.classList.add('var-row');
row.style.display = hide ? 'none' : '';
if (options.id) ip.setAttribute("id", options.id);
if (options.convert) ip.convert = options.convert.bind(ip);

View file

@ -63,6 +63,7 @@ function setMode(mode, lock, then) {
// change mode constants
current.mode = mode;
MODE = MODES[mode];
document.title = 'Kiri:Moto | ' + mode;
// gcode edit area for any non-SLA mode
api.uc.setVisible($('gcode-edit'), mode !== 'SLA');
// highlight selected mode menu item

View file

@ -6,6 +6,7 @@ import { api } from './api.js';
import { MODES } from './consts.js';
import { colorSchemeRegistry } from './color/schemes.js';
import { load as file_load } from '../../load/file.js';
import { load_url as url_load } from '../../load/url.js';
import { newBounds } from '../../geo/bounds.js';
import { Packer } from './pack.js';
import { space } from '../../moto/space.js';
@ -171,14 +172,14 @@ function update_size(updateDark = true) {
space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor);
space.platform.opacity(0.05);
space.sky.set({ color: 0, ambient: { intensity: 0.6 } });
document.body.classList.add('dark');
document.documentElement.setAttribute('data-theme', 'dark');
} else {
space.platform.set({ light: 0.08 });
space.platform.setFont({rulerColor:'#333333'});
space.platform.setGrid(gridMajor, gridMinor, scheme.grid.major, scheme.grid.minor);
space.platform.opacity(0.2);
space.sky.set({ color: 0xffffff, ambient: { intensity: 1.1 } });
document.body.classList.remove('dark');
document.documentElement.setAttribute('data-theme', 'light');
}
space.platform.setSize();
}
@ -491,7 +492,7 @@ function load_stl(url, onload, formdata, credentials, headers) {
*/
function load_url(url, options = {}) {
platform.group();
file_load.URL.load(url, options).then(objects => {
url_load(url, options).then(objects => {
let widgets = [];
for (let object of objects) {
let widget = newWidget(undefined, options.group).loadVertices(object.mesh);
@ -978,6 +979,7 @@ function load_files(files, group) {
isobj = lower.endsWith(".obj"),
is3mf = lower.endsWith(".3mf"),
issvg = lower.endsWith(".svg"),
isdxf = lower.endsWith(".dxf"),
ispng = lower.endsWith(".png"),
isjpg = lower.endsWith(".jpg"),
iskmz = lower.endsWith(".kmz"),
@ -1079,6 +1081,19 @@ function load_files(files, group) {
}
load_dec();
});
} else if (isdxf) {
loadDXFDialog(opt => {
group = group || [];
let dxf = file_load.DXF.parse(data.textDecode('utf-8'), opt);
let ind = 0;
if (dxf.length === 0) {
api.show.alert(`DXF contains no supported entities`, 10);
}
for (let v of dxf) {
load_verts(group, dxf[ind++], ind ? `${name}-${ind}` : name);
}
load_dec();
});
}
else if (iskmz) api.settings.import_zip(data, true);
else if (isset) api.settings.import(data.textDecode('utf-8'), true);
@ -1124,6 +1139,64 @@ function loadSVGDialog(doit) {
});
}
/**
* Show dialog to configure DXF import settings.
* Prompts for extrusion depth, arc segment size, and nesting.
* @param {Function} doit - Callback with options: {soup, depth, segmentSize, minSegments}
* @private
*/
function loadDXFDialog(doit) {
const rnd = Date.now().toString(36);
const host = $('mod-any');
host.innerHTML = [
`<div class="image-convert-dialog f-col a-center">`,
` <h3 class="image-convert-title">Import DXF</h3>`,
` <p class="image-convert-copy t-just">`,
` Extrude a 3D model from a 2D DXF.`,
` Supports POLYLINE, LWPOLYLINE, LINE, CIRCLE, ARC, and SPLINE entities.`,
` </p>`,
` <div class="f-row t-right image-convert-fields"><table>`,
` <tr><th>units</th><td><select id="dxf-units-${rnd}"><option value="auto" selected>auto</option><option value="mm">millimeters</option><option value="inch">inches</option></select></td></tr>`,
` <tr><th>z height</th><td><input id="dxf-depth-${rnd}" value="5" size="3"></td></tr>`,
` <tr><th title="target length of each line segment when converting arcs and circles">arc segment size</th><td><input id="dxf-seg-${rnd}" value="1" size="3"></td></tr>`,
` <tr><th title="minimum number of segments for very small arcs to avoid degenerate geometry">minimum arc segments</th><td><input id="dxf-min-${rnd}" value="4" size="3"></td></tr>`,
` <tr><th>nest shapes</th><td><input id="dxf-nest-${rnd}" type="checkbox" checked></td></tr>`,
` </table></div>`,
` <div class="f-row j-end image-convert-actions">`,
` <button id="dxf-convert-ok-${rnd}">import</button>`,
` <button id="dxf-convert-cancel-${rnd}">cancel</button>`,
` </div>`,
`</div>`
].join('');
const units = $(`dxf-units-${rnd}`);
const depth = $(`dxf-depth-${rnd}`);
const segmentSize = $(`dxf-seg-${rnd}`);
const minSegments = $(`dxf-min-${rnd}`);
const nest = $(`dxf-nest-${rnd}`);
const okBtn = $(`dxf-convert-ok-${rnd}`);
const cancelBtn = $(`dxf-convert-cancel-${rnd}`);
okBtn.onclick = () => {
api.modal.hide();
setTimeout(() => {
doit({
soup: nest.checked,
depth: Math.max(0.1, parseFloat(depth.value)),
segmentSize: Math.max(0.01, parseFloat(segmentSize.value)),
minSegments: Math.max(3, parseInt(minSegments.value)),
units: units.value
});
}, 50);
};
cancelBtn.onclick = () => api.modal.hide();
depth.onkeypress = (ev) => {
if (ev.key === 'Enter' || ev.charCode === 13) okBtn.click();
};
api.modal.show('any');
setTimeout(() => depth.focus(), 0);
}
/**
* Expand platform bed depth to fit widgets (belt mode only).
* Finds maximum Y dimension of all widgets and expands bed if needed.

View file

@ -83,7 +83,7 @@ function booleanSave() {
control.assembly = ui.assembly.checked;
control.autoLayout = ui.autoLayout.checked;
control.autoSave = ui.autoSave.checked;
control.dark = ui.dark.checked;
control.dark = api.sdb['kiri-dark'] = ui.dark.checked;
control.devel = ui.devel.checked;
control.drawer = ui.drawer.checked;
control.exportOcto = ui.exportOcto.checked;
@ -109,6 +109,7 @@ function booleanSave() {
updateDrawer();
api.event.emit('boolean.update');
space.view.setProjection(control.ortho ? 'orthographic' : 'perspective');
setDarkLight(control.dark);
}
function updateDrawer() {
@ -127,3 +128,5 @@ export const preferences = {
booleanSave,
updateDrawer
};
api.prefs = preferences;

View file

@ -6,6 +6,7 @@ import { THREE } from '../../ext/three.js';
import { tool as MeshTool } from '../../mesh/tool.js';
import { encode as objEncode } from '../../load/obj.js';
import { encode as stlEncode } from '../../load/stl.js';
import { $ } from '../../moto/webui.js';
/**
* Array of currently selected widget meshes.

View file

@ -42,11 +42,11 @@ function setViewMode(mode) {
['view-arrange','act-slice','act-preview','act-animate'].forEach(el => {
$(el).classList.remove('selected')
});
$('render-tools').classList.add('hide');
// $('render-tools').classList.add('hide');
switch (mode) {
case VIEWS.ARRANGE:
$('view-arrange').classList.add('selected');
$('render-tools').classList.remove('hide');
// $('render-tools').classList.remove('hide');
api.function.clear_progress();
api.client.clear();
STACKS.clear();

View file

@ -315,17 +315,41 @@ function applyVisualState(widget) {
* @param {string} [msg] - Optional status message to display
*/
function setProgress(value = 0, msg) {
value = (value * 100).round(4);
api.ui.progress.width = value+'%';
if (self.debug) {
// console.log(msg, value.round(2));
api.ui.prostatus.style.display = 'flex';
if (msg) {
api.ui.prostatus.innerHTML = msg;
const overlay = api.ui.progressOverlay;
const ring = api.ui.progress;
const pct = api.ui.progressPct;
const text = api.ui.prostatus;
if (!overlay || !ring || !text) {
return;
}
const hasNumeric = typeof value === 'number' && Number.isFinite(value);
const indeterminate = !hasNumeric || (hasNumeric && value < 0);
const isHidden = hasNumeric && value === 0;
if (isHidden) {
overlay.classList.add('hide');
ring.classList.remove('indeterminate');
if (pct) pct.innerText = '';
text.innerText = '';
return;
}
overlay.classList.remove('hide');
if (!indeterminate) {
const clamped = Math.max(0, Math.min(1, value));
ring.classList.remove('indeterminate');
ring.style.setProperty('--progress-deg', `${(clamped * 360).toFixed(2)}deg`);
if (pct) pct.innerText = `${Math.round(clamped * 100)}%`;
} else {
api.ui.prostatus.innerHTML = '';
}
ring.classList.add('indeterminate');
ring.style.removeProperty('--progress-deg');
if (pct) pct.innerText = '';
}
text.innerText = msg || '';
}
let statsTimer;

View file

@ -288,11 +288,18 @@ class Print {
if (safeEval) {
safeEval.setContext(consts);
}
function doSafeEval(tok) {
try {
return safeEval ? safeEval.eval(tok) : undefined;
} catch (e) {
console.log({ macro_error: tok });
}
}
function tryeval(str) {
try {
return eval(`{ ${str} }`)
} catch (e) {
console.log({ eval_error: e, str });
console.log({ macro_error: e, str });
return str;
}
}
@ -319,7 +326,7 @@ class Print {
}
eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`);
eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`);
let evl = safeEval ? safeEval.eval(tok) : tryeval(eva.join(''));
let evl = doSafeEval(tok) ?? tryeval(eva.join(''));
nutok = evl;
if (pad === 666) {
return evl;

View file

@ -786,7 +786,7 @@ class Widget {
return this.cache.shadow = stack;
}
async computeShadowStack(zlist, progress, pocket) {
async computeShadowStack(zlist, progress, pocket, up = 1) {
let shadow_stack = this.cache.shadow_stack;
if (!shadow_stack) {
shadow_stack = this.cache.shadow_stack = {};
@ -803,7 +803,7 @@ class Widget {
let p = work.minions.queueAsync({
cmd: 'cam_shadow_z',
z: z - 0.005,
t: z + 1
t: z + up
}).then(reply => {
shadow_stack[z - 0.005] = decode(reply.data);
pval += pinc;

View file

@ -0,0 +1,182 @@
{
"mode": "CAM",
"internal": 0,
"bedHeight": 2.5,
"bedWidth": 300,
"bedDepth": 200,
"originCenter": false,
"spindleMax": 13000,
"gcodePre": [
"G21 ; set units to MM (required)",
"G90 ; absolute position mode (required)",
"G0 F2000 ; default rapid move speed",
"G1 F1000 ; default cutting speed"
],
"gcodePost": [
"M5 ; spindle off",
"G0 F4000 ; default rapid move speed",
"G1 F1000 ; default cutting speed",
"M30 ; program end"
],
"gcodeDwell": [
"G4 P{time} ; dwell for {time}ms"
],
"gcodeSpindle": [
"M3 S{spindle}",
"G4 P4000"
],
"gcodeChange": [
"M6 T{tool} ; change tool to '{tool_name}'"
],
"gcodeFExt": "nc",
"gcodeSpace": true,
"gcodeStrip": false,
"gcodeResetA": ["G92.4 A0 R0"],
"new": false,
"deviceName": "Makera Carvera Air",
"maxHeight": 130,
"useLaser": true,
"useIndexed": true,
"imageURL": "",
"bedBelt": false,
"bedRound": false,
"fwRetract": false,
"profiles": [
{
"processName": "default",
"camLevelTool": 1000,
"camLevelSpindle": 1000,
"camLevelOver": 0,
"camLevelSpeed": 1000,
"camLevelDown": 0,
"camRoughTool": 1666284436865,
"camRoughSpindle": 20000,
"camRoughDown": 3,
"camRoughOver": 0.25,
"camRoughSpeed": 1000,
"camRoughPlunge": 250,
"camRoughStock": 0,
"camRoughVoid": false,
"camRoughFlat": false,
"camRoughTop": false,
"camRoughIn": false,
"camRoughOn": true,
"camOutlineTool": 1665437735549,
"camOutlineSpindle": 0,
"camOutlineDown": 1,
"camOutlineOver": 0.4,
"camOutlineSpeed": 2000,
"camOutlinePlunge": 500,
"camOutlineWide": false,
"camOutlineDogbone": false,
"camOutlineOmitThru": false,
"camOutlineOut": false,
"camOutlineIn": false,
"camOutlineOn": true,
"camContourTool": 1666284436865,
"camContourSpindle": 20000,
"camContourOver": 0.15,
"camContourSpeed": 1500,
"camContourAngle": 85,
"camContourCurves": true,
"camContourIn": false,
"camContourXOn": true,
"camContourYOn": true,
"camTraceTool": 1002,
"camTraceSpindle": 1000,
"camTraceType": "clear",
"camTraceOver": 0.5,
"camTraceDown": 1,
"camTraceSpeed": 750,
"camTracePlunge": 200,
"camTraceLines": false,
"camDrillTool": 1000,
"camDrillSpindle": 1000,
"camDrillDownSpeed": 250,
"camDrillDown": 2,
"camDrillDwell": 250,
"camDrillLift": 2,
"camDrillingOn": false,
"camRegisterSpeed": 1000,
"camFlipAxis": "X",
"camFlipOther": "",
"camTabsWidth": 20,
"camTabsHeight": 5,
"camTabsDepth": 10,
"camTabsMidline": false,
"camDepthFirst": true,
"camEaseDown": false,
"camOriginTop": true,
"camZAnchor": "middle",
"camZOffset": 0,
"camZBottom": 0,
"camZClearance": 1,
"camZThru": 0,
"camFastFeed": 3000,
"camFastFeedZ": 500,
"camTolerance": 0,
"camStockX": 0,
"camStockY": 0,
"camStockZ": 0,
"camStockOffset": true,
"camStockClipTo": false,
"camStockOn": true,
"camConventional": false,
"camOriginCenter": false,
"outputInvertX": false,
"outputInvertY": false,
"camExpertFast": false,
"ops": [],
"op2": [
{
"type": "flip",
"axis": "X",
"invert": true,
"disabled": false
}
],
"camTrueShadow": false,
"camDrillMark": true,
"camPocketSpindle": 10000,
"camPocketTool": 1001,
"camPocketOver": 0.25,
"camPocketDown": 5,
"camPocketSpeed": 2000,
"camPocketPlunge": 500,
"camPocketExpand": 0,
"camContourBottom": false,
"camTraceBottom": false,
"cmaPocketOutline": false,
"camRegisterThru": 5,
"camFlatness": 0.001,
"camContourBridge": 10,
"camForceZMax": false,
"camPocketSmooth": 1,
"camPocketContour": true,
"cmaPocketRefine": 20,
"camPocketEngrave": false,
"camLaserEnable": [
"M321"
],
"camLaserDisable": "M322",
"camLaserOn": [
"M3"
],
"camLaserOff": [
"M5"
],
"camLaserSpeed": 100,
"camLaserPower": 1,
"camLaserAdaptive": true,
"camLaserAdaptMod": true,
"camLaserFlatten": false,
"camLaserFlatZ": 0,
"camLaserPowerMin": 1,
"camLaserPowerMax": 0.1,
"camLaserZMin": 0,
"camLaserZMax": 0,
"camOutlineTop": false,
"X": "X"
}
]
}

View file

@ -32,7 +32,7 @@
"gcodeStrip": false,
"gcodeResetA": ["G92.4 A0 R0"],
"new": false,
"deviceName": "My Makera Carvera",
"deviceName": "Makera Carvera",
"maxHeight": 150,
"useLaser": true,
"useIndexed": true,

View file

@ -36,7 +36,7 @@ export function animate_clear(api) {
}
export function animate(api, delay) {
let alert = api.alerts.show("building animation");
api.show.busy("building animation");
let settings = api.conf.get();
client.animate_setup(settings, data => {
checkMeshCommands(data);
@ -80,7 +80,7 @@ export function animate(api, delay) {
button.pause.style.display = 'none';
api.event.emit('animate', 'CAM');
api.alerts.hide(alert);
api.show.busy(false);
space.platform.showGridBelow(false);
toggleTrans(0,api.local.getBoolean('cam.anim.trans', true));
toggleModel(0,api.local.getBoolean('cam.anim.model', false));

View file

@ -35,7 +35,7 @@ export function animate_clear2(api) {
}
export function animate2(api, delay) {
let alert = api.alerts.show("building animation");
api.show.busy("building animation");
let settings = api.conf.get();
dark = settings.controller.dark;
manifold = settings.controller.manifold;
@ -92,7 +92,7 @@ export function animate2(api, delay) {
button.pause.style.display = 'none';
api.event.emit('animate', 'CAM');
api.alerts.hide(alert);
api.show.busy(false);
});
}

View file

@ -485,8 +485,10 @@ export function createPopOps() {
rate: 'camTraceSpeed',
plunge: 'camTracePlunge',
offover: 'camTraceOffOver',
offz: 'camTraceOffZ',
dogbone: 'camTraceDogbone',
revbone: 'camTraceDogbone',
ignore: 'camTraceIgnore',
merge: 'camTraceMerge',
ov_topz: 0,
ov_botz: 0,
@ -500,9 +502,11 @@ export function createPopOps() {
step: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.01, 1.0), show: (op) => env.popOp.trace.rec.mode === "clear" }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, units }),
offover: UC.newInput(LANG.cc_offd_s, { title: LANG.cc_offd_l, convert: toFloat, units, show: () => env.poppedRec.offset !== "none" || env.poppedRec.mode === "clear" }),
offz: UC.newInput(LANG.cc_offz_s, { title: LANG.cc_offz_l, convert: toFloat, units, show: () => env.poppedRec.mode === "follow" }),
sep: UC.newBlank({ class: "pop-sep", modes: MODES.CAM, xshow: zDogSep }),
thru: UC.newBoolean(LANG.cc_thru_s, undefined, { title: LANG.cc_thru_l }),
merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }),
ignore: UC.newBoolean(LANG.co_igno_s, undefined, { title: LANG.co_igno_l, show: () => env.poppedRec.mode === 'clear' }),
// merge: UC.newBoolean(LANG.co_merg_s, undefined, { title: LANG.co_merg_l, show: () => !env.popOp.trace.rec.down }),
dogbone: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: canDogBones }),
revbone: UC.newBoolean(LANG.co_dogr_s, undefined, { title: LANG.co_dogr_l, show: canDogBonesRev }),
exp: UC.newExpand("feeds & speeds", { }),
@ -774,6 +778,7 @@ export function createPopOps() {
revbones: 'camAreaRevbones',
ov_topz: 0,
ov_botz: 0,
finish_cut: 0,
}).inputs = {
mode: UC.newSelect(LANG.mo_menu, { post: opRender }, "opmode"),
tr_type: UC.newSelect(LANG.cc_offs_s, { title: LANG.cc_offs_l, show: isTrace }, "traceoff"),
@ -801,6 +806,7 @@ export function createPopOps() {
sr_angle: UC.newInput(LANG.ca_sang_s, { title: LANG.ca_sang_l, convert: toFloat, bound: UC.bound(0, 360), show: isSurfaceLinear }),
over: UC.newInput(LANG.cc_sovr_s, { title: LANG.cc_sovr_l, convert: toFloat, bound: UC.bound(0.001, 100.0), show: () => isClear() || isSurface() }),
down: UC.newInput(LANG.cc_sdwn_s, { title: LANG.cc_sdwn_l, convert: toFloat, bound: UC.bound(0, 100.0), units, show: () => isClear() || isTrace() }),
finish_cut: UC.newInput(LANG.ca_fini_s, { title: LANG.ca_fini_l, convert: toFloat, bound: UC.bound(0, 10.0), units, show: () => isClear() }), //todo: needs to check camInnerFirst
refine: UC.newInput(LANG.cp_refi_s, { title: LANG.cp_refi_l, convert: toInt, show: isSurface }),
sr_alter: UC.newBoolean(LANG.ca_altr_s, undefined, { title: LANG.ca_altr_l, show: isSurfaceLinear }),
dogbones: UC.newBoolean(LANG.co_dogb_s, undefined, { title: LANG.co_dogb_l, show: isTrace }),

View file

@ -30,6 +30,7 @@ function zAnchorSave() {
api.platform.update_top_z();
}
const hideable = true;
const bottom = true;
const top = true;
@ -118,7 +119,7 @@ export function menu() {
/** Left Side Menu */
_____: newGroup(LANG.ct_menu, $('cam-tabs'), { modes:CAM, marker:true, driven, separator }),
_____: newGroup(LANG.ct_menu, $('cam-tabs'), { modes:CAM, marker:true, driven, separator, hideable, group:"cam-tabs" }),
camTabsWidth: newInput(LANG.ct_wdth_s, {title:LANG.ct_wdth_l, convert:toFloat, bound:bound(0.005,100), units}),
camTabsHeight: newInput(LANG.ct_hght_s, {title:LANG.ct_hght_l, convert:toFloat, bound:bound(0.005,100), units}),
camTabsDepth: newInput(LANG.ct_dpth_s, {title:LANG.ct_dpth_l, convert:toFloat, bound:bound(0.005,100), units}),
@ -130,7 +131,7 @@ export function menu() {
(ui.tabDun = newButton(undefined, onButtonClick, {icon:'<i class="fas fa-check"></i>'})),
(ui.tabClr = newButton(undefined, onButtonClick, {icon:'<i class="fas fa-trash-alt"></i>'}))
], {class:"ext-buttons f-row"}),
_____: newGroup(LANG.cs_menu, $('cam-stock'), { modes:CAM, driven, separator }),
_____: newGroup(LANG.cs_menu, $('cam-stock'), { modes:CAM, driven, separator, hideable, group:"cam-stock" }),
camStockX: newInput(LANG.cs_wdth_s, {title:LANG.cs_wdth_l, convert:toFloat, bound:bound(0,9999), units}),
camStockY: newInput(LANG.cs_dpth_s, {title:LANG.cs_dpth_l, convert:toFloat, bound:bound(0,9999), units}),
camStockZ: newInput(LANG.cs_hght_s, {title:LANG.cs_hght_l, convert:toFloat, bound:bound(0,9999), units}),
@ -142,7 +143,7 @@ export function menu() {
// camStockManual: newRow([
// (ui.stockPlace = newButton('position', onButtonClick, { })),
// ], {class:"ext-buttons f-row"}),
_____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator }),
_____: newGroup(LANG.cc_menu, $('cam-limits'), { modes:CAM, driven, separator, hideable, group:"cam-limits" }),
camZAnchor: newSelect(LANG.ou_zanc_s, {title: LANG.ou_zanc_l, action:zAnchorSave, show:() => !ui.camStockIndexed.checked}, "zanchor"),
camZOffset: newInput(LANG.ou_ztof_s, {title:LANG.ou_ztof_l, convert:toFloat, units}),
camZTop: newInput(LANG.ou_ztop_s, {title:LANG.ou_ztop_l, convert:toFloat, units, trigger, selector, top }),
@ -151,7 +152,7 @@ export function menu() {
separator: newBlank({ class:"set-sep", driven }),
camFastFeed: newInput(LANG.cc_rapd_s, {title:LANG.cc_rapd_l, convert:toFloat, units}),
camFastFeedZ: newInput(LANG.cc_rzpd_s, {title:LANG.cc_rzpd_l, convert:toFloat, units}),
_____: newGroup(LANG.ou_menu, $('cam-output'), { modes:CAM, driven, separator, group:"cam-output" }),
_____: newGroup(LANG.ou_menu, $('cam-output'), { modes:CAM, driven, separator, hideable, group:"cam-output" }),
camEaseDown: newBoolean(LANG.cr_ease_s, onBooleanClick, {title:LANG.cr_ease_l}),
camDepthFirst: newBoolean(LANG.ou_depf_s, onBooleanClick, {title:LANG.ou_depf_l}),
camInnerFirst: newBoolean(LANG.ou_inrf_s, onBooleanClick, {title:LANG.ou_inrf_l}),
@ -162,7 +163,7 @@ export function menu() {
separator: newBlank({ class:"set-sep", driven }),
camEaseAngle: newInput(LANG.ou_eang_s, {title:LANG.ou_eang_l, convert:toFloat, bound:bound(0.1,85), show:() => ui.camEaseDown.checked}),
camFullEngage: newInput(LANG.ou_feng_s, {title:LANG.ou_feng_l, convert:toFloat, bound:bound(0.1,1.0)}),
_____: newGroup(LANG.or_menu, $('cam-origin'), { modes:CAM, driven, separator }),
_____: newGroup(LANG.or_menu, $('cam-origin'), { modes:CAM, driven, separator, hideable, group:"cam-origin" }),
camOriginTop: newBoolean(LANG.or_topp_s, onBooleanClick, {title:LANG.or_topp_l}),
camOriginCenter: newBoolean(LANG.or_cntr_s, onBooleanClick, {title:LANG.or_cntr_l}),
separator: newBlank({ class:"set-sep", driven }),
@ -174,7 +175,7 @@ export function menu() {
newButton("select", originSelect),
newButton("reset", originReset),
], { class: "ext-buttons f-row" }),
_____: newGroup(LANG.op_xprt_s, $('cam-expert'), { group:"cam_expert", modes:CAM, marker: false, driven, separator }),
_____: newGroup(LANG.op_xprt_s, $('cam-expert'), { group:"cam_expert", modes:CAM, marker: false, driven, separator, hideable }),
camArcEnabled: newBoolean(LANG.cx_arce_s, onBooleanClick, { title:LANG.cx_arce_l }),
camArcTolerance: newInput(LANG.cx_arct_s, {title:LANG.cx_arct_l, convert:toFloat, bound:bound(0,100), units, trigger, show:() => ui.camArcEnabled.checked}),
camArcResolution: newInput(LANG.cx_arcr_s, {title:LANG.cx_arcr_l, convert:toFloat, bound:bound(0,180), trigger, show:() => ui.camArcEnabled.checked}),

View file

@ -339,7 +339,7 @@ export function opRender() {
`<div id="${mark + i}" class="${clazz.join(' ')}"${title}>`,
`<label class="label">${label}</label>`,
clock ? '' :
`<label id="${mark + i}-x" class="del"><i class="fa fa-trash"></i></label>`,
`<label id="${mark + i}-x" class="del"><i class="fa-solid fa-xmark"></i></label>`,
`</div>`
]);
bind[mark + i] = rec;
@ -421,9 +421,10 @@ export function opRender() {
const brect = ev.target.getBoundingClientRect();
const prect = parent.getBoundingClientRect();
const Prect = poprec.div.getBoundingClientRect();
const tdiff = prect.top - brect.top;
const botoff = innerHeight - (brect.top + Prect.height);
const offpx = -tdiff + (botoff < 0 ? botoff : -Prect.height/3);
const topmv = brect.top - prect.top - Math.min(50, Prect.height/5);
const topnu = prect.top + topmv;
const botof = innerHeight - (topnu + Prect.height);
const offpx = botof < 0 ? topmv + botof : topmv;
poprec.div.style.transform = `translateY(${offpx}px)`;
poprec.div.onmouseenter = () => { inside = true };
poprec.div.onmouseleave = onLeave;
@ -587,7 +588,7 @@ export function zPlaneSelect({ which, onselect }) {
}
clearPops();
zPlaneStart(which, value => {
onselect(parseFloat(value));
onselect(parseFloat(value) / api.view.unit_scale());
});
}

View file

@ -79,6 +79,8 @@ export function cam_export(print, online) {
time: 0
};
// console.log({ offset, origin, stock });
function section(section) {
append();
online({ section });

View file

@ -134,12 +134,15 @@ class OpArea extends CamOp {
polys = POLY.union(nupolys, 0.00001, true);
}
// filter out invalid polys
polys = polys.filter(p => p && p.length > 2);
// process each area separately
let proc = 0;
let pinc = 1 / polys.length;
for (let area of polys) {
let bounds = area.getBounds3D();
let ts_off = toolDiam / 2 - ts_eps + (op.leave_xy ?? 0);
let ts_off = toolDiam / 2 + (op.leave_xy ?? 0) + ts_eps;
let offopt = {
arc: 250,
join: roundSharps ? ClipperLib.JoinType.jtRound : undefined,
@ -175,7 +178,7 @@ class OpArea extends CamOp {
for (let z of zs) {
let slice = newLayer(z);
let layers = slice.output();
let shadow = await shadowAt(z);
let shadow = await shadowAt(z + 0.01);
let tool_shadow = [
...POLY.offset(shadow, [ ts_off ], { count: 1, z, ...offopt }),
...POLY.offset(shadow, [ -ts_off ], { count: 1, z, ...offopt }),
@ -188,8 +191,23 @@ class OpArea extends CamOp {
let outs = [];
let clip = [];
let firstOff = -(toolDiam / 2 + (op.leave_xy ?? 0));
// remove shadow from area
if (op.ignore) {
clip = [ area ];
} else {
POLY.subtract([ area ], shadow, clip, undefined, undefined, 0);
POLY.offset(clip, [ firstOff, -toolOver ], {
}
//generate offsets to use
let offsets = [ firstOff ];
//if we need a finish cut, add it
let finish_cut = op.finish_cut ?? 0;
if (finish_cut != 0) { //todo: this should check for camInnerFirst and warn if it is not true
offsets.push(-finish_cut);
}
//everything else uses the tool stepover
offsets.push(-toolOver);
//actually offset the walls inwards
POLY.offset(clip, offsets, {
count: op.walls ? 1 : (op.steps ?? 999), outs, flat: true, z: z - zMov, ...offopt
});
// if we see no offsets, re-check the mesh bottom Z then exit
@ -248,14 +266,15 @@ class OpArea extends CamOp {
progress(proc, 'clear');
} else
if (mode === 'trace') {
let { tr_over, tr_type } = op;
let { tr_over, tr_offz, tr_type } = op;
let zs = down ? base_util.lerp(zTop, op.thru ? zBottom : Math.max(zBottom, area.minZ()), down) : [ bounds.min.z ];
let zroc = 0;
let zinc = 1 / zs.length;
if (tr_offz) zs = zs.map(z => z - tr_offz);
for (let z of zs) {
let slice = newLayer(z);
let layers = slice.output();
let shadow = await shadowAt(z);
let shadow = op.base ? state.shadow.base : await shadowAt(z);
let outs = [];
if (tr_type === 'none') {
// todo: move this out of the zs loop and only setZ when needed
@ -462,7 +481,8 @@ class OpArea extends CamOp {
while (areas?.length) {
let min = {
dist: Infinity,
area: undefined
area: undefined,
point: undefined
};
for (let area of areas.filter(p => !p.used)) {
@ -477,16 +497,19 @@ class OpArea extends CamOp {
if (find.distance < min.dist) {
min.area = area;
min.dist = find.distance;
min.point = find.point
}
}
// if we have a next-closest top poly, pocket that
if (min.area) {
min.area.used = true;
printPoint = min.point;
pocket({
cutdir: op.ov_conv,
depthFirst: process.camDepthFirst && !op.drape,
depthFirst: process.camDepthFirst,
easeDown: op.down && process.easeDown ? op.down : 0,
outline: op.drape || op.mode === 'trace',
progress: (n,m) => progress(n/m, "area"),
slices: min.area.filter(slice => slice.camLines)
});

View file

@ -21,7 +21,7 @@ function createFilter(op, origin, axis) {
let index = 0;
const accept = [];
filter = function (slices) {
for (let slice of slices) {
for (let slice of slices.filter(s => s.camLines)) {
if (slice_fn && slice_fn(slice, index++)) {
accept.push(slice);
} else if (box) {

View file

@ -13,8 +13,7 @@ class OpDrill extends CamOp {
async slice(progress) {
let { op, state } = this;
let { settings, addSlices, widget, updateToolDiams } = state;
let { color } = state;
let { color, settings, addSlices, widget, updateToolDiams, zBottom } = state;
let { drills } = op
let drillTool = new Tool(settings, op.tool),
@ -39,6 +38,10 @@ class OpDrill extends CamOp {
}
drill.zBottom = drill.z - drill.depth;
// honor zBottom when set
if (zBottom) drill.zBottom = Math.max(zBottom, drill.zBottom);
// for thru holes, follow z thru when set
if ((op.thru > 0)) {
drill.zBottom -= op.thru;

View file

@ -27,6 +27,9 @@ class OpLevel extends CamOp {
let zBot = zTop - down;
let zList = stepz && down ? util.lerp(zTop, zBot, stepz) : [ zBot ];
// ensure zList is descending
zList.sort((a,b) => b - a);
if (share.ran) {
console.log('skip');
this.skip = true;
@ -49,6 +52,7 @@ class OpLevel extends CamOp {
POLY.fillArea(clear, 1090, stepOver, points);
let layers = this.layers = [];
for (let z of zList) {
let lines = [];
layers.push(lines);

View file

@ -25,9 +25,11 @@ class OpOutline extends CamOp {
let areas = shadow.base.clone(true);
ops_list.push(new OpArea(state, {
areas: { [widget.id]: areas.map(p => p.toArray()) },
base: true,
direction,
dogbones,
down,
drape: true,
expand: 0,
mode: 'trace',
omitinner: omitvoid,

View file

@ -147,13 +147,13 @@ class OpRegister extends CamOp {
prepare(ops, progress) {
let { op } = this;
let { emitDrills, setDrill, setTool, setTravelBoundary } = ops;
let { emitDrills, emitTraces, setDrill, setTool, setTravelBoundary } = ops;
setTravelBoundary();
if (op.axis === '-' || op.axis === '=') {
setTool(op.tool, op.feed, op.rate);
for (let slice of this.sliceOut) {
ops.emitTrace(slice);
emitTraces(slice.camLines);
}
} else {
setTool(op.tool, undefined, op.rate);

View file

@ -83,6 +83,10 @@ class OpRough extends CamOp {
// outside only if we're not clearing all of stock
if (cutOutside && !op.all) {
if (op.leave) {
// recompute area with offset when provided
areas = POLY.flatten(POLY.expand(shadowBase, tool.fluteDiameter() / 2 - 0.001 + op.leave));
}
ops_list.push(new OpArea(state, {
rename: op.rename ?? "cutout",
spindle: op.spindle,

View file

@ -12,7 +12,7 @@ class OpTrace extends CamOp {
async slice(progress) {
let { op, state } = this;
let { areas, direction, down, expand, follow, offover, offset, outline, mode, ov_botz, ov_topz } = op;
let { areas, direction, down, expand, follow, offover, offset, offz, outline, ignore, mode, ov_botz, ov_topz } = op;
let { plunge, rate, refine, smooth, spindle, step, steps, thru, tolerance, tool } = op;
let trace = {
areas,
@ -21,6 +21,7 @@ class OpTrace extends CamOp {
down,
expand,
follow,
ignore,
mode: mode === 'clear' ? 'clear' : 'trace',
outline,
ov_botz,
@ -39,6 +40,7 @@ class OpTrace extends CamOp {
tool,
thru,
tr_over: offover,
tr_offz: offz,
tr_type: offset
};
this.op_trace = new OpArea(state, trace);

View file

@ -87,7 +87,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
{ alignTop } = settings.controller,
{ camArcEnabled, camArcResolution, camArcTolerance } = process,
{ camDepthFirst, camEaseAngle, camEaseDown } = process,
{ camFastFeed, camFastFeedZ } = process,
{ camFastFeed, camFastFeedZ, camZTop } = process,
{ camStockX, camStockY, camStockZ, camStockIndexed, camStockOffset } = process,
{ camForceZMax, camFullEngage, camInnerFirst, camOriginCenter } = process,
{ camOriginOffX, camOriginOffY, camOriginOffZ, camZClearance } = process,
@ -110,7 +110,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
wmx = wmpos.x,
wmy = wmpos.y,
wmz = !camStockIndexed ? stock.z - boundsZ : alignTop ? 0 : 0,
zSafe = camStockIndexed ? Math.hypot(stock.y, stock.z) / 2 + camZClearance : stockZClear,
zSafe = Math.max(camZTop, camStockIndexed ? Math.hypot(stock.y, stock.z) / 2 + camZClearance : stockZClear),
originx = (camOriginCenter ? 0 : -stock.x / 2) + (camOriginOffX || 0),
originy = (camOriginCenter ? 0 : -stock.y / 2) + (camOriginOffY || 0),
origin = newPoint(originx, originy, zSafe),
@ -138,6 +138,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
toolType,
toolDiam,
toolDiamMove,
toolDiamEpsilon,
travelBounds,
spindle = 0,
spindleMax = device.spindleMax,
@ -194,6 +195,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
}
function setSpindle(speed) {
// console.trace({ setSpindle: speed });
spindle = Math.min(speed, spindleMax);
}
@ -211,6 +213,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
toolType = tool.getType();
toolDiam = tool.fluteDiameter();
toolDiamMove = (tool.hasTaper() ? tolerance ?? toolDiam : toolDiam) * 2;
toolDiamEpsilon = toolDiam * 0.01,
lastTool = toolID;
}
feedRate = Math.min(camFastFeed, feed || feedRate || plunge);
@ -387,7 +390,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
/**
* when moving between contour endpoints, check if we can
* instead route around the bounding area of the contour
* whih we call the coastline.
* which we call the coastline.
*/
function coastlineMove(point) {
let from = toWidgetCoords(printPoint);
@ -395,6 +398,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
if (!coastline || from.distTo2D(to) < 0.01) {
return false;
}
let minz = Math.min(from.z, to.z);
let start = { dist: 1, poly: 0, pt: from };
let end = { dist: 1, poly: 1, pt: to };
for (let poly of coastline) {
@ -445,7 +449,9 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
}
}
for (let i=sp, d=0; d < dist; i += dir, d++) {
layerPush(toWorkCoords(points[i % pl]), 1, 0, tool);
let cp = points[i % pl].clone();
cp.z = Math.max(minz, cp.z);
layerPush(toWorkCoords(cp), 1, 0, tool);
}
return true;
}
@ -567,7 +573,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
} else
// otherwise move over before descending
if (deltaZ <= -tolerance) {
if (debug) console.log('over before descend');
if (debug) console.log('over before descend', deltaZ, -tolerance);
layerPush(point.clone().setZ(printPoint.z), 0, 0, tool);
newLayer();
}
@ -590,9 +596,11 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
if (lastTravelBounds) check.push(...lastTravelBounds);
let from = toWidgetCoords(printPoint);
let to = toWidgetCoords(point);
let ep = toolDiamEpsilon;
for (let poly of check) {
let ints = poly.intersections(from, to);
if (ints.length) {
let ints = poly.intersections(from, to) ?? [];
let far = ints.filter(p => p.distTo2D(to) > ep && p.distTo2D(from) > ep);
if (far.length) {
if (debug) console.log({ ints, poly, deltaXY, deltaZ });
upAndOver = "bounds";
break;
@ -636,7 +644,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
// plunge safety catch
if (deltaZ < 0 && !contouring) {
if (debug) console.log('deltaZ snap', rate, plungeRate);
if (debug) console.log('plunge safety', deltaZ, rate, plungeRate);
emit = 1;
rate = plungeRate;
}
@ -667,16 +675,21 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
* @param {boolean} cutdir true=CW false=CCW
* @param {boolean} depthFirst prioritize cut depth in pockets by nesting
*/
function pocket({ slices, cutdir, depthFirst, progress }) {
function pocket({ slices, cutdir, depthFirst, outline, progress }) {
let total = 0;
let depthData = [];
for (let slice of slices) {
let polys = [], t = [], c = [];
// use shadow + tool radius offset when available (roughing)
// collect polys in to tops (parents) and children
// so we can have the windings be opposite
POLY.flatten(slice.camLines).forEach((poly) => {
// poly is child if has parent
let child = poly.parent;
// for depth, collapse parent to 1 or 0 (has, missing)
if (depthFirst) { poly = poly.clone(); poly.parent = child ? 1 : 0 }
// place poly into top or child bucket
if (child) c.push(poly); else t.push(poly);
polys.push(poly);
});
@ -687,6 +700,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
POLY.setWinding(c, !cutdir);
if (depthFirst) {
// re-nest layer polys and add to depth stack
polys = POLY.nest(polys,true,true);
polys.tool_shadow = POLY.flatten(slice.tool_shadow.clone(true));
depthData.push(polys);
@ -706,18 +720,18 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
if (depthFirst) {
for (let i=0; i<depthData.length; i++) {
descend(depthData.slice(i));
descend(depthData.slice(i), undefined, outline);
}
}
}
function descend(stack, inside) {
function descend(stack, inside, outline) {
if (stack.length === 0) return;
let tops = stack[0];
let flat = tops.filter(poly => !poly.marked);
let flat = (outline ? POLY.flatten(tops) : tops).filter(poly => !poly.marked);
if (flat.length === 0) return;
if (inside) {
flat = flat.filter(p => p.isNested(inside));
flat = flat.filter(p => p.isInside(inside));
}
for (;;) {
@ -736,7 +750,13 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
polyEmit(poly, CLOSEST_TO_PP, engage);
engage = false;
}
descend(stack.slice(1), poly);
if (outline) {
output.forEach(poly => {
descend(stack.slice(1), poly, outline);
});
} else {
descend(stack.slice(1), poly, outline);
}
} else {
return;
}
@ -826,20 +846,31 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
// calculate ease down for poly path output
if (startPoint.z > point0.z) {
let easeFeed = plungeRate + ((feedRate - plungeRate) * easeThrottle);
let easeMax = feedRate * camFullEngage;
let easeLerp = plungeRate + ((feedRate - plungeRate) * easeThrottle);
let easeFeed = Math.min(easeLerp, easeMax);
let zat = startPoint.z;
let lp;
for (let i=0; ; i++) {
let ii = i % points.length;
let len = points.length;
let lp, lz = Infinity;
// hard cap on number of repeats to catch bad geometry
for (let i=0; i<len*50 ; i++) {
let ii = i % len;
let pt = points[ii];
if (zat <= pt.z) {
// rotate points to start at end of ease
points = [...points.slice(i), ...points.slice(0,i)];
points = [...points.slice(ii), ...points.slice(0,ii)];
break;
}
if (i > 0) {
let dd = lp.distTo2D(pt);
zat = Math.max(pt.z, zat - (dd * easeDzPerMm));
if (zat > lz) {
// rotate points to start at end of ease
// also should never get here unless bad geometry
points = [...points.slice(ii), ...points.slice(0,ii)];
break;
}
lz = zat;
}
lp = pt.clone().setZ(Math.max(pt.z, zat));
camOut(lp, 1, { feed: easeFeed });
@ -950,7 +981,7 @@ export async function prepare_one(widget, settings, print, firstPoint, update) {
// console.log('coming from another widget', { printPoint });
} else if (center) {
// we're the first widget output. offset is center
printPoint = origin.clone().move({ x: center.x, y: center.y });
printPoint = origin.clone().move({ x: center.x, y: center.y, z: 0 });
// console.log('first widget output', { printPoint });
} else {
console.log({ missing_center_using_origin: origin });

View file

@ -141,7 +141,7 @@ export async function cam_slice(settings, widget, onupdate, ondone) {
bottom_stock, bottom_part, bottom_gap, bottom_z,
}, 3);
// console.log({ bounds, stock, track, workarea });
// console.log({ bounds, stock, track, workarea, camZBottom });
return structuredClone(workarea);
};

View file

@ -51,18 +51,20 @@ export class Topo {
stepsY = Math.ceil(boundsY / resolution),
widtopo = widget.topo,
topoCache = widtopo
&& widtopo.resolution === resolution
&& widtopo.tolerance === tolerance
&& widtopo.diameter === toolDiameter
? widtopo : undefined,
topo = widget.topo = topoCache || {
axis,
data: new Float32Array(new SharedArrayBuffer(stepsX * stepsY * 4)),
stepsX: stepsX,
stepsY: stepsY,
bounds: bounds,
stepsX,
stepsY,
bounds,
diameter: toolDiameter,
resolution: resolution,
resolution,
tolerance,
profile: toolOffset,
widget: widget,
widget,
raster: true,
slices: null
},
@ -120,14 +122,15 @@ export class Topo {
}
let toolData = { positions: toolPos, bounds: toolBounds };
const vertices = widget.getGeoVertices({ unroll: true, translate: true });
const wbounds = widget.getBoundingBox();
let vertices = widget.getGeoVertices({ unroll: true, translate: true });
let wbounds = widget.getBoundingBox();
if (!inside) {
wbounds.expandByVector({ x: toolDiameter/2 + resolution, y: toolDiameter/2 + resolution, z: 0 });
}
// swap XY vertices (unswap later after polylines generated)
if (contourY) {
vertices = vertices.slice();
for (let i=0; i<vertices.length; i+= 3) {
let tmp = vertices[i+1];
vertices[i+1] = vertices[i+0];
@ -796,7 +799,7 @@ export class Trace {
ok++;
}
}
return ok === clips.length;
return ok > 0;
}
}

View file

@ -121,12 +121,13 @@ export function menu() {
separator: newBlank({ class:"set-sep", driven }),
sliceSupportAngle: newInput(LANG.sp_angl_s, {title:LANG.sp_angl_l, convert:toFloat, bound:bound(0.0,90.0)}),
sliceSupportDensity: newInput(LANG.sp_dens_s, {title:LANG.sp_dens_l, convert:toFloat, bound:bound(0.0,1.0)}),
sliceSupportGap: newInput(LANG.sp_gaps_s, {title:LANG.sp_gaps_l, convert:toInt, bound:bound(0,5)}),
sliceSupportOffset: newInput(LANG.sp_offs_s, {title:LANG.sp_offs_l, convert:toFloat, bound:bound(0.0,200.0)}),
sliceSupportExtra: newInput(LANG.sp_xpnd_s, {title:LANG.sp_xpnd_l, convert:toFloat, bound:bound(0.0,10.0)}),
sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }),
// sliceSupportSpan: newInput(LANG.sp_span_s, {title:LANG.sp_span_l, convert:toFloat, bound:bound(0.0,200.0), show:() => ui.sliceSupportEnable.checked }),
separator: newBlank({ class:"set-sep", driven }),
sliceSupportOutline: newBoolean(LANG.sp_outl_s, onBooleanClick, {title:LANG.sp_outl_l, xshow: () => !isTree() }),
sliceSupportGap: newBoolean(LANG.sp_gaps_s, onBooleanClick, {title:LANG.sp_gaps_l }),
sliceSupportTree: newBoolean(LANG.sp_tree_s, onBooleanClick, {title:LANG.sp_tree_l }),
separator: newBlank({ class:"set-sep", driven, show:manualSupport }),
sliceSupportManual: newRow([
(ui.ssmAdd = newButton(undefined, onButtonClick, {icon:'<i class="fas fa-plus"></i>'})),

View file

@ -380,6 +380,8 @@ function supportDone() {
// Use popVisualState to restore original material
w.popVisualState('paint');
});
api.conf.save();
api.space.save();
}
// manual supports clear

View file

@ -577,10 +577,12 @@ export function fdm_export(print, online, ondone, ondebug) {
lastFanSpeed = fanSpeed;
}
if (bedTemp !== lastBedTemp) {
// console.log({ bed_temp_change_to: bedTemp, from: lastBedTemp });
append(`M140 S${bedTemp} T0`);
lastBedTemp = bedTemp;
}
if (nozzleTemp !== lastNozzleTemp) {
// console.log({ temp_change_to: nozzleTemp, from: lastNozzleTemp });
if (t0) append(`M104 S${nozzleTemp} T0`);
if (t1) append(`M104 S${nozzleTemp} T1`);
if (!(t0 || t1)) append(`M104 S${nozzleTemp} T${tool}`);

View file

@ -173,6 +173,29 @@ function vopt(opt, ctx) {
return opt;
}
/**
* return percentage values broken into ranges
*
* @param {number} plo 0.0-1.0 percentage value
* @param {number} phi 0.0-1.0 high percentage value
* @param {Array} pcts [{ lo, hi }, ...]
*/
function divide(plo, phi, pcts) {
let sum = 0;
let lo = plo;
let rval = pcts.map(pct => {
sum += pct;
let diff = (phi - plo) * pct;
let rval = { lo, hi: lo + diff };
lo += diff;
return rval;
});
if (Math.abs(1 - sum) > 0.001) {
console.log('SUM FAIL', { rval, sum });
}
return rval;
}
/**
* DRIVER SLICE CONTRACT
*
@ -295,6 +318,10 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}
}
if (isConcurrent) {
minions.setPoints(points);
}
// create Slice objects for specified list of Z heights
// zGen() produces the list (or empty for slicer auto-detected)
slice(points, {
@ -303,6 +330,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
xray: process.xray,
zMin: bounds.min.z,
zMax: bounds.max.z - zCut,
bucketMin: minions.concurrent * 5,
union: controller.healMesh,
indices: process.indices || process.xray,
useAssembly,
@ -328,6 +356,9 @@ export function sliceOne(settings, widget, onupdate, ondone) {
})
.then(decodeSlices)
.then(processSlices)
.then(() => {
minions.setPoints([]);
})
.then(ondone);
// z index generator (bottom up)
@ -441,17 +472,18 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}).filter(s => s);
}
// slicing is the first 50% of the update "time"
function trackupdate(pct, from, to, msg) {
// console.log(from.round(2), to.round(2), msg);
onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg);
}
// calculate % complete and call onupdate()
function doupdate(index, from, to, msg) {
trackupdate(index / slices.length, from, to, msg);
}
// slicing is the first 50% of the update "time"
function trackupdate(pct, from, to, msg) {
onupdate(0.5 + (from + (pct * (to - from))) * 0.5, msg);
}
// for each slice, performe a function and call doupdate()
// for each slice, perform a function and call doupdate()
function forSlices(from, to, fn, msg) {
slices.forEach(slice => {
fn(slice);
@ -462,11 +494,12 @@ export function sliceOne(settings, widget, onupdate, ondone) {
/**
* Process automatic and manual shadow-based support generation
*/
async function processSupports() {
async function processSupports(plo, phi) {
if (process.sliceSupportType === 'disabled') {
return;
}
let div = divide(plo, phi, [ 0.5, 0.5 ]);
let stack = slices.slice();
let indices = stack.map(s => s.z);
let zAngNorm = Math.sin(process.sliceSupportAngle * Math.PI / 180);
@ -476,11 +509,32 @@ export function sliceOne(settings, widget, onupdate, ondone) {
// since that is done later and clipped to slice.clips
stack.sort((a,b) => a.z - b.z);
// automatic supports
if (!manual) {
// find where shadow areas begin (async)
await widget.computeShadowStack(indices, progress => {
trackupdate(progress, div[0].lo, div[0].hi, "shadow");
}, zAngNorm, sliceHeight);
// assign to slices (sync)
for (let slice of stack) {
slice.shadow = await widget.shadowAt(slice.z, true);
if (!(slice.up && slice.shadow?.length)) continue;
// trim shadow to part overhangs
let top = slice.up.topPolys();
let bot = slice.topPolys();
let bridge = [];
POLY.subtract(top, bot, bridge, undefined, slice.z, 0, { wasm: true });
slice.shadow = POLY.trimTo(slice.shadow, bridge, { minArea: 0 });
}
}
// process manual supports if they exist
let { paint } = widget.anno;
let { belt } = widget;
// apply belt transformations, if needed
if (belt && paint) {
if (manual && belt && paint?.length) {
let { anchor, angle, dy, slope } = belt;
// make a copy we can modify
paint = structuredClone(paint);
@ -496,10 +550,14 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}
}
}
// convert paint points to circles on matching slices
if (manual && paint?.length) {
let hpi = Math.PI/2;
for (let slice of stack) {
if (!slice.up) {
continue;
}
let polys = [];
for (let rec of paint) {
let { point, radius } = rec;
@ -510,31 +568,34 @@ export function sliceOne(settings, widget, onupdate, ondone) {
polys.push(newPolygon().centerCircle(point, radius, 10));
}
}
slice.shadow = POLY.union(polys, 0, true);
}
}
// create automatic shadows supports when on manual paint
if (!manual) {
await widget.computeShadowStack(indices, progress => {
trackupdate(progress, 0.05, 0.10, "shadow");
}, zAngNorm);
for (let slice of stack) {
slice.shadow = await widget.shadowAt(slice.z, true);
// trim polys to part overhangs
let top = slice.up.topPolys();
let bot = slice.topPolys();
let bridge = [];
let propose = POLY.setZ(POLY.union(polys, 0, true), slice.z);
POLY.subtract(top, bot, bridge, undefined, slice.z, 0, { wasm: true });
propose = POLY.trimTo(propose, bridge, { minArea: 0 });
slice.shadow = propose;
// if (devel) slice.output().setLayer("over", 0x8844aa).addPolys(bridge);
}
}
// 1. accumulate / union shadow coverage top down
// 2. trim to area outside slice.clips
let minArea = lineWidth;
let minArea = lineWidth * lineWidth;
let shadowSum;
let length = stack.length;
let count = 0;
// convert shadows to trees, when specified
if (process.sliceSupportTree) {
// console.log('TREE OUTPUT');
}
// perform accumulation top down
for (let slice of stack.reverse()) {
for (let slice of stack.slice().reverse()) {
let shadow = slice.shadow ?? [];
if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow);
if (process.sliceSupportExtra) {
shadow = POLY.offset(shadow, process.sliceSupportExtra);
}
@ -542,14 +603,10 @@ export function sliceOne(settings, widget, onupdate, ondone) {
if (shadowSum) {
shadow = POLY.union([...shadow, ...shadowSum], minArea, true);
}
// subtract slice.clips areas (widget boundaries) from shadow projection
// subtract slice top areas (widget boundaries) from shadow projection
if (true) {
let rem = [];
let clips = [
slice.up?.clips,
slice.clips,
slice.down?.clips
].filter(v => v).flat();
let clips = [ slice.topPolys() ].filter(v => v).flat();
clips = POLY.union(clips, minArea, true);
POLY.subtract(shadow, clips, rem, null, slice.z, minArea, { wasm: false });
shadow = rem;
@ -570,11 +627,47 @@ export function sliceOne(settings, widget, onupdate, ondone) {
.centerRectangle(newPoint(0, 0, slice.z), boundsx, boundsy)
.move({ x: 0, y: -boundsy / 2 + skewy, z: 0 });
shadow = POLY.trimTo(shadow, [ clip ]);
if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]);
// if (devel) slice.output().setLayer("belt clip", 0xffff00).addPolys([ clip ]);
}
slice.supports = shadow;
if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow);
trackupdate((++count/length), 0.10, 0.15, "support");
// if (devel) slice.output().setLayer("shadow", 0xff0000).addPolys(shadow);
trackupdate((++count/length), div[1].lo, div[1].hi, "support");
}
// TODO layerDiff shadows to identify tops/bottoms of pillars
if (false) {
let ps = [];
for (let slice of stack.slice().reverse()) {
let { supports } = slice;
let supportsDown = slice.down ? slice.down.supports : [];
let bridges = [], flats = [];
slice.supportsDiff = { bridges, flats };
ps.push(self.kiri_worker.minions
.subtract({
a: supports, b: supportsDown,
outA: bridges, outB: flats,
area: 1, wasm: true, z: slice.z,
})
);
}
await Promise.all(ps);
console.log({ slices: slices.map(s => s.supportsDiff) });
}
// trim using support part offset value
let gaps = process.sliceSupportGap;
for (let slice of stack) {
let clips = [
slice.clips,
gaps ? slice.up?.clips : undefined,
gaps ? slice.down?.clips : undefined
].filter(v => v).flat();
if (clips.length) {
let rem = [];
clips = POLY.union(clips, minArea, true);
POLY.subtract(slice.supports, clips, rem, null, slice.z, minArea, { wasm: false });
slice.supports = rem;
}
}
}
@ -582,8 +675,8 @@ export function sliceOne(settings, widget, onupdate, ondone) {
* Process top and bottom layers or any other
* layers detected and marked for solid fill
*/
async function processSolidLayers() {
forSlices(0.15, 0.2, slice => {
async function processSolidLayers(plo, phi) {
forSlices(plo, phi, slice => {
let range = slice.params;
let isBottom = slice.index < bottomLayers;
let isTop = topLayers && slice.index > slices.length - topLayers - 1;
@ -604,20 +697,29 @@ export function sliceOne(settings, widget, onupdate, ondone) {
/**
* Process layer diffs and project solid areas
*/
async function processLayerDiffs() {
async function processLayerDiffs(plo, phi) {
let div = divide(plo, phi, [ 0.9, 0.05, 0.05 ]);
// boolean diff layers to detect bridges and flats
let promises = [];
profileStart("delta");
forSlices(0.2, 0.33, slice => {
forSlices(div[0].lo, div[1].hi, slice => {
let params = slice.params || process;
let solidMinArea = params.sliceSolidMinArea;
let sliceMinThick = params.sliceSolidMinThick;
let sliceFillGrow = params.sliceFillGrow;
layerDiff(slice, { area: solidMinArea, grow: sliceFillGrow, thick: sliceMinThick });
let p = layerDiff(slice, {
area: solidMinArea,
grow: sliceFillGrow,
thick: sliceMinThick,
async: true
});
promises.push(p);
}, "layer deltas");
await Promise.all(promises);
profileEnd();
// project bridges and flats up and down into part
profileStart("delta-project");
forSlices(0.33, 0.34, slice => {
forSlices(div[1].lo, div[1].hi, slice => {
let params = slice.params || process;
topLayers = params.sliceTopLayers || 0;
bottomLayers = params.sliceBottomLayers || 0;
@ -628,7 +730,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
profileEnd();
// union solid areas
profileStart("solid-union");
forSlices(0.34, 0.35, slice => {
forSlices(div[2].lo, div[2].hi, slice => {
if (slice.solids) {
slice.solids = POLY.union(slice.solids, 0, true);
}
@ -639,10 +741,11 @@ export function sliceOne(settings, widget, onupdate, ondone) {
/**
* Process solid fill patterns
*/
async function processSolidFills() {
async function processSolidFills(plo, phi) {
profileStart("solid-fill")
let promises = isConcurrent ? [] : undefined;
forSlices(0.35, promises ? 0.4 : 0.5, slice => {
let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]);
forSlices(div[0].lo, div[0].hi, slice => {
let params = slice.params || process;
let solidWidth = params.sliceFillWidth || 1;
let fillSpace = fillSpacing * solidWidth;
@ -654,7 +757,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
slices.last().finishSolids = true
if (promises) {
await tracker(promises, (i, t) => {
trackupdate(i / t, 0.4, 0.5);
trackupdate(i / t, div[1].lo, div[1].hi);
});
}
profileEnd();
@ -663,10 +766,11 @@ export function sliceOne(settings, widget, onupdate, ondone) {
/**
* Process sparse infill patterns
*/
async function processSparseInfill() {
async function processSparseInfill(plo, phi) {
let lastType;
let promises = isConcurrent ? [] : undefined;
forSlices(0.5, promises ? 0.55 : 0.7, slice => {
let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]);
forSlices(div[0].lo, div[0].hi, slice => {
let params = slice.params || process;
if (!params.sliceFillSparse) {
return;
@ -689,7 +793,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}, "infill");
if (promises) {
await tracker(promises, (i, t) => {
trackupdate(i / t, 0.55, 0.7);
trackupdate(i / t, div[1].lo, div[1].hi);
});
}
// filter out tiny fill points less than nozzle diameter
@ -715,10 +819,11 @@ export function sliceOne(settings, widget, onupdate, ondone) {
/**
* Process support structure fills
*/
async function processSupportFills() {
async function processSupportFills(plo, phi) {
profileStart("support-fill");
let promises = false && isConcurrent ? [] : undefined;
forSlices(0.8, promises ? 0.88 : 0.9, slice => {
let div = divide(plo, phi, promises ? [ 0.8, 0.2 ] : [ 1 ]);
forSlices(div[0].lo, div[0].hi, slice => {
let params = slice.params || process;
let density = params.sliceSupportDensity;
layerSupportFill({
@ -733,7 +838,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}, "support fill");
if (promises) {
await tracker(promises, (i, t) => {
trackupdate(i / t, 0.88, 0.9);
trackupdate(i / t, div[1].lo, div[1].hi);
});
}
profileEnd();
@ -788,8 +893,8 @@ export function sliceOne(settings, widget, onupdate, ondone) {
slices.forEach((s,i) => s.index = i);
}
async function renderSlices() {
forSlices(0.9, 1.0, slice => {
async function renderSlices(plo, phi) {
forSlices(plo, phi, slice => {
let params = slice.params || process;
layerRender(slice, params, {
dark: controller.dark,
@ -850,7 +955,7 @@ export function sliceOne(settings, widget, onupdate, ondone) {
}
// process solid layers (top/bottom)
await processSolidLayers();
await processSolidLayers(0.10, 0.20);
// add lead in anchor when specified in belt mode (but not for synths)
if (isBelt) {
@ -883,10 +988,6 @@ export function sliceOne(settings, widget, onupdate, ondone) {
});
}
// support generation using either
// enclosed shadow or manual painted supports
await processSupports();
// calculations only relevant when solid layers are used
// layer boolean diffs need to be computed to find flat areas to fill
// and overhangs that need to be supported. these are stored in flats
@ -894,24 +995,27 @@ export function sliceOne(settings, widget, onupdate, ondone) {
// for "real" objects, fill the remaining voids with sparse fill
// sparse layers only present when non-vase mode and sparse % > 0
if (!vaseMode) {
await processLayerDiffs();
await processSolidFills();
await processSparseInfill();
await processLayerDiffs(0.2, 0.4);
// support generation using either
// enclosed shadow or manual painted supports
await processSupports(0.4, 0.5);
await processSolidFills(0.5, 0.6);
await processSparseInfill(0.6, 0.8);
}
// fill all supports (auto and manual)
if (supportDensity) {
await processSupportFills();
await processSupportFills(0.8, 0.84);
}
// brick/interleave mode processing
if (isBrick) {
await processBrickMode();
await processBrickMode(0.84, 0.85);
}
// render if not explicitly disabled
if (render) {
await renderSlices();
await renderSlices(0.85, 1.0);
}
if (isBelt) {
@ -1086,6 +1190,7 @@ export function slicePost(settings, onupdate) {
// assign grid_id which can be embedded in gcode and
// used by the controller to cancel objects during print
let { bounds } = settings;
if (!bounds) return;
for (let widget of widgets) {
let { pos, box } = widget.track;
// calculate top/left coordinate for widget
@ -1375,19 +1480,36 @@ export function layerDiff(slice, options = {}) {
let newBridges = [];
let newFlats = [];
if (options.async) {
return self.kiri_worker.minions
.subtract({
a: topInner, b: downInner,
outA: newBridges, outB: newFlats,
area, wasm: true, z: slice.z,
})
.then(() => {
layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options });
});
} else {
POLY.subtract(topInner, downInner, newBridges, newFlats, slice.z, area, {
wasm: true
});
layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options });
}
}
function layerDiffDone({ slice, bridges, flats, newBridges, newFlats, options }) {
const { sla, grow, area, thick } = options;
// console.log(slice.z, { newBridges, newFlats });
newBridges = newBridges.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick);
newFlats = newFlats.filter(p => p.areaDeep() >= area && p.thickness(true) >= thick);
if (grow > 0 && newBridges.length) {
newBridges = POLY.offset(newBridges, grow);
newBridges = POLY.offset(newBridges, grow, { z: slice.z });
}
if (grow > 0 && newFlats.length) {
newFlats = POLY.offset(newFlats, grow);
newFlats = POLY.offset(newFlats, grow, { z: slice.z });
}
bridges.appendAll(newBridges);

View file

@ -19,13 +19,15 @@ class Engine {
filter: { FDM: "internal" },
device: conf.defaults.fdm.d, // device profile
process: conf.defaults.fdm.p, // slicing settings
widget: { [this.widget.id]: {} }
widget: { [this.widget.id]: {} },
time: Date.now()
};
this.listener = () => { };
try {
client.setWorkPath(workURL);
client.setPoolPath(poolURL);
client.restart();
client.pool.start();
} catch (error) {
console.log({ error });
}
@ -37,6 +39,7 @@ class Engine {
new load.STL().load(url, vertices => {
this.listener({ loaded: url, vertices });
this.widget.loadVertices(vertices).center();
this.setTopOffset(0);
accept(this);
});
} catch (error) {
@ -49,6 +52,10 @@ class Engine {
api.platform.clear();
}
workspace() {
return api.settings.export({ engine: this.settings });
}
parse(data) {
return new Promise((accept, reject) => {
try {
@ -63,11 +70,8 @@ class Engine {
}
setThreading(bool) {
if (bool) {
client.pool.start();
} else {
client.pool.stop();
}
console.log('setThreading() deprecated');
return this;
}
setListener(listener) {
@ -86,7 +90,15 @@ class Engine {
* @returns {Engine} this
*/
setMode(mode) {
this.settings.mode = mode;
let lmode = mode.toLowerCase();
Object.assign(this.settings, {
mode: mode,
controller: {},
render: false,
filter: { [mode]: "internal" },
device: conf.defaults[lmode].d,
process: conf.defaults[lmode].p,
});
return this;
}
@ -123,17 +135,24 @@ class Engine {
process.camStockX = stock.x;
process.camStockY = stock.y;
process.camStockZ = stock.z;
if (this.origin) settings.stock.center = origin;
settings.stock.center = {
x: stock.x / 2,
y: stock.y / 2,
z: stock.z / 2
};
return this;
}
setTopOffset(offset = 0) {
this.topOffset = offset;
let wbb = this.widget.getBoundingBox();
this.widget.setTopZ(wbb.max.z - offset);
return this;
}
setOrigin(x, y, z) {
this.origin = { x, y, z };
if (this.settings.stock) this.settings.stock.center = { x, y, z };
this.settings.origin = this.origin;
return this;
}
@ -158,11 +177,11 @@ class Engine {
}
slice() {
this.widget.setTopZ((this.settings?.stock?.z || 0) - (this.topOffset || 0));
return new Promise((accept, reject) => {
client.clear();
client.sync([this.widget]);
client.rotate(this.settings);
client.slicePre(this.settings, () => {});
client.slice(this.settings, this.widget, msg => {
this.listener({ slice: msg });
if (msg.error) {
@ -170,6 +189,7 @@ class Engine {
}
if (msg.done) {
accept(this);
client.slicePost(this.settings, () => {});
}
});
});

View file

@ -78,6 +78,20 @@ const funcs = self.minion = {
}
},
subtract(data, seq) {
let { arg, opt } = data;
let { area, wasm, z } = opt;
let a = codec.decode(arg.a);
let b = codec.decode(arg.b);
let outA = [], outB = [];
POLY.subtract(a, b, outA, outB, z, area, { wasm });
reply({
seq,
outA: codec.encode(outA),
outB: codec.encode(outB)
});
},
union(data, seq) {
if (!(data.polys && data.polys.length)) {
reply({ seq, union: codec.encode([]) });
@ -140,14 +154,11 @@ const funcs = self.minion = {
sliceZ(data, seq) {
debug('minion.sliceZ', { data, seq });
let { z, points, options } = data;
let i = 0, p = 0, realp = new Array(points.length / 3);
while (i < points.length) {
realp[p++] = newPoint(points[i++], points[i++], points[i++]).round(3);
}
let { z, options } = data;
let { points } = cache;
let state = { zero: [] };
let output = [];
sliceZ(z, realp, {
sliceZ(z, points, {
...options,
each(out) { output.push(out) }
}).then(() => {
@ -159,9 +170,17 @@ const funcs = self.minion = {
});
},
setPoints(data, seq) {
let { points } = data;
let i = 0, p = 0, realp = new Array(points.length / 3);
while (i < points.length) {
realp[p++] = newPoint(points[i++], points[i++], points[i++]).round(3);
}
cache.points = realp;
},
putCache(msg) {
const { key, data } = msg;
// log({ minion_putCache: key, data });
if (data) {
cache[key] = data;
} else {

View file

@ -39,7 +39,7 @@ let drivers = {
WJET
},
ccvalue = self.navigator ? self.navigator.hardwareConcurrency || 0 : 0,
concurrent = Math.min(4, self.Worker && ccvalue > 3 ? ccvalue - 1 : 0),
concurrent = Math.round(Math.max(4, self.Worker && ccvalue > 3 ? ccvalue * 0.75 : 0)),
current = {
print: null,
snap: null,
@ -101,6 +101,9 @@ function minhandler(msg) {
// for concurrent operations
const minwork = {
// core functions
get concurrent() {
return concurrent
},
@ -137,6 +140,76 @@ const minwork = {
minions.length = 0;
},
queue(work, ondone, direct) {
minionq.push({work, ondone, direct});
minwork.kick();
},
queueAsync(work, direct) {
return new Promise(resolve => {
minwork.queue(work, resolve, direct);
});
},
kick() {
if (minions.length && minionq.length) {
let qrec = minionq.shift();
let minion = minions.shift();
let seq = miniseq++;
qrec.work.seq = seq;
minifns[seq] = (data) => {
qrec.ondone(data);
minions.push(minion);
minwork.kick();
};
minion.postMessage(qrec.work, qrec.direct);
}
},
broadcast(cmd, data, direct) {
for (let minion of minions) {
minion.postMessage({
cmd, ...data
}, direct);
}
},
setPoints(points) {
let i = 0, floatP = new Float32Array(points.length * 3);
for (let p of points) {
floatP[i++] = p.x;
floatP[i++] = p.y;
floatP[i++] = p.z;
}
minwork.broadcast("setPoints", { points: floatP });
},
// added functions (should be namespaced)
subtract({ a, b, outA, outB, z, area, wasm }) {
return new Promise((resolve, reject) => {
if (concurrent < 2 || a.length + b.length < concurrent * 2 || POLY.points([...a,...b]) < concurrent * 50) {
POLY.subtract(a, b, outA, outB, z, area, { wasm });
resolve();
return;
}
minwork.queue({
cmd: "subtract",
opt: { area, wasm, z },
arg: {
a: codec.encode(a),
b: codec.encode(b),
outA: outA ? 1 : 0,
outB: outB ? 1 : 0,
}
}, result => {
if (outA) outA.push(...codec.decode(result.outA));
if (outB) outB.push(...codec.decode(result.outB));
resolve();
});
});
},
union(polys, minarea) {
return new Promise((resolve, reject) => {
if (concurrent < 2 || polys.length < concurrent * 2 || POLY.points(polys) < concurrent * 50) {
@ -222,17 +295,9 @@ const minwork = {
reject("concurrent slice unavaiable");
}
let { each } = options;
// todo use shared array buffer?
let i = 0, floatP = new Float32Array(points.length * 3);
for (let p of points) {
floatP[i++] = p.x;
floatP[i++] = p.y;
floatP[i++] = p.z;
}
minwork.queue({
cmd: "sliceZ",
z,
points: floatP,
options: codec.toCodable(options)
}, data => {
let recs = codec.decode(data.output);
@ -242,43 +307,9 @@ const minwork = {
}
}
resolve(recs);
}, [ floatP.buffer ]);
});
});
},
queue(work, ondone, direct) {
minionq.push({work, ondone, direct});
minwork.kick();
},
queueAsync(work, direct) {
return new Promise(resolve => {
minwork.queue(work, resolve, direct);
});
},
kick() {
if (minions.length && minionq.length) {
let qrec = minionq.shift();
let minion = minions.shift();
let seq = miniseq++;
qrec.work.seq = seq;
minifns[seq] = (data) => {
qrec.ondone(data);
minions.push(minion);
minwork.kick();
};
minion.postMessage(qrec.work, qrec.direct);
}
},
broadcast(cmd, data, direct) {
for (let minion of minions) {
minion.postMessage({
cmd, ...data
}, direct);
}
}
};
console.log(`kiri | init work | ${version || "rogue"}`);
@ -586,9 +617,9 @@ const dispatch = {
const { process } = settings;
const origin = settings.origin;
const offset = {
x: origin.x - (process.camOriginOffX ?? 0),
y: -origin.y - (process.camOriginOffY ?? 0),
z: origin.z - (process.camOriginOffZ ?? 0)
x: origin.x,// - (process.camOriginOffX ?? 0),
y: -origin.y,// - (process.camOriginOffY ?? 0),
z: origin.z,// + (process.camOriginOffZ ?? 0)
};
const device = settings.device;
const print = setPrint(newPrint(settings, Object.values(wcache)));

932
src/load/dxf.js Normal file
View file

@ -0,0 +1,932 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
import { newPolygon } from '../geo/polygon.js';
import { newPoint } from '../geo/point.js';
import { polygons } from '../geo/polygons.js';
export function parseAsync(text, opt) {
return new Promise((resolve, reject) => {
try {
resolve(parse(text, opt));
} catch (e) {
reject(e);
}
});
}
export function parse(text, opt = { }) {
const justPoly = opt.flat || false;
const fromSoup = opt.soup !== false || justPoly;
const depth = parseFloat(opt.depth || 5);
const segmentSize = parseFloat(opt.segmentSize || 1); // default 1mm segments
const minSegments = parseInt(opt.minSegments || 4); // minimum segments for very small arcs
const objs = [];
const polys = [];
// Parse DXF file - normalize line endings and split
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').map(l => l.trim());
// Parse header for units (can be overridden by user)
const fileUnits = extractUnits(lines);
// Use file units if "auto" or not specified, otherwise use user's choice
const inputUnits = (!opt.units || opt.units === 'auto') ? fileUnits : opt.units;
const scale = getScaleToMM(inputUnits); // convert to mm (Kiri:Moto's internal unit)
const entities = extractEntities(lines);
// Scale all entities to mm BEFORE stitching
scaleEntities(entities, scale);
// Stitch together open paths that share endpoints (tolerance in mm now)
const tolerance = Math.max(0.001, segmentSize * 0.001); // 0.1% of segment size, min 0.001mm
const stitchedEntities = stitchPaths(entities, tolerance);
// Convert entities to polygons (no scaling needed, already in mm)
for (let entity of stitchedEntities) {
if (entity.type === 'STITCHED') {
// Convert stitched path parts into a single polyline
const points = [];
for (const part of entity.parts) {
const partPoints = convertEntityToPoints(part, segmentSize, minSegments);
if (partPoints.length > 0) {
if (points.length === 0) {
points.push(...partPoints);
} else {
// Skip first point if it's the same as our last point (avoid duplicates)
points.push(...partPoints.slice(1));
}
}
}
if (points.length < 2) continue;
let poly = newPolygon().addPoints(
points.map(p => newPoint(p.x, p.y, p.z || 0))
).clean();
if (entity.closed && poly.appearsClosed()) {
poly.points.pop();
} else if (!entity.closed) {
poly.setOpen(true);
}
polys.push(poly);
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
if (entity.points.length < 2) {
continue;
}
let poly = newPolygon().addPoints(
entity.points.map(p => newPoint(p.x, p.y, p.z || 0))
).clean();
// Check if closed
if (entity.closed && poly.appearsClosed()) {
poly.points.pop();
} else if (!entity.closed) {
poly.setOpen(true);
}
polys.push(poly);
} else if (entity.type === 'LINE') {
// Convert line to polyline
let poly = newPolygon().addPoints([
newPoint(entity.start.x, entity.start.y, entity.start.z || 0),
newPoint(entity.end.x, entity.end.y, entity.end.z || 0)
]);
poly.setOpen(true);
polys.push(poly);
} else if (entity.type === 'CIRCLE') {
// Convert circle to polygon with points
// Calculate segments based on circumference and desired segment size (already in mm)
const circumference = 2 * Math.PI * entity.radius;
const segments = Math.max(minSegments, Math.ceil(circumference / segmentSize));
let points = [];
for (let i = 0; i < segments; i++) {
const angle = (i / segments) * Math.PI * 2;
points.push(newPoint(
entity.center.x + Math.cos(angle) * entity.radius,
entity.center.y + Math.sin(angle) * entity.radius,
entity.center.z || 0
));
}
let poly = newPolygon().addPoints(points).clean();
polys.push(poly);
} else if (entity.type === 'ARC') {
// Convert arc to polyline
// DXF arcs always go counterclockwise. Handle angle wrapping.
let startAngle = entity.startAngle;
let endAngle = entity.endAngle;
let angleDiff = endAngle - startAngle;
// If endAngle < startAngle, arc wraps through 0/360
if (angleDiff < 0) {
angleDiff += Math.PI * 2;
}
const arcLength = angleDiff * entity.radius;
const segments = Math.max(minSegments, Math.ceil(arcLength / segmentSize));
let points = [];
if (entity.reversed) {
// Sample backwards from end to start
for (let i = 0; i <= segments; i++) {
const angle = endAngle - (i / segments) * angleDiff;
points.push(newPoint(
entity.center.x + Math.cos(angle) * entity.radius,
entity.center.y + Math.sin(angle) * entity.radius,
entity.center.z || 0
));
}
} else {
// Sample forward from start to end
for (let i = 0; i <= segments; i++) {
const angle = startAngle + (i / segments) * angleDiff;
points.push(newPoint(
entity.center.x + Math.cos(angle) * entity.radius,
entity.center.y + Math.sin(angle) * entity.radius,
entity.center.z || 0
));
}
}
let poly = newPolygon().addPoints(points);
poly.setOpen(true);
polys.push(poly);
} else if (entity.type === 'SPLINE') {
// Convert NURBS spline to polyline by sampling (already in mm)
if (entity.controlPoints.length < 2) {
continue;
}
const points = evaluateSpline(entity, segmentSize, minSegments);
if (points.length < 2) {
continue;
}
let poly = newPolygon().addPoints(points);
if (entity.closed) {
// Remove duplicate end point if closed
if (poly.appearsClosed()) {
poly.points.pop();
}
} else {
poly.setOpen(true);
}
polys.push(poly);
}
}
// Nest polygons to identify holes vs outlines
const sub = fromSoup ? polygons.nest(polys) : polys;
const nest = sub.filter(p => {
for (let pc of polys) {
if (pc === p) {
return true;
} else {
return !pc.isEquivalent(p);
}
}
});
if (justPoly) {
return nest;
}
// Extrude polygons to 3D
for (let poly of nest) {
let obj = poly.extrude(depth);
objs.push(obj);
}
return objs;
}
function extractEntities(lines) {
const entities = [];
let inEntities = false;
let i = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
// Check if we're in the ENTITIES section
if (code === '0' && value === 'SECTION') {
if (i + 3 < lines.length && lines[i + 2] === '2' && lines[i + 3] === 'ENTITIES') {
inEntities = true;
i += 4;
continue;
}
}
if (code === '0' && value === 'ENDSEC' && inEntities) {
break;
}
if (inEntities && code === '0') {
if (value === 'POLYLINE') {
const entity = parsePolyline(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'LWPOLYLINE') {
const entity = parseLWPolyline(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'LINE') {
const entity = parseLine(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'CIRCLE') {
const entity = parseCircle(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'ARC') {
const entity = parseArc(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
} else if (value === 'SPLINE') {
const entity = parseSpline(lines, i);
if (entity) {
entities.push(entity);
i = entity.endIndex;
continue;
}
}
}
i += 2;
}
return entities;
}
function parsePolyline(lines, start) {
let i = start + 2;
let closed = false;
const points = [];
// Read polyline flags
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '70') {
// Polyline flag: 1 = closed
closed = (parseInt(value) & 1) === 1;
}
if (code === '0' && value === 'VERTEX') {
const vertex = parseVertex(lines, i);
if (vertex) {
points.push(vertex.point);
i = vertex.endIndex;
continue;
}
}
if (code === '0' && value === 'SEQEND') {
return { type: 'POLYLINE', points, closed, endIndex: i + 2 };
}
i += 2;
}
return null;
}
function parseVertex(lines, start) {
let i = start + 2;
const point = { x: 0, y: 0, z: 0 };
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') point.x = parseFloat(value);
if (code === '20') point.y = parseFloat(value);
if (code === '30') point.z = parseFloat(value);
if (code === '0') {
return { point, endIndex: i };
}
i += 2;
}
return { point, endIndex: i };
}
function parseLWPolyline(lines, start) {
let i = start + 2;
let closed = false;
const points = [];
let currentPoint = null;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '70') {
closed = (parseInt(value) & 1) === 1;
}
if (code === '10') {
if (currentPoint) {
points.push(currentPoint);
}
currentPoint = { x: parseFloat(value), y: 0, z: 0 };
}
if (code === '20' && currentPoint) {
currentPoint.y = parseFloat(value);
}
if (code === '0') {
if (currentPoint) {
points.push(currentPoint);
}
return { type: 'LWPOLYLINE', points, closed, endIndex: i };
}
i += 2;
}
if (currentPoint) {
points.push(currentPoint);
}
return { type: 'LWPOLYLINE', points, closed, endIndex: i };
}
function parseLine(lines, start) {
let i = start + 2;
const start_point = { x: 0, y: 0, z: 0 };
const end_point = { x: 0, y: 0, z: 0 };
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') start_point.x = parseFloat(value);
if (code === '20') start_point.y = parseFloat(value);
if (code === '30') start_point.z = parseFloat(value);
if (code === '11') end_point.x = parseFloat(value);
if (code === '21') end_point.y = parseFloat(value);
if (code === '31') end_point.z = parseFloat(value);
if (code === '0') {
return { type: 'LINE', start: start_point, end: end_point, endIndex: i };
}
i += 2;
}
return { type: 'LINE', start: start_point, end: end_point, endIndex: i };
}
function parseCircle(lines, start) {
let i = start + 2;
const center = { x: 0, y: 0, z: 0 };
let radius = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') center.x = parseFloat(value);
if (code === '20') center.y = parseFloat(value);
if (code === '30') center.z = parseFloat(value);
if (code === '40') radius = parseFloat(value);
if (code === '0') {
return { type: 'CIRCLE', center, radius, endIndex: i };
}
i += 2;
}
return { type: 'CIRCLE', center, radius, endIndex: i };
}
function parseArc(lines, start) {
let i = start + 2;
const center = { x: 0, y: 0, z: 0 };
let radius = 0;
let startAngle = 0;
let endAngle = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '10') center.x = parseFloat(value);
if (code === '20') center.y = parseFloat(value);
if (code === '30') center.z = parseFloat(value);
if (code === '40') radius = parseFloat(value);
if (code === '50') startAngle = parseFloat(value) * Math.PI / 180; // Convert to radians
if (code === '51') endAngle = parseFloat(value) * Math.PI / 180; // Convert to radians
if (code === '0') {
return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i };
}
i += 2;
}
return { type: 'ARC', center, radius, startAngle, endAngle, endIndex: i };
}
function parseSpline(lines, start) {
let i = start + 2;
let degree = 3; // default cubic
let closed = false;
const controlPoints = [];
const knots = [];
let numKnots = 0;
let numControlPoints = 0;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '70') {
// Spline flag: bit 0 (1) = closed
closed = (parseInt(value) & 1) === 1;
}
if (code === '71') degree = parseInt(value);
if (code === '72') numKnots = parseInt(value);
if (code === '73') numControlPoints = parseInt(value);
if (code === '40') {
// Knot value
knots.push(parseFloat(value));
}
if (code === '10') {
// Control point X - start new point
controlPoints.push({ x: parseFloat(value), y: 0, z: 0 });
}
if (code === '20' && controlPoints.length > 0) {
// Control point Y
controlPoints[controlPoints.length - 1].y = parseFloat(value);
}
if (code === '30' && controlPoints.length > 0) {
// Control point Z
controlPoints[controlPoints.length - 1].z = parseFloat(value);
}
if (code === '0') {
return { type: 'SPLINE', degree, closed, knots, controlPoints, endIndex: i };
}
i += 2;
}
return { type: 'SPLINE', degree, closed, knots, controlPoints, endIndex: i };
}
// Evaluate NURBS B-spline curve to generate sample points (entities already scaled to mm)
function evaluateSpline(entity, segmentSize, minSegments) {
const { degree, controlPoints, knots, closed } = entity;
if (controlPoints.length < degree + 1 || knots.length === 0) {
// Degenerate spline, just return control points
return controlPoints.map(p => newPoint(p.x, p.y, p.z));
}
// Estimate curve length by summing control point distances (rough approximation)
let estimatedLength = 0;
for (let i = 1; i < controlPoints.length; i++) {
const dx = controlPoints[i].x - controlPoints[i-1].x;
const dy = controlPoints[i].y - controlPoints[i-1].y;
estimatedLength += Math.sqrt(dx * dx + dy * dy);
}
// Calculate number of samples
const numSamples = Math.max(minSegments, Math.ceil(estimatedLength / segmentSize));
const points = [];
// Find parameter range (first and last non-repeated knot values)
const knotStart = knots[degree];
const knotEnd = knots[knots.length - degree - 1];
if (knotStart >= knotEnd) {
// Invalid knot vector, return control points
return controlPoints.map(p => newPoint(p.x, p.y, p.z));
}
// Sample the curve
for (let i = 0; i <= numSamples; i++) {
const t = knotStart + (i / numSamples) * (knotEnd - knotStart);
const point = evaluateNURBS(t, degree, controlPoints, knots);
points.push(newPoint(point.x, point.y, point.z));
}
return points;
}
// Evaluate a single point on a NURBS curve using De Boor's algorithm
function evaluateNURBS(t, degree, controlPoints, knots) {
const n = controlPoints.length - 1;
// Clamp t to valid range
t = Math.max(knots[degree], Math.min(knots[n + 1], t));
// Find knot span (which segment t falls into)
let span = degree;
while (span <= n && knots[span + 1] <= t) {
span++;
}
if (span > n) span = n;
// Compute basis functions using Cox-de Boor recursion
const N = [];
for (let i = 0; i <= n; i++) {
N[i] = [];
}
// Initialize degree 0 basis functions
for (let i = 0; i <= n; i++) {
if (t >= knots[i] && t < knots[i + 1]) {
N[i][0] = 1.0;
} else {
N[i][0] = 0.0;
}
}
// Special case for last knot
if (t === knots[n + 1]) {
N[n][0] = 1.0;
}
// Compute higher degree basis functions
for (let k = 1; k <= degree; k++) {
for (let i = 0; i <= n; i++) {
let c1 = 0, c2 = 0;
if (N[i][k - 1] !== 0) {
if (knots[i + k] !== knots[i]) {
c1 = ((t - knots[i]) / (knots[i + k] - knots[i])) * N[i][k - 1];
}
}
if (i + 1 <= n && N[i + 1][k - 1] !== 0) {
if (knots[i + k + 1] !== knots[i + 1]) {
c2 = ((knots[i + k + 1] - t) / (knots[i + k + 1] - knots[i + 1])) * N[i + 1][k - 1];
}
}
N[i][k] = c1 + c2;
}
}
// Compute curve point as weighted sum of control points
let x = 0, y = 0, z = 0;
for (let i = 0; i <= n; i++) {
const weight = N[i][degree] || 0;
x += controlPoints[i].x * weight;
y += controlPoints[i].y * weight;
z += controlPoints[i].z * weight;
}
return { x, y, z };
}
// Scale all entity coordinates to millimeters
function scaleEntities(entities, scale) {
if (scale === 1) return; // no scaling needed
for (let entity of entities) {
if (entity.type === 'LINE') {
entity.start.x *= scale;
entity.start.y *= scale;
entity.start.z = (entity.start.z || 0) * scale;
entity.end.x *= scale;
entity.end.y *= scale;
entity.end.z = (entity.end.z || 0) * scale;
} else if (entity.type === 'CIRCLE') {
entity.center.x *= scale;
entity.center.y *= scale;
entity.center.z = (entity.center.z || 0) * scale;
entity.radius *= scale;
} else if (entity.type === 'ARC') {
entity.center.x *= scale;
entity.center.y *= scale;
entity.center.z = (entity.center.z || 0) * scale;
entity.radius *= scale;
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
for (let point of entity.points) {
point.x *= scale;
point.y *= scale;
point.z = (point.z || 0) * scale;
}
} else if (entity.type === 'SPLINE') {
for (let point of entity.controlPoints) {
point.x *= scale;
point.y *= scale;
point.z = (point.z || 0) * scale;
}
}
}
}
// Extract units from DXF header
function extractUnits(lines) {
let i = 0;
let inHeader = false;
while (i < lines.length - 1) {
const code = lines[i];
const value = lines[i + 1];
if (code === '0' && value === 'SECTION') {
if (i + 3 < lines.length && lines[i + 2] === '2' && lines[i + 3] === 'HEADER') {
inHeader = true;
i += 4;
continue;
}
}
if (code === '0' && value === 'ENDSEC' && inHeader) {
break;
}
if (inHeader && code === '9' && value === '$INSUNITS') {
// Next line should be 70, followed by the unit code
if (i + 3 < lines.length && lines[i + 2] === '70') {
const unitCode = parseInt(lines[i + 3]);
// DXF INSUNITS codes: 0=unitless, 1=inches, 2=feet, 4=mm, 5=cm, 6=meters
switch (unitCode) {
case 1: return 'inch';
case 2: return 'foot';
case 4: return 'mm';
case 5: return 'cm';
case 6: return 'meter';
default: return 'mm'; // default to mm for unitless
}
}
}
i += 2;
}
return 'mm'; // default to millimeters
}
// Get scale factor to convert from input units to millimeters
function getScaleToMM(inputUnits) {
// Scale factors to convert to mm (Kiri:Moto's internal unit)
const toMM = {
'mm': 1,
'cm': 10,
'meter': 1000,
'inch': 25.4,
'foot': 304.8
};
return toMM[inputUnits] || 1;
}
// Stitch together open paths that share endpoints
function stitchPaths(entities, tolerance = 0.01) {
const stitched = [];
const used = new Set();
// Helper to check if two points are within tolerance
const pointsMatch = (p1, p2) => {
const dx = p1.x - p2.x;
const dy = p1.y - p2.y;
const dz = (p1.z || 0) - (p2.z || 0);
return Math.sqrt(dx * dx + dy * dy + dz * dz) < tolerance;
};
// Helper to get endpoints of an entity
const getEndpoints = (entity) => {
if (entity.type === 'LINE') {
return { start: entity.start, end: entity.end };
} else if (entity.type === 'ARC') {
// Calculate actual arc endpoints
const startX = entity.center.x + Math.cos(entity.startAngle) * entity.radius;
const startY = entity.center.y + Math.sin(entity.startAngle) * entity.radius;
const endX = entity.center.x + Math.cos(entity.endAngle) * entity.radius;
const endY = entity.center.y + Math.sin(entity.endAngle) * entity.radius;
const start = { x: startX, y: startY, z: entity.center.z || 0 };
const end = { x: endX, y: endY, z: entity.center.z || 0 };
// If arc is reversed, swap the endpoints
if (entity.reversed) {
return { start: end, end: start };
}
return { start, end };
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
if (entity.closed || entity.points.length < 2) return null;
return {
start: entity.points[0],
end: entity.points[entity.points.length - 1]
};
}
return null;
};
// Helper to convert entity to points
const entityToPoints = (entity) => {
if (entity.type === 'LINE') {
return [entity.start, entity.end];
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
return [...entity.points];
}
// For ARC and other types, return null (will be converted later in main loop)
return null;
};
// First, identify stitchable entities (LINE, ARC, open POLYLINE)
const stitchable = [];
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (entity.type === 'LINE' || entity.type === 'ARC' ||
((entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') && !entity.closed)) {
stitchable.push({ entity, index: i });
}
}
// Try to stitch paths together
for (let i = 0; i < stitchable.length; i++) {
if (used.has(i)) continue;
const { entity, index } = stitchable[i];
const endpoints = getEndpoints(entity);
if (!endpoints) {
stitched.push(entity);
used.add(i);
continue;
}
// Start a new path
const path = [entity];
used.add(i);
let currentEnd = endpoints.end;
let currentStart = endpoints.start;
let foundMatch = true;
// Keep extending the path
while (foundMatch) {
foundMatch = false;
for (let j = 0; j < stitchable.length; j++) {
if (used.has(j)) continue;
const nextEndpoints = getEndpoints(stitchable[j].entity);
if (!nextEndpoints) continue;
// Check if this entity connects to current end
if (pointsMatch(currentEnd, nextEndpoints.start)) {
path.push(stitchable[j].entity);
currentEnd = nextEndpoints.end;
used.add(j);
foundMatch = true;
break;
} else if (pointsMatch(currentEnd, nextEndpoints.end)) {
// Need to reverse this entity
const reversed = reverseEntity(stitchable[j].entity);
path.push(reversed);
// After reversing, the start becomes the new end
const reversedEndpoints = getEndpoints(reversed);
currentEnd = reversedEndpoints.end;
used.add(j);
foundMatch = true;
break;
}
// Check if this entity connects to current start (prepend)
else if (pointsMatch(currentStart, nextEndpoints.end)) {
path.unshift(stitchable[j].entity);
currentStart = nextEndpoints.start;
used.add(j);
foundMatch = true;
break;
} else if (pointsMatch(currentStart, nextEndpoints.start)) {
// Need to reverse and prepend
const reversed = reverseEntity(stitchable[j].entity);
path.unshift(reversed);
// After reversing, the end becomes the new start
const reversedEndpoints = getEndpoints(reversed);
currentStart = reversedEndpoints.start;
used.add(j);
foundMatch = true;
break;
}
}
}
// Convert path to a single stitched entity
if (path.length === 1) {
stitched.push(path[0]);
} else {
// Combine into stitched polyline - mark for later conversion
const stitchedEntity = {
type: 'STITCHED',
parts: path,
closed: pointsMatch(currentStart, currentEnd)
};
stitched.push(stitchedEntity);
}
}
// Add non-stitchable entities (CIRCLE, SPLINE, closed POLYLINE)
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (entity.type === 'CIRCLE' || entity.type === 'SPLINE' ||
((entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') && entity.closed)) {
stitched.push(entity);
}
}
return stitched;
}
// Reverse an entity's direction
function reverseEntity(entity) {
if (entity.type === 'LINE') {
return {
type: 'LINE',
start: entity.end,
end: entity.start
};
} else if (entity.type === 'ARC') {
// Mark the arc as reversed so it gets sampled in reverse
return {
type: 'ARC',
center: entity.center,
radius: entity.radius,
startAngle: entity.startAngle,
endAngle: entity.endAngle,
reversed: true
};
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
return {
type: entity.type,
points: [...entity.points].reverse(),
closed: entity.closed
};
}
return entity;
}
// Convert entity to array of points (entities already scaled to mm)
function convertEntityToPoints(entity, segmentSize, minSegments) {
if (entity.type === 'LINE') {
return [entity.start, entity.end];
} else if (entity.type === 'POLYLINE' || entity.type === 'LWPOLYLINE') {
return [...entity.points];
} else if (entity.type === 'ARC') {
// DXF arcs always go counterclockwise. Handle angle wrapping.
let startAngle = entity.startAngle;
let endAngle = entity.endAngle;
let angleDiff = endAngle - startAngle;
// If endAngle < startAngle, arc wraps through 0/360
if (angleDiff < 0) {
angleDiff += Math.PI * 2;
}
const arcLength = angleDiff * entity.radius;
const segments = Math.max(minSegments, Math.ceil(arcLength / segmentSize));
const points = [];
if (entity.reversed) {
// Sample backwards from end to start
for (let i = 0; i <= segments; i++) {
const angle = endAngle - (i / segments) * angleDiff;
points.push({
x: entity.center.x + Math.cos(angle) * entity.radius,
y: entity.center.y + Math.sin(angle) * entity.radius,
z: entity.center.z || 0
});
}
} else {
// Sample forward from start to end
for (let i = 0; i <= segments; i++) {
const angle = startAngle + (i / segments) * angleDiff;
points.push({
x: entity.center.x + Math.cos(angle) * entity.radius,
y: entity.center.y + Math.sin(angle) * entity.radius,
z: entity.center.z || 0
});
}
}
return points;
}
return [];
}

View file

@ -6,6 +6,7 @@ import { STL } from './stl.js';
import * as OBJ from './obj.js';
import * as TMF from './3mf.js';
import * as SVG from './svg.js';
import * as DXF from './dxf.js';
import * as GBR from './gbr.js';
import { load as pngLoad } from './png.js';
@ -36,6 +37,11 @@ const types = {
resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } }));
},
dxf(data, file, resolve, reject, opt = {}) {
let out = DXF.parse(data, opt);
resolve(opt.flat ? out : out.map(m => { return { mesh: m.toFloat32(), file } }));
},
png(data, file, resolve, reject, opt = {}) {
pngLoad.PNG.parse(data, {
...opt,
@ -100,6 +106,6 @@ function load_file(file, opt) {
});
}
Object.assign(load_file, { SVG, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG });
Object.assign(load_file, { SVG, DXF, OBJ, STL, TMF, GBR, PNG: pngLoad.PNG });
export { types, as_buffer, load_data, load_file, load_file as load };

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;

View file

@ -44,7 +44,7 @@ export function parse(text, opt = { }) {
if (points.length < 3) {
continue;
}
let poly = newPolygon().addPoints(points.map(p => newPoint(p.x, -p.y, 0)));
let poly = newPolygon().addPoints(points.map(p => newPoint(p.x, -p.y, 0))).clean();
if (poly.appearsClosed()) poly.points.pop();
if (type === 'polyline') poly.setOpen(true);
poly._svg = { width, miter };

View file

@ -2,11 +2,9 @@
'use strict';
import { STL } from './stl.js';
import { OBJ } from './obj.js';
import { TMF } from './3mf.js';
import { SVG } from './svg.js';
import { load_file } from './file.js';
const { STL, OBJ, TMF, SVG, DXF } = load_file;
const CDH = 'Content-Disposition';
export function load_url(url, options = {}) {
@ -14,7 +12,7 @@ export function load_url(url, options = {}) {
let xhr = new XMLHttpRequest();
let file = options.file || options.filename || (((url.split('?')[0]).split('#')[0]).split('/')).pop();
let ext = file.split('.').pop().toLowerCase();
let deftype = ext === "obj" || ext === 'svg' ? "text" : "arraybuffer";
let deftype = ext === "obj" || ext === 'svg' || ext === 'dxf' ? "text" : "arraybuffer";
let datatype = options.datatype || deftype;
let formdata = options.formdata;
@ -57,6 +55,9 @@ export function load_url(url, options = {}) {
case "svg":
resolve(SVG.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
case "dxf":
resolve(DXF.parse(data).map(m => { return {mesh: m.toFloat32(), file} }));
break;
default:
reject(`unknown file type: "${ext}" from ${url}`);
break;

View file

@ -10,6 +10,7 @@ import { api } from '../kiri/app/api.js';
import { init_lang } from '../kiri/app/init/lang.js';
import { init_input } from '../kiri/app/init/input.js';
import { init_sync } from '../kiri/app/init/sync.js';
import { surfaces } from '../kiri/app/init/build.js';
let traceload = location.search.indexOf('traceload') > 0;
let load = [];
@ -36,6 +37,7 @@ async function checkReady() {
{
api.client.start();
await init_lang();
surfaces.build();
await init_input();
await init_sync();
}
@ -44,11 +46,11 @@ async function checkReady() {
}
load = undefined;
api.event.emit('load-done', stats);
api.event.emit('resize');
if (api.electron) {
$('install').classList.add('hide');
$('app-quit').classList.remove('hide');
$('app-name-text').innerText = "More Info";
$('top-sep').style.display = 'flex';
[...document.getElementsByClassName('el-app-hide')].forEach(el => el.classList.add('hide'));
} else if (bootctrl) {
$('install').classList.add('hide');
$('uninstall').classList.remove('hide');
@ -57,6 +59,7 @@ async function checkReady() {
location.reload();
}
} else {
[...document.getElementsByClassName('app-hide')].forEach(el => el.classList.add('hide'));
$('install').onclick = () => {
location.replace('/boot');
}

View file

@ -17,10 +17,12 @@ import { edges as meshEdges } from '../mesh/edges.js';
import { open as dataOpen } from '../data/index.js';
import { load as fileLoad } from '../load/file.js';
import { THREE } from '../ext/three.js';
import { createDocumentManager } from '../mesh/document.js';
const version = '1.5.7';
const call = broker.send;
const dbindex = [ "admin", "space" ];
const dbindex = [ "admin", "documents", "versions" ];
const DOC_META_KEY = '__doc';
const { Quaternion } = THREE;
@ -28,10 +30,67 @@ function log() {
return api.log.emit(...arguments);
}
function boot_status(message = 'loading...') {
const curtain = $('curtain');
if (!curtain) return;
curtain.textContent = String(message || 'loading...');
}
function boot_start(message = 'loading...') {
$('app')?.classList?.add('booting');
boot_status(message);
$d('curtain', 'flex');
}
function boot_done() {
$('app')?.classList?.remove('booting');
$d('curtain', 'none');
}
function get_doc_meta(meta = metaCache) {
if (!meta || typeof meta !== 'object') return {};
return meta[DOC_META_KEY] || {};
}
function capture_camera_state() {
return {
place: space.view.save(),
focus: space.view.getFocus()
};
}
function apply_camera_state(camera) {
if (!camera) return;
if (camera.place) {
space.view.load(camera.place);
}
if (camera.focus) {
space.view.setFocus(camera.focus);
}
}
function save_camera_to_document() {
const dmeta = get_doc_meta(metaCache);
metaCache[DOC_META_KEY] = {
...dmeta,
camera: capture_camera_state()
};
store_meta();
}
let cameraSaveTimer = null;
function schedule_camera_save(delay = 120) {
clearTimeout(cameraSaveTimer);
cameraSaveTimer = setTimeout(() => {
cameraSaveTimer = null;
save_camera_to_document();
}, delay);
}
// set below. called once the DOM readyState = complete
// this is the main() entrypoint called after all dependents load
function init() {
let stores = dataOpen('mesh', { stores: dbindex, version: 4 }).init(),
let stores = dataOpen('mesh', { stores: dbindex, version: 5 }).init(),
dark = false,
ortho = false,
zoomrev = true,
@ -39,11 +98,21 @@ function init() {
platform = space.platform,
db = api.db = {
admin: stores.promise('admin'),
space: stores.promise('space')
documents: stores.promise('documents'),
versions: stores.promise('versions')
};
const docman = api.document = createDocumentManager({
admin: db.admin,
documents: db.documents,
versions: db.versions,
maxRevisions: 200
});
db.space = docman.spaceStore;
// initialize the API (to avoid circular dependencies)
api.init();
boot_start('initializing mesh:tool');
// mark init time and use count
db.admin.put("init", Date.now());
@ -70,26 +139,23 @@ function init() {
colorX: 0xff7777, colorY: 0x7777ff },
});
platform.onMove(() => {
// save last location and focus
db.admin.put('camera', {
place: space.view.save(),
focus: space.view.getFocus()
});
// save camera per-document
save_camera_to_document();
}, 100);
space.view.setZoom(zoomrev, zoomspd);
// trigger ui building
call.ui_build();
// trigger space event binding
call.space_init({ space: space, platform });
// reload stored space when worker is ready
motoClient.on('ready', restore_space);
// start worker
motoClient.start('../lib/mesh/work.js?' + version);
// trigger space event binding
call.space_init({ space: space, platform });
// trigger ui building
call.ui_build();
// hide url params
let wlp = window.location.pathname;
let mio = wlp.indexOf('/mesh/');
@ -102,32 +168,27 @@ function init() {
self.electron = navigator.userAgent.includes('Electron');
}
// restore space layout and view from previous session
async function restore_space() {
const db_admin = api.db.admin;
const db_space = api.db.space;
// let mcache = {};
await db_admin.get("camera")
.then(saved => {
if (saved) {
space.view.load(saved.place);
space.view.setFocus(saved.focus);
function clear_workspace() {
api.selection.clear();
for (let sk of api.sketch.list().slice()) {
sk.remove();
}
});
const mcache = await db_admin.get("meta") || {};
for (let grp of api.group.list().slice()) {
grp.remove();
}
}
async function restore_workspace_from_state(cached = {}, mcache = {}) {
const db_space = api.db.space;
let count = 0;
await db_space.iterate({ map: true }).then(cached => {
await Promise.resolve(cached).then(cached => {
const keys = [];
const claimed = [];
for (let [id, data] of Object.entries(cached)) {
// console.log({ id, data });
keys.push(id);
if (count++ === 0) {
log(`restoring workspace`);
}
// restore object based on type
// group arrays load models they contain
// sketches are loaded by type since they're not grouped
if (Array.isArray(data)) {
claimed.push(id);
let models = data
@ -135,11 +196,11 @@ async function restore_space() {
claimed.push(id);
return { id, md: cached[id] }
})
.filter(r => r.md) // filter cache misses
.map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id]))
.filter(r => r.md)
.map(r => new meshModel(r.md, r.id).applyMeta(mcache[r.id]));
if (models.length) {
log(`restored ${models.length} model(s)`);
api.group.new(models, id).applyMeta(mcache[id])
api.group.new(models, id).applyMeta(mcache[id]);
} else {
log(`removed empty group ${id}`);
db_space.remove(id);
@ -155,22 +216,17 @@ async function restore_space() {
if (keys.length) {
log(`removing ${keys.length} unclaimed meshes`);
}
// clear out meshes left in the space db along with their meta-data
for (let id of keys) {
db_space.remove(id);
delete mcache[id];
}
// restore global cache only after objects are restored
// otherwise their setup will corrupt the cache for other restores
metaCache = mcache;
store_meta();
api.document.setMeta(metaCache);
}).then(() => {
// restore preferences after models are restored
return api.prefs.load().then(() => {
let { map } = api.prefs;
let { space, mode } = map;
api.grid(space.grid);
// restore selected state
let selist = space.select || [];
let smodel = api.model.list().filter(m => selist.contains(m.id));
let sgroup = api.group.list().filter(m => selist.contains(m.id));
@ -179,14 +235,38 @@ async function restore_space() {
let tgroup = api.group.list().filter(m => tolist.contains(m.id));
let sklist = api.sketch.list().filter(s => selist.contains(s.id));
api.selection.set([...smodel, ...sgroup, ...sklist], [...tmodel, ...tgroup]);
// restore edit mode
api.mode[mode]();
// restore dark mode
set_darkmode(map.space.dark);
set_darkmode();
});
}).finally(() => {
});
}
// restore space layout and view from previous session
async function restore_space() {
const db_admin = api.db.admin;
const db_space = api.db.space;
const docman = api.document;
boot_status('loading document');
const currentDoc = await docman.restoreOrCreate();
const mcache = docman.getMeta() || {};
const oldCamera = await db_admin.get("camera");
const docCamera = get_doc_meta(mcache).camera || oldCamera || null;
boot_status('restoring workspace');
const cached = await db_space.iterate({ map: true }) || {};
docman.pause();
try {
await restore_workspace_from_state(cached, mcache);
} finally {
docman.resume();
}
boot_status('restoring view');
apply_camera_state(docCamera);
space.update();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
boot_status('finalizing');
Promise.resolve().finally(() => {
// hide loading curtain
$d('curtain','none');
boot_done();
// restore handles visibility
handles.setEnabled(api.prefs.map.space.bounds ?? false);
// restore script if was showing
@ -196,15 +276,103 @@ async function restore_space() {
if (api.prefs.map.info.welcome !== false) {
api.welcome(version);
}
api.file.set_doc_name(currentDoc?.name || 'Untitled');
broker.publish("app_ready");
});
}
async function document_new(opt = {}) {
const docman = api.document;
await docman.flush();
await docman.commit('document.autosave', 'document.autosave');
docman.pause();
try {
clear_workspace();
space.view.home();
metaCache = {
[DOC_META_KEY]: {
camera: capture_camera_state()
}
};
await docman.create(opt.name || 'Untitled');
docman.setMeta(metaCache);
} finally {
docman.resume();
}
await docman.commit('document.new', 'document.new');
api.file.set_doc_name(docman.current?.name || 'Untitled');
}
async function document_open(opt = {}) {
const docman = api.document;
const id = String(opt?.id || '');
if (!id) return;
await docman.flush();
await docman.open(id, { autosave: opt.autosave !== false });
const cached = docman.getSpace() || {};
const mcache = docman.getMeta() || {};
docman.pause();
try {
clear_workspace();
await restore_workspace_from_state(cached, mcache);
} finally {
docman.resume();
}
apply_camera_state(get_doc_meta(mcache).camera);
api.file.set_doc_name(docman.current?.name || 'Untitled');
}
// add space event bindings
function space_init(data) {
let platcolor = 0x00ff00;
let { space, platform } = data;
let { selection } = api;
function selection_or_visible_entities() {
const selected = api.selection.list(true);
if (selected?.length) return selected;
return [
...api.group.list().filter(g => g.visible()),
...api.sketch.list().filter(s => s.visible())
];
}
function fit_visible() {
const entities = selection_or_visible_entities();
const objects = entities.map(e => e?.object).filter(o => o);
return space.view.fit(undefined, {
padding: 1,
visibleOnly: true,
objects: objects.length ? objects : undefined
});
}
function focus_visible() {
const entities = selection_or_visible_entities();
if (entities.length) {
return api.focus(entities);
}
return api.focus([
...api.group.list(),
...api.sketch.list()
]);
}
function norm_code(evt) {
if (evt?.code) return evt.code;
const key = evt?.key;
if (!key) return '';
if (key === ' ') return 'Space';
if (key === 'Spacebar') return 'Space';
if (key === 'Escape') return 'Escape';
if (key.length === 1) {
const up = key.toUpperCase();
if (up >= 'A' && up <= 'Z') return `Key${up}`;
if (up >= '0' && up <= '9') return `Digit${up}`;
}
return key;
}
// add file drop handler
space.event.addHandlers(self, [
'drop', (evt) => {
@ -222,6 +390,10 @@ function space_init(data) {
'dragleave', evt => {
platform.set({ opacity: 0, color: platcolor });
},
// camera interactions (orbit/pan/dolly) are not guaranteed to trigger platform.onMove
'wheel', () => schedule_camera_save(),
'mouseup', () => schedule_camera_save(),
'touchend', () => schedule_camera_save(),
'keypress', evt => {
if (api.modal.showing) {
return;
@ -229,7 +401,8 @@ function space_init(data) {
if (evt.key === '?') {
return api.welcome(version);
}
let { shiftKey, metaKey, ctrlKey, code, target } = evt;
let { shiftKey, metaKey, ctrlKey, target } = evt;
let code = norm_code(evt);
if (target.nodeName === 'TEXTAREA') {
api.script.changed();
return;
@ -250,7 +423,11 @@ function space_init(data) {
case 'KeyB':
return selection.boundsBox({toggle:true});
case 'KeyC':
if (shiftKey) {
return selection.floor();
} else {
return selection.centerXY().focus();
}
case 'KeyD':
return shiftKey && api.tool.duplicate();
case 'KeyE':
@ -259,14 +436,14 @@ function space_init(data) {
return api.sketch.extrude();
}
return;
case 'KeyF':
return shiftKey ? selection.focus() : selection.floor().focus();
case 'KeyG':
return shiftKey ?
(api.mode.is([ api.modes.sketch ]) ? api.sketch.arrange.group() : api.tool.regroup()) :
api.grid();
case 'KeyH':
return shiftKey ? selection.hide() : space.view.home();
if (shiftKey) return selection.hide();
schedule_camera_save(180);
return space.view.home();
case 'KeyI':
return api.file.import();
case 'KeyL':
@ -283,7 +460,9 @@ function space_init(data) {
if (!api.mode.is([ api.modes.object ])) return;
return shiftKey ? selection.visible({toggle:true}) : meshSplit.start();
case 'KeyT':
return shiftKey ? api.tool.triangulate() : space.view.top();
if (shiftKey) return api.tool.triangulate();
schedule_camera_save(180);
return space.view.top();
case 'KeyU':
return shiftKey && api.tool.union();
case 'KeyV':
@ -295,7 +474,9 @@ function space_init(data) {
}
},
'keydown', evt => {
let { shiftKey, metaKey, ctrlKey, code, target } = evt;
let { shiftKey, metaKey, ctrlKey, target } = evt;
let code = norm_code(evt);
const key = evt?.key;
if (target.nodeName === 'TEXTAREA') {
if (code === 'Tab') {
estop(evt);
@ -320,14 +501,36 @@ function space_init(data) {
delete keyOnce[code];
return once(evt);
}
let rv = (Math.PI / 12);
if (api.modal.showing) {
if (code === 'Escape') {
api.modal.cancel();
}
return;
}
let rot, floor = api.prefs.map.space.floor !== false;
const isFit = code === 'KeyF' ||
key === 'f' ||
key === 'F';
if (isFit && !(metaKey || ctrlKey)) {
estop(evt);
const rv = shiftKey ? focus_visible() : fit_visible();
schedule_camera_save(220);
return rv;
}
const isSpace = code === 'Space' ||
code === 'Spacebar' ||
key === ' ' ||
key === 'Spacebar';
if (isSpace) {
if (selection.clear()) {
meshEdges.clear();
meshSplit.end();
}
estop(evt);
return;
}
let rv = (Math.PI / 12);
let rot;
let floor = api.prefs.map.space.floor !== false;
switch (code) {
case 'KeyA':
estop(evt);
@ -353,7 +556,9 @@ function space_init(data) {
if (metaKey || ctrlKey) {
return shiftKey ? api.history.redo() : api.history.undo();
} else {
return space.view.reset();
space.view.reset();
schedule_camera_save(220);
return;
}
case 'Escape':
if (selection.clear()) {
@ -664,7 +869,7 @@ function key_once_cancel(code) {
}
function store_meta() {
api.db.admin.put("meta", metaCache);
api.document?.setMeta?.(metaCache);
}
function update_meta(id, data) {
@ -694,28 +899,19 @@ function object_destroy(id) {
function set_darkmode(dark) {
let { prefs, model } = api;
let { sky, platform } = space;
prefs.map.space.dark = dark;
if (dark) {
dark = true;
prefs.map.space.dark = true;
materials.wireframe.color.set(0xaaaaaa);
materials.wireline.color.set(0xaaaaaa);
$('app').classList.add('dark');
} else {
materials.wireframe.color.set(0,0,0);
materials.wireline.color.set(0,0,0);
$('app').classList.remove('dark');
}
sky.set({
color: dark ? 0 : 0xffffff,
ambient: { intensity: dark ? 0.55 : 1.1 }
color: 0,
ambient: { intensity: 0.55 }
});
platform.set({
light: dark ? 0.08 : 0.08,
grid: dark ? {
light: 0.08,
grid: {
colorMajor: 0x666666,
colorMinor: 0x333333,
} : {
colorMajor: 0xcccccc,
colorMinor: 0xeeeeee,
},
});
api.updateFog();
@ -741,11 +937,8 @@ function set_normals_length(length) {
function set_normals_color(color) {
let { prefs, model } = api;
let { map } = prefs;
if (map.space.dark) {
map.normals.color_dark = color || 0;
} else {
map.normals.color_lite = color || 0;
}
prefs.save();
// Update existing normals
for (let m of model.list()) {
@ -809,7 +1002,9 @@ broker.listeners({
set_surface_radius,
set_wireframe_opacity,
set_wireframe_fog,
set_snap_value
set_snap_value,
document_new,
document_open
});
init();
@ -837,5 +1032,7 @@ export {
set_surface_radius,
set_wireframe_opacity,
set_wireframe_fog,
set_snap_value
set_snap_value,
document_new,
document_open
};

329
src/main/void.js Normal file
View file

@ -0,0 +1,329 @@
/** 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();
};
const onResize = () => {
applyWidth(left.getBoundingClientRect().width);
}
window.addEventListener('resize', onResize);
}
// Main initialization function
async function init() {
console.log({ void_form_init: version });
// Initialize IndexedDB
let stores = dataOpen('void', { stores: dbindex, version: 2 }).init();
let db = api.db = {
admin: stores.promise('admin'),
documents: stores.promise('documents'),
versions: stores.promise('versions')
};
// Mark init time and use count
db.admin.put("init", Date.now());
db.admin.get("uses").then(v => db.admin.put("uses", (v || 0) + 1));
// Initialize API
api.init();
await initSketchConstraintsSolver();
await api.solids.init();
// Setup 3D workspace
space.setAntiAlias(true);
// Void owns its own keymap (Onshape-style); disable space.js defaults.
space.useDefaultKeys(false);
// Default void to orthographic (CAD-like), while saved camera projection
// restoration below can still override per-document/session.
space.init($('container'), delta => {}, true);
api.sketchRuntime.init(space.world);
api.solids.attach(space.world);
// Initialize 2D overlay system
overlay.init();
// Initialize datum planes
const datumGroup = datum.init({ size: 200, visible: true });
space.world.add(datumGroup);
// Add datum labels to overlay
datum.updateLabels(overlay);
// Hook overlay to update datum labels on camera movement
overlay.onUpdate = () => {
datum.updateLabels(overlay);
};
// Initialize interaction system (hover, select, drag)
interact.init();
api.document.bindRuntimeObservers();
// Initialize ViewCube navigation widget
const viewcube = new ViewCube({
size: 80, // Size in pixels
padding: 20, // Padding from corner
cubeSize: 1.5 // 3D cube size
});
// Register viewcube to render after main scene
space.afterRender((renderer) => {
viewcube.render(renderer);
});
// Configure sky and platform
space.sky.set({
grid: false,
color: 0x101010
});
space.view.setCtrl('void');
// Rebind overlay camera/control hooks after Orbit -> Trackball swap.
overlay.onProjectionChanged();
space.view.setFitVisibleOnly(true);
space.view.setHome(VOID_HOME_LEFT, VOID_HOME_UP);
space.platform.set({
visible: false,
size: { width: 1000, depth: 1000, height: 0 },
zoom: { reverse: true, speed: 1 },
grid: {
disabled: true,
}
});
// Enable camera-aligned tracking plane for drag operations
space.tracking.setMode('camera-aligned');
space.tracking.setDistance(10000); // Far behind camera to catch all rays
// Save camera position on movement
space.platform.onMove(() => {
db.admin.put('camera', {
place: space.view.save(),
focus: space.view.getFocus(),
projection: space.view.getProjection()
});
}, 100);
// Restore saved camera position
db.admin.get('camera').then(cam => {
if (cam && cam.place) {
if (cam.projection && cam.projection !== space.view.getProjection()) {
space.view.setProjection(cam.projection);
space.view.setCtrl('void');
overlay.onProjectionChanged();
toolbar.updateProjectionLabel();
}
space.view.load(cam.place);
if (cam.focus) {
space.view.setFocus(cam.focus);
}
} else {
// Use void-specific default home view when no saved camera exists.
space.view.home();
}
});
// Build UI components
toolbar.build();
toolbar.updateProjectionLabel();
properties.init();
tree.build();
setupLeftPanelResize(db);
// Document history hotkeys: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y
window.addEventListener('keydown', async event => {
const isMeta = event.metaKey || event.ctrlKey;
if (!isMeta) return;
const activeTag = document.activeElement?.tagName;
const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
if (editing) return;
const key = event.key.toLowerCase();
let handled = false;
if (key === 'z' && event.shiftKey) {
handled = await api.document.redo();
} else if (key === 'z') {
handled = await api.document.undo();
} else if (key === 'y') {
handled = await api.document.redo();
}
if (handled) {
event.preventDefault();
toolbar.updateDocumentTitle();
tree.render();
}
});
// Ensure scene redraw when app state changes from non-canvas UI interactions
// (tree toggles, toolbar actions, property edits, etc.).
window.addEventListener('void-state-change', () => {
space.update();
});
// Keep rendering responsive for keyboard-driven interactions even when
// the pointer is not over the canvas and idle-throttling is active.
window.addEventListener('keydown', event => {
const activeTag = document.activeElement?.tagName;
const editing = activeTag === 'INPUT' || activeTag === 'TEXTAREA' || document.activeElement?.isContentEditable;
if (!editing) {
space.update();
}
});
// Restore last active document, or seed a new blank one.
await api.document.restoreOrCreate();
api.geometryStore?.seedFromDocument?.(api.document.current);
api.sketchRuntime.sync();
await api.solids.rebuild('startup');
api.geometryStore?.seedFromDocument?.(api.document.current);
toolbar.updateDocumentTitle();
tree.render();
const { THREE } = window;
// Show overlay
overlay.show();
// Add origin
overlay.add('origin-point', 'point', {
pos3d: new THREE.Vector3(0, 0, 0),
radius: 4.8,
color: 'rgba(140, 140, 140, 0.45)',
stroke: '#5a9fd4',
strokeWidth: 2
});
api.origin.syncOverlayPoint();
// update canvas based on left panel size
space.event.onResize();
// Hide loading curtain
const curtain = $('curtain');
if (curtain) {
curtain.style.opacity = '0';
curtain.style.transition = 'opacity 0.3s';
setTimeout(() => {
curtain.style.display = 'none';
}, 300);
}
console.log({ void_form_ready: true });
}
// Wait for DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

View file

@ -972,6 +972,93 @@ let add = {
};
let file = {
set_doc_name(name = 'Untitled') {
const label = String(name || 'Untitled').trim() || 'Untitled';
document.title = `${label} | Mesh:Tool`;
const el = $('top-doc-name');
if (el) {
el.textContent = label;
el.title = `click to rename (${label})`;
}
},
async new() {
await api.document?.flush?.();
await call.document_new({ name: 'Untitled' });
api.file.set_doc_name('Untitled');
},
async open() {
const docs = await (api.document?.list?.() || Promise.resolve([]));
const rows = docs.map(doc => h.div({ class: "doc-open-row", onclick: async function() {
api.modal.hide();
await call.document_open({ id: doc.id });
api.file.set_doc_name(doc.name || 'Untitled');
} }, [
h.div({ class: "doc-open-name", _: `${doc.name || 'Untitled'}` }),
h.button({ _: "rename", onclick(evt) {
evt?.stopPropagation?.();
api.modal.hide();
setTimeout(() => api.file.rename(doc), 0);
} }),
h.button({ class: "doc-open-del", _: "×", title: "delete", onclick: async (evt) => {
evt?.stopPropagation?.();
await api.file.delete(doc);
} })
]));
api.modal.dialog({
title: "open document",
body: [ h.div({ class: "doc-open-list" }, [
...rows,
rows.length ? undefined : h.div({ class: "doc-open-empty", _: "no documents" }),
h.hr(),
h.button({ class: "doc-open-new", _: "new", onclick() {
api.modal.hide();
api.file.new();
} })
].filter(v => v)) ]
});
},
async rename(doc = api.document?.current) {
const current = doc || api.document?.current;
if (!current?.id) return;
if (api.modal?.showing) {
api.modal.hide();
await Promise.resolve();
}
let onclick = onkeydown = (ev) => {
if (!tempedit || (ev.code && ev.code !== 'Enter')) {
return;
}
api.document.rename(current.id, tempedit.value).then(rec => {
if (rec?.id && api.document?.current?.id === rec.id) {
api.file.set_doc_name(rec.name || 'Untitled');
}
}).finally(() => api.modal.hide());
};
let { tempedit } = api.modal.show(`rename document`, h.div({ class: "rename"}, [
h.input({ id: "tempedit", value: current.name || 'Untitled', onkeydown }),
h.button({ _: 'ok', onclick })
]));
tempedit.setSelectionRange(0,1000);
tempedit.focus();
},
async delete(doc = api.document?.current) {
const current = doc || api.document?.current;
if (!current?.id) return;
const result = await api.document?.delete?.(current.id);
if (result?.switched && result?.current?.id) {
await call.document_open({ id: result.current.id, autosave: false });
api.file.set_doc_name(result.current.name || 'Untitled');
}
api.modal.hide();
api.file.open();
},
import() {
// binding created in mesh.build
$('import').click();
@ -1348,7 +1435,7 @@ const mode = {
$(`mode-${key}`).classList.remove('selected');
}
$(`mode-${mode}`).classList.add('selected');
$('mode-label').innerText = mode;
$('top-mode-label').innerText = mode;
api.mode.check();
meshEdges?.end();
if (mode === 'sketch') {
@ -1572,11 +1659,31 @@ const api = {
history: {
undo() {
if (api.document?.undo) {
api.document.undo().then(changed => {
if (changed) {
call.document_open({ id: api.document.current?.id, autosave: false });
} else {
history.undo();
}
});
} else {
history.undo();
}
},
redo() {
if (api.document?.redo) {
api.document.redo().then(changed => {
if (changed) {
call.document_open({ id: api.document.current?.id, autosave: false });
} else {
history.redo();
}
});
} else {
history.redo();
}
}
},
// @param object {MeshObject | MeshObject[] | Object}
@ -1701,6 +1808,8 @@ const api = {
sketch,
space: motoSpace,
tool,
isDebug: self.debug === true

View file

@ -19,7 +19,10 @@ let deg = Math.PI / 180;
let und = undefined;
broker.listeners({
ui_build
ui_build,
app_ready() {
api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled');
}
});
let spin_timer;
@ -240,14 +243,10 @@ api.welcome = function(version = "unknown") {
api.settings = function() {
const { prefs } = api;
const { surface, normals, space, sketch, wireframe } = prefs.map;
const { dark } = space;
const dark = true;
const set1 = div([
label('dark mode'),
input({ type: "checkbox",
onchange: ev => call.set_darkmode(ev.target.checked),
[ dark ? 'checked' : 'unchecked' ] : 1
}),
label({ class: "header", _: 'auto'}),
label('auto floor'),
input({ type: "checkbox",
onchange: ev => prefs.save( space.floor = !space.floor ),
@ -391,20 +390,27 @@ function ui_build() {
// top left drop menus
bind($('top-left'), [
div({ _: 'Mesh:Tool', class: "title" }),
div({ class: "menubar-separator" }),
div({ class: "menu" }, [
div('File'),
div({ class: "menu-items" }, [
input({
id: "import", type: "file", class: ["hide"], multiple: true, accept:".stl,.obj",
id: "import", type: "file", class: ["hide"], multiple: true, accept:".stl,.obj,.svg,.png",
onchange(evt) { broker.send.load_files(evt.target.files) }
}),
menu_item('New', file.new),
menu_item('Open', file.open),
hr(),
menu_item('Import', file.import, 'I'),
menu_item('Export', file.export, 'X'),
hr(),
menu_item('Slicer', api.kirimoto),
menu_item('Script', api.script.toggle),
hr(),
menu_item('Close', window.close),
menu_item('Preferences', api.settings, 'Q'),
hr(),
menu_item('Close', () => window.close() || api.kirimoto()),
])
]),
div({ class: "menu sketch-on" }, [
@ -467,7 +473,6 @@ function ui_build() {
menu_item('Face', mode.face, '5', 'mode-face'),
menu_item('Edge', mode.edge, '6', 'mode-edge'),
]),
div({ id: "mode-label" })
]),
div({ class: "menu sketch-on" }, [
div('Items'),
@ -510,7 +515,7 @@ function ui_build() {
div({ class: "menu sketch-off" }, [
div('Faces'),
div({ class: "menu-items" }, [
menu_item('Flip Normals', tool.invert, ['bi-shift','I']),
menu_item('Flip Normals', tool.invert, ['bi-shift','N']),
menu_item('Triangulate', tool.triangulate, ['bi-shift','T']),
menu_item('To Sketch', tool.toSketch),
hr(),
@ -535,6 +540,7 @@ function ui_build() {
])
]),
div({ class: "menu" }, [
// div({ class: "fas fa-question" }),
div('Help'),
div({ class: "menu-items" }, [
menu_item('About', () => { api.welcome(version) }),
@ -546,15 +552,15 @@ function ui_build() {
menu_item('Versions', api.version),
])
]),
div({ class: "menubar-separator" }),
div({ id: "top-mode-label" }),
]);
// add help buttons
bind($('top-right'), [
div({ id: "top-settings", onclick: api.settings }, [
div({ class: "fas fa-gear" }),
div('Settings')
]),
div({ id: "top-doc-name", onclick: () => api.file.rename(), _: 'Untitled' }),
]);
api.file?.set_doc_name?.(api.document?.current?.name || 'Untitled');
// modal dialog and page blocker
bind($('modal_page'), [
@ -600,32 +606,55 @@ function ui_build() {
return div({ onclick: fn, class: "tool" }, [ bicon(icon), div([ label(help) ]) ]);
}
function toolbar_separator() {
return div({ class: "toolbar-separator" });
}
// bind sketch chiclets
bind(sketchtools, div([
tool_item('bi-plus', 'New Sketch', add.sketch),
toolbar_separator(),
tool_item('bi-circle', 'Add Circle', api.add.circle),
toolbar_separator(),
tool_item('bi-square', 'Add Rectangle', api.add.rectangle),
toolbar_separator(),
tool_item('bi-symmetry-vertical', 'Flip Horizontal', api.sketch.arrange.fliph),
toolbar_separator(),
tool_item('bi-symmetry-horizontal', 'Flip Vertical', api.sketch.arrange.flipv),
toolbar_separator(),
tool_item('bi-arrow-clockwise', 'Rotate', api.sketch.arrange.rotate),
toolbar_separator(),
tool_item('bi-union', 'Union', sketch.boolean.union),
toolbar_separator(),
tool_item('bi-intersect', 'Intersect', sketch.boolean.intersect),
toolbar_separator(),
tool_item('bi-exclude', 'Difference', sketch.boolean.difference),
toolbar_separator(),
tool_item('bi-pip', 'Nest', sketch.boolean.nest),
toolbar_separator(),
tool_item('bi-layers', 'Flatten', sketch.boolean.flatten),
toolbar_separator(),
tool_item('bi-cookie', 'Even Odd', sketch.boolean.evenodd),
toolbar_separator(),
tool_item('bi-arrow-bar-up', 'Extrude', () => sketch.extrude()),
]));
// bind object chiclets
bind(objecttools, div([
tool_item('bi-pencil', 'New Sketch', add.sketch),
toolbar_separator(),
tool_item('bi-box', 'New Cube', add.cube),
toolbar_separator(),
tool_item('bi-database', 'New Cylinder', add.cylinder),
toolbar_separator(),
tool_item('bi-gear', 'New Gear', add.gear),
toolbar_separator(),
tool_item('bi-union', 'Union', tool.union),
toolbar_separator(),
tool_item('bi-subtract', 'Subtract', tool.subtract),
toolbar_separator(),
tool_item('bi-intersect', 'Intersect', tool.intersect),
toolbar_separator(),
tool_item('bi-exclude', 'Difference', tool.difference),
]));

316
src/mesh/document.js Normal file
View file

@ -0,0 +1,316 @@
/** Copyright Stewart Allen <sa@grid.space> -- All Rights Reserved */
function uid() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
}
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function clone(data) {
if (data === undefined || data === null) return data;
if (typeof structuredClone === 'function') {
return structuredClone(data);
}
return JSON.parse(JSON.stringify(data));
}
function title(name = '') {
const clean = String(name || '').trim();
return clean || 'Untitled';
}
function revKey(docId, revId) {
return `${docId}:${revId}`;
}
export function createDocumentManager({ admin, documents, versions, maxRevisions = 200 }) {
const state = {
admin,
documents,
versions,
maxRevisions,
doc: null,
space: {},
meta: {},
pauseDepth: 0,
commitTimer: null,
commitDelay: 350
};
function paused() {
return state.pauseDepth > 0;
}
async function saveDoc() {
if (!state.doc) return;
await state.documents.put(state.doc.id, clone(state.doc));
await state.admin.put('current_document_id', state.doc.id);
}
async function loadSnapshot(doc, revId = null) {
if (!doc) {
state.space = {};
state.meta = {};
return null;
}
const rid = revId || doc.cursor_rev || doc.head_rev || null;
if (!rid) {
state.space = {};
state.meta = {};
return null;
}
const rec = await state.versions.get(revKey(doc.id, rid));
const snap = rec?.snapshot || {};
state.space = clone(snap.space || {});
state.meta = clone(snap.meta || {});
return rec || null;
}
async function maybePruneRevisions() {
const order = Array.isArray(state.doc?.rev_order) ? state.doc.rev_order : [];
const over = order.length - state.maxRevisions;
if (over <= 0) return;
const purge = order.splice(0, over);
for (const rid of purge) {
await state.versions.remove(revKey(state.doc.id, rid));
}
if (state.doc.cursor_rev && !order.includes(state.doc.cursor_rev)) {
state.doc.cursor_rev = order[0] || null;
}
if (state.doc.head_rev && !order.includes(state.doc.head_rev)) {
state.doc.head_rev = order[order.length - 1] || null;
}
}
async function commit(op_type = 'autosave', label = 'autosave') {
if (!state.doc || paused()) return null;
const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order.slice() : [];
const cursor = state.doc.cursor_rev || null;
const cursorIndex = cursor ? order.indexOf(cursor) : -1;
if (cursorIndex >= 0 && cursorIndex < order.length - 1) {
const remove = order.slice(cursorIndex + 1);
for (const rid of remove) {
await state.versions.remove(revKey(state.doc.id, rid));
}
order.length = cursorIndex + 1;
}
const parent = order.length ? order[order.length - 1] : null;
const rid = uid();
await state.versions.put(revKey(state.doc.id, rid), {
doc_id: state.doc.id,
rev_id: rid,
parent_rev: parent,
created_at: Date.now(),
op_type,
label,
snapshot: {
space: clone(state.space),
meta: clone(state.meta)
}
});
order.push(rid);
state.doc.rev_order = order;
state.doc.head_rev = rid;
state.doc.cursor_rev = rid;
state.doc.updated_at = Date.now();
await maybePruneRevisions();
await saveDoc();
return rid;
}
function scheduleCommit(op_type = 'autosave', label = 'autosave') {
if (!state.doc || paused()) return;
clearTimeout(state.commitTimer);
state.commitTimer = setTimeout(() => {
state.commitTimer = null;
commit(op_type, label).catch(error => console.trace(error));
}, state.commitDelay);
}
async function createDoc(name = 'Untitled') {
const id = uid();
const now = Date.now();
state.doc = {
id,
name: title(name),
created_at: now,
updated_at: now,
head_rev: null,
cursor_rev: null,
rev_order: []
};
state.space = {};
state.meta = {};
await commit('document.create', 'document.create');
return clone(state.doc);
}
async function restoreOrCreate() {
let docId = await state.admin.get('current_document_id');
let doc = docId ? await state.documents.get(docId) : null;
if (!doc) {
const listed = await state.documents.iterate({ map: true }) || {};
const docs = Object.values(listed);
if (docs.length) {
docs.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
doc = docs[0];
}
}
if (!doc) {
return createDoc('Untitled');
}
state.doc = clone(doc);
await loadSnapshot(state.doc);
await saveDoc();
return clone(state.doc);
}
async function open(docId, { autosave = true } = {}) {
if (autosave) {
await flush();
await commit('document.autosave', 'document.autosave');
}
const next = await state.documents.get(docId);
if (!next) throw new Error(`document ${docId} missing`);
state.doc = clone(next);
await loadSnapshot(state.doc);
await saveDoc();
return clone(state.doc);
}
async function rename(docId, name) {
const doc = await state.documents.get(docId);
if (!doc) return null;
doc.name = title(name);
doc.updated_at = Date.now();
await state.documents.put(doc.id, doc);
if (state.doc?.id === doc.id) {
state.doc = clone(doc);
await state.admin.put('current_document_id', doc.id);
}
return clone(doc);
}
async function list() {
const map = await state.documents.iterate({ map: true }) || {};
return Object.values(map)
.sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
}
async function remove(docId) {
const id = String(docId || '');
if (!id) return { deleted: false, switched: false, current: clone(state.doc) };
await flush();
const doc = await state.documents.get(id);
if (!doc) return { deleted: false, switched: false, current: clone(state.doc) };
const revs = Array.isArray(doc.rev_order) ? doc.rev_order : [];
for (const rid of revs) {
await state.versions.remove(revKey(id, rid));
}
await state.documents.remove(id);
if (state.doc?.id !== id) {
return { deleted: true, switched: false, current: clone(state.doc) };
}
const map = await state.documents.iterate({ map: true }) || {};
const docs = Object.values(map).sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
if (!docs.length) {
await createDoc('Untitled');
return { deleted: true, switched: true, current: clone(state.doc) };
}
state.doc = clone(docs[0]);
await loadSnapshot(state.doc);
await saveDoc();
return { deleted: true, switched: true, current: clone(state.doc) };
}
async function undo() {
await flush();
if (!state.doc) return false;
const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : [];
if (order.length < 2) return false;
const idx = order.indexOf(state.doc.cursor_rev);
if (idx <= 0) return false;
state.doc.cursor_rev = order[idx - 1];
state.doc.updated_at = Date.now();
await loadSnapshot(state.doc, state.doc.cursor_rev);
await saveDoc();
return true;
}
async function redo() {
await flush();
if (!state.doc) return false;
const order = Array.isArray(state.doc.rev_order) ? state.doc.rev_order : [];
const idx = order.indexOf(state.doc.cursor_rev);
if (idx < 0 || idx >= order.length - 1) return false;
state.doc.cursor_rev = order[idx + 1];
state.doc.updated_at = Date.now();
await loadSnapshot(state.doc, state.doc.cursor_rev);
await saveDoc();
return true;
}
async function flush() {
if (state.commitTimer) {
clearTimeout(state.commitTimer);
state.commitTimer = null;
await commit('autosave.flush', 'autosave.flush');
}
}
const spaceStore = {
async put(key, value) {
state.space[String(key)] = clone(value);
scheduleCommit('space.put', `space.put:${key}`);
return value;
},
async remove(key) {
delete state.space[String(key)];
scheduleCommit('space.remove', `space.remove:${key}`);
return true;
},
async get(key) {
return clone(state.space[String(key)]);
},
async iterate(opt = {}) {
if (opt?.map) {
return clone(state.space);
}
return Object.entries(state.space || {});
}
};
return {
spaceStore,
pause() {
state.pauseDepth++;
},
resume() {
state.pauseDepth = Math.max(0, state.pauseDepth - 1);
},
setMeta(meta = {}) {
state.meta = clone(meta || {});
scheduleCommit('meta.set', 'meta.set');
},
getMeta() {
return clone(state.meta || {});
},
getSpace() {
return clone(state.space || {});
},
get current() {
return clone(state.doc);
},
restoreOrCreate,
create: createDoc,
open,
rename,
delete: remove,
list,
commit,
flush,
undo,
redo
};
}

View file

@ -2,7 +2,6 @@
import { THREE } from '../ext/three.js';
const { Matrix4, Matrix3, Vector3, Box3 } = THREE;
import { license as motoLicense } from '../moto/license.js';
// geometry helper functions
const geom = {

View file

@ -929,7 +929,7 @@ class MeshTool {
}
pitch = p.round(3);
geom.log(`gear pitch radius: ${pitch}`);
log(`gear pitch radius: ${pitch}`);
}
return { gear, pitch };

View file

@ -35,6 +35,8 @@ function log(msg) {
return worker.publish("mesh.log", msg);
}
self.log = log;
function cacheUpdate(id, data) {
return Object.assign(cache[id], data);
}

View file

@ -3,9 +3,9 @@
const terms = {
COPYRIGHT: "Copyright (C) Stewart Allen <sa@grid.space> - All Rights Reserved",
LICENSE: "See the license.md file included with the source distribution",
VERSION: "4.6.0"
VERSION: "4.7.0"
};
export const beta = 4600;
export const beta = 0;
export const license = terms;
export const version = terms.VERSION;

121
src/moto/opfs.js Normal file
View file

@ -0,0 +1,121 @@
const root = await navigator.storage?.getDirectory();
function resolvePath(path) {
if (!path) {
return [];
} else if (typeof path === 'string') {
path = path.trim();
while (path.charAt(0) === '/') {
path = path.substring(1);
}
return path.length ? path.split('/') : [];
} else if (Array.isArray(path)) {
return path;
} else {
throw "invalid path value";
}
}
export async function dirHandle(path, options = { create: true }) {
path = resolvePath(path);
if (path.length === 0) {
return root;
}
let dir = root;
for (let tok of path) {
try {
dir = await dir.getDirectoryHandle(tok, options);
} catch (error) {
if (options.report) {
console.log({ path, error });
}
return undefined;
}
}
return dir;
}
export async function fileHandle(path, options = { create: false }) {
path = resolvePath(path);
if (path.length === 0) {
return undefined;
}
let target = path.pop();
let dir = await dirHandle(path);
return dir.getFileHandle(target, options);
}
export async function clear() {
for await (let name of root.keys()) {
await root.removeEntry(name, { recursive: true });
}
}
export async function remove(path) {
path = resolvePath(path);
if (path.length === 0) {
return clear();
}
let target = path.pop();
let dir = await dirHandle(path);
console.log({ dir, target });
await dir.removeEntry(target, { recursive: true });
}
export async function entries(path) {
return (await dirHandle(path)).entries();
}
export async function values(path) {
return (await dirHandle(path)).values();
}
export async function keys(path) {
return (await dirHandle(path)).keys();
}
export async function tree(path) {
path = resolvePath(path);
let dir = await dirHandle(path);
return treeFrom(dir, path, []);
}
async function treeFrom(dir, path, list) {
let entries = await dir.entries()
for await (let [ name, handle ] of entries) {
path.push(name);
if (handle.kind === 'directory') {
await treeFrom(handle, path, list);
} else {
list.push(path.join('/'));
}
path.pop();
}
return list;
}
export async function getText(path) {
let handle = await fileHandle(path);
let file = await handle.getFile();
return file.text();
}
export async function putText(path, text) {
let handle = await fileHandle(path, { create: true });
let stream = await handle.createWritable();
await stream.write(text);
return stream.close();
}
export const OPFS = {
dirHandle,
fileHandle,
entries,
keys,
values,
tree,
clear,
remove,
getText,
putText
};

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(),
@ -227,15 +235,33 @@ class Orbit extends EventDispatcher {
this.setPosition = function(set) {
thetaSet = firstValue([set.left, set.theta, thetaSet]);
phiSet = firstValue([set.up, set.phi, phiSet]);
if (set.panX !== undefined) this.target.x = set.panX;
if (set.panY !== undefined) this.target.y = set.panY;
if (set.panZ !== undefined) this.target.z = set.panZ;
let target = this.target;
let position = this.object.position;
if (set.posX !== undefined) position.x = set.posX;
if (set.posY !== undefined) position.y = set.posY;
if (set.posZ !== undefined) position.z = set.posZ;
if (set.panX !== undefined) target.x = set.panX;
if (set.panY !== undefined) target.y = set.panY;
if (set.panZ !== undefined) target.z = set.panZ;
if (set.scale !== undefined) scale = set.scale;
else scale = 1;
this.update();
};
this.getPosition = function(scaled) {
this.getPosition = function({ scaled } = { scaled: false }) {
let t = this.target,
pos = { left:theta, up:phi, panX:t.x, panY:t.y, panZ:t.z, scale:scaled ? scaleSave : 1 };
p = this.object.position,
pos = {
left: theta,
up: phi,
panX: t.x,
panY: t.y,
panZ: t.z,
posX: p.x,
posY: p.y,
posZ: p.z,
scale: scaled ? scaleSave : undefined
};
return pos;
};
@ -325,11 +351,13 @@ class Orbit extends EventDispatcher {
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (lastPosition.distanceToSquared(this.object.position) > EPS
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS) {
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS
|| Math.abs(lastZoom - this.object.zoom) > EPS) {
this.dispatchEvent(changeEvent);
lastPosition.copy(this.object.position);
lastQuaternion.copy(this.object.quaternion);
lastZoom = this.object.zoom;
if (notify) notify(position, true);
} else {
if (notify) notify(position, false);
@ -362,6 +390,15 @@ class Orbit extends EventDispatcher {
if (scope.enabled === false) return;
event.preventDefault();
// keep wheel as dolly, but treat middle-button drag like right-button drag
// in default orbit bindings.
const touchSynthesized = Boolean(event?.sourceCapabilities?.firesTouchEvents);
if (!touchSynthesized
&& event.button === MOUSE.MIDDLE
&& scope.mouseButtons.ZOOM === MOUSE.MIDDLE
&& scope.mouseButtons.PAN === MOUSE.RIGHT) {
state = STATE.PAN;
} else {
switch (event.button) {
case scope.mouseButtons.ORBIT:
state = event.metaKey ? STATE.PAN : STATE.ROTATE;
@ -373,6 +410,7 @@ class Orbit extends EventDispatcher {
state = STATE.PAN;
break;
}
}
switch (state) {
case STATE.ROTATE:
@ -467,14 +505,14 @@ class Orbit extends EventDispatcher {
if (event.wheelDelta !== undefined) {
// Chrome/Safari wheelDelta: scroll up = +120, scroll down = -120
// Negate to match deltaY convention
delta = -event.wheelDelta;
delta = event.wheelDelta;
} else if (event.detail !== undefined) {
// Old Firefox DOMMouseScroll detail: scroll up = -3, scroll down = +3
delta = event.detail * 40; // Normalize to pixel values
delta = -event.detail * 40; // Normalize to pixel values
} else if (event.deltaY !== undefined) {
// Modern browsers deltaY: scroll up = negative, scroll down = positive
// Already matches our convention
delta = event.deltaY;
delta = -event.deltaY;
// Firefox's deltaMode indicates the unit of deltaY
// DOM_DELTA_PIXEL (0x00) - pixels
// DOM_DELTA_LINE (0x01) - lines (default for Firefox, ~3 units per notch)
@ -681,7 +719,23 @@ class Orbit extends EventDispatcher {
this.onMouseUp = onMouseUp;
domEl.addEventListener('contextmenu', function (event) { event.preventDefault() }, false);
this.dispose = function() {
domEl.removeEventListener('contextmenu', onContextMenu, false);
domEl.removeEventListener('mousedown', onMouseDown, false);
domEl.removeEventListener('wheel', onMouseWheel, false);
domEl.removeEventListener('mousewheel', onMouseWheel, false);
domEl.removeEventListener('DOMMouseScroll', onMouseWheel, false);
domEl.removeEventListener('touchstart', touchstart, false);
domEl.removeEventListener('touchend', touchend, false);
domEl.removeEventListener('touchmove', touchmove, false);
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
window.removeEventListener('keydown', onKeyDown, false);
};
function onContextMenu(event) { event.preventDefault() }
domEl.addEventListener('contextmenu', onContextMenu, false);
domEl.addEventListener('mousedown', onMouseDown, false);
domEl.addEventListener('wheel', onMouseWheel, false); // Modern standard (Chrome, Safari, Firefox)
domEl.addEventListener('mousewheel', onMouseWheel, false); // Legacy Chrome/Safari

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 = 0.8;
const VOID_PAN_SPEED_ORTHO = 2.0;
const VOID_ZOOM_SPEED_PERSPECTIVE_MULT = 1.35;
const VOID_ZOOM_SPEED_ORTHO_MULT = 2.2;
class Trackball {
constructor(object, domElement, notify, slider) {
this.object = object;
this.domElement = domElement !== undefined ? domElement : document;
this.control = new TrackballControls(object, this.domElement);
this.control.staticMoving = true;
this.control.dynamicDampingFactor = 0;
this.control.rotateSpeed = VOID_ROTATE_SPEED;
this.control.zoomSpeed = 2.0;
this.control.panSpeed = VOID_PAN_SPEED_PERSPECTIVE;
this.target = this.control.target;
this.center = this.target;
this.mouseDefault = {
ORBIT: MOUSE.LEFT,
ZOOM: MOUSE.MIDDLE,
PAN: MOUSE.RIGHT
};
this.mouseOnshape = {
ORBIT: MOUSE.RIGHT,
ZOOM: MOUSE.LEFT,
PAN: MOUSE.MIDDLE
};
this.mouseVoid = {
ORBIT: MOUSE.RIGHT,
PAN: MOUSE.MIDDLE
};
this.mouseButtons = this.mouseDefault;
this.orbitPivotOnRight = false;
this.continuousRotate = true;
this.reverseZoom = false;
this.zoomSpeed = 1.0;
this._keysDisabled = false;
this.isTrackballAdapter = true;
const mapButtons = () => {
const actions = {
LEFT: -1,
MIDDLE: -1,
RIGHT: -1
};
const bind = this.mouseButtons || this.mouseDefault;
const buttonToAction = Object.create(null);
if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE;
if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY;
if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN;
actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT;
actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE;
actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT;
this.control.mouseButtons = actions;
};
mapButtons();
const emitNotify = (moved) => {
if (notify) notify(this.object.position, moved);
};
this._emitNotify = emitNotify;
this._lastPosition = this.object.position.clone();
this._lastQuaternion = this.object.quaternion.clone();
this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1;
// Keep Space idle-halo semantics identical to Orbit: any control change
// is treated as active camera motion.
this.control.addEventListener('change', () => {
this._emitNotify?.(true);
});
// Mirror Orbit semantics: always signal notify from update(), with moved=true/false.
// Patch underlying control.update() so internal handlers also flow through this path.
const rawUpdate = this.control.update.bind(this.control);
this.control.update = (...args) => {
rawUpdate(...args);
const moved =
this._lastPosition.distanceToSquared(this.object.position) > EPS
|| 8 * (1 - this._lastQuaternion.dot(this.object.quaternion)) > EPS
|| Math.abs((this.object.zoom || 1) - this._lastZoom) > EPS;
this._emitNotify?.(moved);
if (moved) {
this._lastPosition.copy(this.object.position);
this._lastQuaternion.copy(this.object.quaternion);
this._lastZoom = this.object.zoom !== undefined ? this.object.zoom : 1;
}
};
this._animating = false;
this._raf = null;
this._tick = () => {
if (!this._animating) return;
if (this.control.enabled) {
this.control.update();
}
this._raf = self.requestAnimationFrame(this._tick);
};
this._startTick = () => {
if (this._animating) return;
this._animating = true;
this._tick();
};
this._stopTick = () => {
this._animating = false;
if (this._raf) {
self.cancelAnimationFrame(this._raf);
this._raf = null;
}
};
this._onPointerDown = (event) => {
const b = event?.button;
if (b === BUTTON.LEFT || b === BUTTON.MIDDLE || b === BUTTON.RIGHT) {
this._startTick();
}
};
this._onPointerUp = () => this._stopTick();
this._onPointerCancel = () => this._stopTick();
this._onWheel = () => {
if (!this.control.enabled) return;
// Run after Trackball's wheel handler mutates zoom deltas.
self.requestAnimationFrame(() => {
if (this.control.enabled) {
this.control.update();
}
});
};
if (this.domElement?.addEventListener) {
this.domElement.addEventListener('pointerdown', this._onPointerDown, true);
this.domElement.addEventListener('wheel', this._onWheel, false);
}
if (this.domElement?.ownerDocument?.addEventListener) {
this.domElement.ownerDocument.addEventListener('pointerup', this._onPointerUp, true);
this.domElement.ownerDocument.addEventListener('pointercancel', this._onPointerCancel, true);
}
Object.defineProperty(this, 'enabled', {
get: () => this.control.enabled,
set: (v) => { this.control.enabled = !!v; }
});
Object.defineProperty(this, 'noKeys', {
get: () => !!this._keysDisabled,
set: (v) => {
const next = !!v;
if (next === this._keysDisabled) return;
this._keysDisabled = next;
if (typeof window !== 'undefined') {
if (next) {
window.removeEventListener('keydown', this.control._onKeyDown);
window.removeEventListener('keyup', this.control._onKeyUp);
} else {
window.addEventListener('keydown', this.control._onKeyDown);
window.addEventListener('keyup', this.control._onKeyUp);
}
}
}
});
Object.defineProperty(this, 'minDistance', {
get: () => this.control.minDistance,
set: (v) => { this.control.minDistance = v; }
});
Object.defineProperty(this, 'maxDistance', {
get: () => this.control.maxDistance,
set: (v) => { this.control.maxDistance = v; }
});
}
setMouse(bindings) {
this.mouseButtons = bindings || this.mouseDefault;
const bind = this.mouseButtons || this.mouseDefault;
const actions = {
LEFT: -1,
MIDDLE: -1,
RIGHT: -1
};
const buttonToAction = Object.create(null);
if (bind.ORBIT !== undefined) buttonToAction[bind.ORBIT] = ACTION.ROTATE;
if (bind.ZOOM !== undefined) buttonToAction[bind.ZOOM] = ACTION.DOLLY;
if (bind.PAN !== undefined) buttonToAction[bind.PAN] = ACTION.PAN;
actions.LEFT = buttonToAction[BUTTON.LEFT] ?? actions.LEFT;
actions.MIDDLE = buttonToAction[BUTTON.MIDDLE] ?? actions.MIDDLE;
actions.RIGHT = buttonToAction[BUTTON.RIGHT] ?? actions.RIGHT;
this.control.mouseButtons = actions;
}
setOrbitPivotOnRight(enabled) {
this.orbitPivotOnRight = !!enabled;
}
setContinuousRotate(enabled) {
this.continuousRotate = !!enabled;
}
setZoom(reverse, speed) {
this.reverseZoom = !!reverse;
this.zoomSpeed = speed || 1.0;
const mult = this.object.isOrthographicCamera
? VOID_ZOOM_SPEED_ORTHO_MULT
: VOID_ZOOM_SPEED_PERSPECTIVE_MULT;
this.control.zoomSpeed = this.zoomSpeed * mult;
}
getTarget() {
return this.control.target;
}
setTarget(t) {
this.control.target.copy(t);
}
setPosition(set) {
const t = this.control.target;
if (set.panX !== undefined) t.x = set.panX;
if (set.panY !== undefined) t.y = set.panY;
if (set.panZ !== undefined) t.z = set.panZ;
const hasCamPos = Number.isFinite(set?.camX) && Number.isFinite(set?.camY) && Number.isFinite(set?.camZ);
if (hasCamPos) {
this.object.position.set(set.camX, set.camY, set.camZ);
if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) {
const up = new Vector3(set.upX, set.upY, set.upZ);
if (up.lengthSq() > 1e-12) this.object.up.copy(up.normalize());
}
this.object.lookAt(t);
if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) {
if (this.object.isPerspectiveCamera) {
const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale);
this.object.position.copy(t).add(eye);
} else if (this.object.isOrthographicCamera) {
this.object.zoom = this.object.zoom / set.scale;
this.object.updateProjectionMatrix();
}
}
return;
}
let off = this.object.position.clone().sub(t);
let radius = off.length();
if (!isFinite(radius) || radius <= 0) radius = 1;
const left = set.left !== undefined ? set.left : Math.atan2(off.x, off.z);
const up = set.up !== undefined ? set.up : Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y);
off = new Vector3(
radius * Math.sin(up) * Math.sin(left),
radius * Math.cos(up),
radius * Math.sin(up) * Math.cos(left)
);
// Avoid singular lookAt matrices at poles by ensuring camera.up is not parallel to view direction.
const viewDir = off.clone().normalize();
let upVec = null;
if (Number.isFinite(set?.upX) && Number.isFinite(set?.upY) && Number.isFinite(set?.upZ)) {
upVec = new Vector3(set.upX, set.upY, set.upZ);
} else {
upVec = this.object.up.clone();
}
if (upVec.lengthSq() < 1e-12) upVec.set(0, 1, 0);
upVec.projectOnPlane(viewDir);
if (upVec.lengthSq() < 1e-8) upVec = new Vector3(0, 0, 1).projectOnPlane(viewDir);
if (upVec.lengthSq() < 1e-8) upVec = new Vector3(1, 0, 0).projectOnPlane(viewDir);
if (upVec.lengthSq() > 1e-12) this.object.up.copy(upVec.normalize());
this.object.position.copy(t).add(off);
this.object.lookAt(t);
if (set.scale !== undefined && isFinite(set.scale) && set.scale > 0) {
if (this.object.isPerspectiveCamera) {
const eye = this.object.position.clone().sub(t).multiplyScalar(set.scale);
this.object.position.copy(t).add(eye);
} else if (this.object.isOrthographicCamera) {
this.object.zoom = this.object.zoom / set.scale;
this.object.updateProjectionMatrix();
}
}
}
getPosition(scaled) {
const t = this.control.target;
const off = this.object.position.clone().sub(t);
const left = Math.atan2(off.x, off.z);
const up = Math.atan2(Math.sqrt(off.x * off.x + off.z * off.z), off.y);
return {
left,
up,
panX: t.x,
panY: t.y,
panZ: t.z,
camX: this.object.position.x,
camY: this.object.position.y,
camZ: this.object.position.z,
upX: this.object.up.x,
upY: this.object.up.y,
upZ: this.object.up.z,
scale: scaled ? (this.object.isOrthographicCamera ? 1 / this.object.zoom : 1) : 1
};
}
update() {
this.control.panSpeed = this.object.isOrthographicCamera
? VOID_PAN_SPEED_ORTHO
: VOID_PAN_SPEED_PERSPECTIVE;
const zoomMult = this.object.isOrthographicCamera
? VOID_ZOOM_SPEED_ORTHO_MULT
: VOID_ZOOM_SPEED_PERSPECTIVE_MULT;
this.control.zoomSpeed = this.zoomSpeed * zoomMult;
this.control.update();
}
addEventListener(type, listener) {
this.control.addEventListener(type, listener);
}
removeEventListener(type, listener) {
this.control.removeEventListener(type, listener);
}
dispatchEvent(event) {
this.control.dispatchEvent(event);
}
reset() {
this.control.reset();
}
onMouseUp() {
// TrackballControls manages pointer lifecycle internally.
}
resetInputState() {
// Clear any latched key/mouse state (e.g. if native prompt swallowed keyup/mouseup)
// and ensure key listeners are restored according to noKeys policy.
this.control.state = -1;
this.control.keyState = -1;
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', this.control._onKeyDown);
window.removeEventListener('keyup', this.control._onKeyUp);
if (!this._keysDisabled) {
window.addEventListener('keydown', this.control._onKeyDown);
window.addEventListener('keyup', this.control._onKeyUp);
}
}
}
dispose() {
this._stopTick();
if (this.domElement?.removeEventListener) {
this.domElement.removeEventListener('pointerdown', this._onPointerDown, true);
this.domElement.removeEventListener('wheel', this._onWheel, false);
}
if (this.domElement?.ownerDocument?.removeEventListener) {
this.domElement.ownerDocument.removeEventListener('pointerup', this._onPointerUp, true);
this.domElement.ownerDocument.removeEventListener('pointercancel', this._onPointerCancel, true);
}
this.control.dispose();
}
}
export { Trackball };

Some files were not shown because too many files have changed in this diff Show more